From bef657c2ed8a7ed7be1b7c7d232dc037d76ee39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 29 May 2026 14:44:56 +0200 Subject: [PATCH] fix(currency): degrade gracefully when rates return 404 (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `PHP-LARAVEL-2M` (High, escalating, 38 events) — `RuntimeException: Failed to fetch currency rates for xxx on 2025-12-31: HTTP 404` crashing `/dashboard`. A user holds an account in currency `xxx` (ISO 4217 "no/unknown currency"). No rate file exists for it, so every candidate URL returns 404. `CurrencyConversionService::fetchRates` threw an unhandled `RuntimeException` that bubbled past the graceful-degradation logic in `ExchangeRateService::convert` and broke the whole dashboard render. ## Fix Distinguish **permanent** 404s from **transient** failures: - All sources return 404 → log a warning and return `[]`. Callers already handle empty rates (return unconverted / zero amount). - Timeouts / 5xx / connection errors → still throw, so real outages stay visible. ## Tests - New: unknown base currency with all-404 sources returns `0.0` instead of throwing. - Existing "throws when both primary and fallback fail" (500s) still passes — transient errors still surface. Fixes PHP-LARAVEL-2M. --- app/Services/CurrencyConversionService.php | 16 +++++++++++++++- tests/Feature/CurrencyConversionServiceTest.php | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/Services/CurrencyConversionService.php b/app/Services/CurrencyConversionService.php index 8dfa2997..94673b04 100644 --- a/app/Services/CurrencyConversionService.php +++ b/app/Services/CurrencyConversionService.php @@ -86,6 +86,11 @@ class CurrencyConversionService foreach ($this->rateUrls($currency, $candidateDate) as $url) { try { $response = Http::timeout(10)->get($url); + + if ($response->notFound()) { + continue; + } + $response->throw(); return $response->json($currency) ?? []; @@ -95,7 +100,16 @@ class CurrencyConversionService } } - throw new RuntimeException("Failed to fetch currency rates for {$currency} on {$date}: {$lastException?->getMessage()}", 0, $lastException); + if ($lastException !== null) { + throw new RuntimeException("Failed to fetch currency rates for {$currency} on {$date}: {$lastException->getMessage()}", 0, $lastException); + } + + Log::warning('Currency rates unavailable, all sources returned 404', [ + 'currency' => $currency, + 'date' => $date, + ]); + + return []; } /** diff --git a/tests/Feature/CurrencyConversionServiceTest.php b/tests/Feature/CurrencyConversionServiceTest.php index 94067c47..aa3581c2 100644 --- a/tests/Feature/CurrencyConversionServiceTest.php +++ b/tests/Feature/CurrencyConversionServiceTest.php @@ -73,6 +73,18 @@ test('throws when both primary and fallback fail', function () { $service->convert('BTC', 'EUR', 1.0, '2026-01-15'); })->throws(RuntimeException::class); +test('returns zero when base currency is unknown and all sources return 404', function () { + Http::fake([ + 'cdn.jsdelivr.net/*' => Http::response('Not Found', 404), + 'currency-api.pages.dev/*' => Http::response('Not Found', 404), + ]); + + $service = new CurrencyConversionService; + $result = $service->convert('BTC', 'XXX', 1.0, '2026-01-15'); + + expect($result)->toBe(0.0); +}); + test('falls back to previous historical date when requested release is missing', function () { Http::fake([ 'cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2025-12-10/*' => Http::response('Not Found', 404),