fix(banking): treat EnableBanking upstream 5xx as transient, not reportable

getTransactions/getBalances classified 401/400/422/ASPSP errors but let any
other RequestException fall through to a raw re-throw, so an upstream 500
('Internal server error') from EnableBanking or the ASPSP propagated and was
report()ed as an app error (Sentry PHP-LARAVEL-3J: 36 events / 3 users).

A 5xx is a transient server-side failure — the same class as a
ConnectionException, which is already wrapped as TransientBankingProviderException
(logged at warning, retried, self-healing). Classify any upstream 5xx the same
way in both getTransactions and getBalances via a new isTransientServerError()
helper, so provider outages retry/self-heal instead of paging Sentry.

Non-5xx paths (401/400/422/ASPSP + genuine 4xx client errors) are unchanged and
stay reportable.
This commit is contained in:
Víctor Falcón 2026-07-15 08:35:02 +02:00
parent 9e237802f6
commit d35bc07c05
2 changed files with 74 additions and 0 deletions

View File

@ -127,6 +127,15 @@ class EnableBankingProvider implements BankingProviderInterface
);
}
if ($this->isTransientServerError($e)) {
throw new TransientBankingProviderException(
'EnableBanking returned a server error while fetching account transactions.',
provider: 'enablebanking',
statusCode: $e->response->status(),
previous: $e,
);
}
if (! $this->isAspspError($e)) {
throw $e;
}
@ -178,6 +187,15 @@ class EnableBankingProvider implements BankingProviderInterface
);
}
if ($this->isTransientServerError($e)) {
throw new TransientBankingProviderException(
'EnableBanking returned a server error while fetching account balances.',
provider: 'enablebanking',
statusCode: $e->response->status(),
previous: $e,
);
}
if (! $this->isAspspError($e)) {
throw $e;
}
@ -230,6 +248,14 @@ class EnableBankingProvider implements BankingProviderInterface
&& ($body['error'] ?? null) === 'ASPSP_ERROR';
}
private function isTransientServerError(RequestException $e): bool
{
// Any upstream 5xx (EnableBanking itself or the ASPSP behind it) is a
// transient server-side failure — same class as a ConnectionException,
// so retry/self-heal rather than report it as an app error.
return $e->response->status() >= 500;
}
private function isExpiredSession(RequestException $e): bool
{
$body = $this->errorBody($e);

View File

@ -59,6 +59,54 @@ test('getTransactions wraps connection failures as non-reportable transient erro
test()->fail('Expected transient banking provider exception.');
});
test('getTransactions wraps an upstream 500 as a non-reportable transient error', function () {
Http::fake([
'api.enablebanking.com/accounts/ext-123/transactions*' => Http::response([
'code' => 500,
'message' => 'Internal server error',
], 500),
]);
$provider = enableBankingProviderForTest();
try {
$provider->getTransactions('ext-123', now()->toDateString(), now()->toDateString());
} catch (TransientBankingProviderException $e) {
expect($e)->toBeInstanceOf(ShouldntReport::class)
->and($e->provider)->toBe('enablebanking')
->and($e->statusCode)->toBe(500)
->and($e->getPrevious())->toBeInstanceOf(RequestException::class);
return;
}
test()->fail('Expected transient banking provider exception.');
});
test('getBalances wraps an upstream 500 as a non-reportable transient error', function () {
Http::fake([
'api.enablebanking.com/accounts/ext-123/balances*' => Http::response([
'code' => 500,
'message' => 'Internal server error',
], 500),
]);
$provider = enableBankingProviderForTest();
try {
$provider->getBalances('ext-123');
} catch (TransientBankingProviderException $e) {
expect($e)->toBeInstanceOf(ShouldntReport::class)
->and($e->provider)->toBe('enablebanking')
->and($e->statusCode)->toBe(500)
->and($e->getPrevious())->toBeInstanceOf(RequestException::class);
return;
}
test()->fail('Expected transient banking provider exception.');
});
test('getTransactions wraps an expired session 401 as a non-reportable expired session error', function () {
Http::fake([
'api.enablebanking.com/accounts/ext-123/transactions*' => Http::response([