> Sentry's MCP token is still expired, so this came from the production
DB again.
## Two small fixes, and a much bigger finding I am *not* fixing here
I went after the rate-limit problem I flagged as the next big thing. The
headline number is real — **490 rate-limit failures in 14 days**, and
**14 of 18 Trade Republic connections have never once completed a sync**
— but my framing of it was wrong, and the change I first wrote would
have made those users slightly worse off. What survives is two narrow,
verified fixes. The actual cure is scoped at the bottom.
## 1. Recognise a spent daily allowance from what the banks actually say
`resolveRateLimitBackoffUntil` treated a 429 as a daily-allowance error
only when the message contained the literal word `daily`. Enable
Banking's banks say it several other ways, all of which were getting the
one-hour burst fallback:
| wording | events / connections (45d) |
|---|---|
| `[HUB046] Allowed number of accesses exceeded for consent.` | 234 / 88
|
| `Access exceeded` | 94 / 1 |
| `The access on the account has been exceeding the consented
multiplicity per day.` | 39 / 3 |
| `CLO03941 - Operación no disponible. Has superado el número máximo de
accesos.` (+ Catalan twin) | 5 / 2 |
The old one-hour window gave a free natural experiment: the later
same-day runs *did* happen, so I can check whether they recovered.
Across every wording above, **zero** later same-day attempts succeeded.
`Too many requests` is deliberately left on the short path — 1.7% of its
later same-day attempts do recover, so it is not a clean daily reset.
Matched on prose because there is nothing better: `detail.error_name` is
`RateLimitException` for a spent allowance and a plain burst alike.
Marked with a `ponytail:` note, including that a future attempt at a
structured field has to start by logging the full body, since
`error_message` keeps only the first 120 bytes.
**Honest scope**: for the largest widened cohort (HUB046) this saves
almost nothing, because those 429s land on the 18:00 run and the next
scheduled run is already after midnight. The saving is real for banks
that exhaust their allowance earlier — Eurocaja Rural burns its last two
runs every day, Postepay's wording says "per day" outright.
## 2. Stop the backoff from silently swallowing a manual sync
`ConnectionController::sync` cleared the status, the error message and
the failure counter, then dispatched — but left `rate_limited_until`,
which the job checks first. So "Sync started. Transactions will be
updated shortly." was followed by nothing at all. Fix (1) makes that
materially worse, turning a ≤1h swallow into the whole evening for the
banks that exhaust their allowance at 18:00.
The backoff is there to keep the *scheduler* off a provider that asked
us to stop. A person asking for their own connection is a different
request, and PSD2 budgets access with the user present separately from
unattended access. The other "try again" paths already clear everything
else on the connection; this one row was the omission.
## What I dropped, and why
I first also raised the unexplained-429 fallback from 1h to 7h, so it
would outlast the six-hourly schedule instead of expiring five hours
before it — the mechanism really is inert today, and I have the
receipts: of the ten connections currently holding a
`rate_limited_until`, nine are exactly 60 minutes past the run that set
them, because Enable Banking never sends `Retry-After`.
Both reviews showed it is worthless where it would apply, from different
angles, and they were right:
- Trade Republic is 81% of all rate limits, and its **first** run after
UTC midnight 429s as often as its fourth — 79% vs 73% over 21 days. Six
idle hours already don't help, so no sub-day window can.
- The cohort is bimodal: 8 connections with **zero** successes in 21
days, 3 at ~97%. That is a per-consent condition, not something our
cadence controls.
- Meanwhile those connections import transactions on nearly every run. A
7h window settles into 2 runs/day, so it would only have doubled their
worst-case transaction staleness in exchange for nothing.
## The actual cure, deliberately deferred
Both reviews rank two levers above anything in this PR, and they are
what fix the €0-balance users. **This PR does not claim to.**
**(a) Don't discard a run because the balance call was rate-limited.**
This is the whole user-visible bug. Transactions import fine; the 429
lands on `getBalances`, which `EnableBankingSyncer::syncBalances`
rethrows purely so the job can set the backoff — and that discards the
run, so `last_synced_at` is never set. Consequences: the connections
page shows an **indefinite spinner** ("Syncing transactions and
balances…") and polls the server **every 5 seconds** for as long as it
is open, `bank_transactions_email_cutoff_at` is never set so the daily
email never works, and ~11 users see a €0 balance feeding net worth.
Swallowing the 429 into metadata and applying the backoff on the success
path costs zero extra provider calls and fixes all of it. Its own PR
because threading the backoff out of the syncer needs a design, and last
time I let a partial run report success both reviews were right to stop
me.
**(b) Don't spend a provider access to rewrite a balance row we already
have.** `BalanceSyncService` keys the row on `balance_date`, so four
runs a day overwrite one value: ~1,740 balance calls/day producing ~305
new rows, ~82% redundant. This is the fix for the *non*-Trade-Republic
cohort, and the evidence is clean — **every one of the 86 non-TR
rate-limit events in 14 days happened on a connection that already had a
balance row for that day**, and HUB046 fires on the 4th run of the day
and essentially never on the first three (148 events at 18:00 vs 0 at
00:00 and 06:00). Needs a migration: the guard cannot key on
`balance_date` (some banks report yesterday's `reference_date`, so it
would never fire) nor on `updated_at` (an unchanged `updateOrCreate`
leaves the model clean), so it wants an explicit last-read timestamp.
Also noted: the rate-limit message never reaches the user at all —
`applyRateLimitBackoff` leaves the status Active while the connections
page only renders `error_message` for Error connections — and the copy
still says "wait a few minutes".
## Verification
`tests/Feature/OpenBanking`: 365 tests, 355 pass, the **same 10 failures
as clean main** (Inertia page-render tests hitting the SSR `/render`
endpoint). The daily-allowance test became a 7-case dataset covering
every wording production sends, and it fails on all the new ones against
the old matcher; the manual-sync test fails without its one-line fix.
Net −7 lines of test code, because two tests I had written folded into
the existing one. `pint`, `crap` and `dry` green.
## Auto-merge
Enabled. Both changes are small and evidence-backed: the matcher only
moves wordings that provably never recover within the day onto a window
that already existed and is already tested, and the second is a single
column added to an update that already clears three of its siblings.