Commit Graph

1 Commits

Author SHA1 Message Date
Víctor Falcón 81c32b887b
fix(banking): stop Wise outages from silently parking a bank connection (#757)
Fixes
[PHP-LARAVEL-58](https://whisper-money.sentry.io/issues/PHP-LARAVEL-58).

## What production looks like

There is exactly one Wise connection in production and it has **never
synced** — `last_synced_at` is null. Its entire history is three failed
attempts:

| when | error |
|---|---|
| 2026-07-29 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-03 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-10 | `RequestException` — HTTP 500 from
`/v1/profiles/{id}/activities`, mid-cursor |

All three are upstream. All three were charged to
`consecutive_sync_failures`, now at **2**. At `MAX_SCHEDULED_RETRIES =
3` both `SyncAllBankingConnectionsJob` and `sync:banking` stop
dispatching the connection — permanently, with no email and no reconnect
notice in the UI. The next Wise outage would have been the last sync
this user ever got.

## Three commits

**1. Classify Wise timeouts and 5xx as transient.** `WiseClient` rethrew
everything raw, so an outage looked like an application bug: reported to
Sentry at error level and logged as `Wise API error`. Timeouts and 5xx
now raise `TransientBankingProviderException`, matching
`EnableBankingProvider` (PR #678). The job already understands that type
— warning instead of error, `ShouldntReport`, and a "temporarily
unavailable" message for the user. **401/403 and 429 deliberately stay
raw**: `isAuthError` and `isRateLimitError` match on `RequestException`,
and `resolveRateLimitBackoffUntil` reads `Retry-After` off
`$e->response`. The three request methods now share one private `get()`,
which is where the classification lives.

**2. Keep an empty body from becoming a TypeError.** Introduced by
commit 1: routing everything through `get(): array` turned a 200 with an
empty body into a `TypeError` — neither transient nor suppressed, i.e.
exactly the noise this branch removes. It used to degrade to `[]` via
`$accounts[0] ?? []`. Caught by review.

**3. Stop provider outages from parking a connection for good.** Without
this the branch is only a log-level change: `handleTemporaryError`
incremented the counter for any non-auth throwable, so the
classification never reached the user. A transient failure now surfaces
on the connection (status and message unchanged) without spending a
scheduled retry. Unclassified failures still count, so a genuine defect
still parks the connection.

No data migration needed — the connection is at 2, still under the cap,
so it stays eligible and the counter resets on its first success.

## Trade-off, stated plainly

A provider that is down forever is now retried forever: one job per
cycle, surfacing as a repeating `Banking sync failed` warning. That is
cheap and visible, and strictly better than silently never syncing a
user's bank. Marked with a `ponytail:` comment naming the ceiling.

The flip side is that a **persistent** Wise breakage (an API contract
change, say) now produces no Sentry issue — only warn-level logs and
`banking_sync_logs` rows. Worth knowing before assuming silence means
health.

## Tests

- `WiseTransientErrorTest`: 500 mid-cursor and a cURL 28 timeout both
become `TransientBankingProviderException`; 401/403/429 (dataset) stay
`RequestException`; an empty body still degrades to `[]`.
- `SyncRetryAndLoggingTest`: a transient failure at
`consecutive_sync_failures = 2` leaves it at 2 and below the cap; a
**non**-transient one still takes it to 3.

Local: 490/490 across `tests/Feature/OpenBanking`, `tests/Feature/Jobs`,
`tests/Unit`.

## Follow-ups (found in review, deliberately not here)

- `WiseClient` sets no timeouts; `EnableBankingProvider` uses
`timeout(20)->connectTimeout(5)`, IB uses 5/15. Two of the three prod
failures were 30s timeouts, so bounding them is worth a look — but
shortening the window on a 12-month first sync needs its own thinking.
- `EnableBankingProvider` logs its 5xx at `error` while raising the same
transient exception. Wise is the consistent one here; EB should follow.
- The `ConnectionException` + 5xx catch skeleton now exists three times.
Rule of three is reached — a shared trait would also let
Binance/Coinbase/Bitpanda/IndexaCapital adopt it.
- Both credential-validation call sites catch `\Throwable`, so
connecting Wise during an outage still tells the user "Invalid API
token". Now cheaply fixable by catching the transient type first.
2026-08-10 16:40:09 +02:00