fix(integrations): escalate a long transient failure streak, and scope a success to its own leg

A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 14:19:13 +02:00
1 parent 84e38ab110
commit 527dd6c1fa
32 files changed
+823 -65

No files matched your search

+8
View File
@@ -168,6 +168,7 @@ export const GET = apiHandler(async () => {
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
failingSince: string | null;
},
opts: {
connected?: boolean;
@@ -181,6 +182,7 @@ export const GET = apiHandler(async () => {
state: snapshot.state,
lastSuccessAt: snapshot.lastSuccessAt,
lastAttemptAt: snapshot.lastAttemptAt,
failingSinceAt: snapshot.failingSince,
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
cadence: INTEGRATION_CADENCE[integration],
now,
@@ -381,6 +383,12 @@ interface IntegrationViewModel {
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
/**
* Start of the current unbroken failure streak, or null when the provider is
* not failing. `syncHealth.since` is derived from it; the raw instant rides
* along so a client can render the age without re-deriving the verdict.
*/
failingSince: string | null;
/**
* The server-resolved liveness verdict. Present on EVERY entry, whether or
* not a card renders it — the unit of coverage is the envelope entry, not