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

@@ -110,11 +110,20 @@ const WARNING_STATES = new Set<IntegrationPillState>([
"parked",
]);
/** The states whose label carries an inline relative timestamp. */
/**
* The states whose label carries an inline relative timestamp.
*
* `warning` is in the set because leaving it out was the whole lie: the pill
* said "sync issue" identically on hour one and on day seventeen, and the age
* — which the mapper computes and passes in — was dropped on the floor. How
* long it has been broken is the only part of that sentence a person can act
* on.
*/
const RELATIVE_STATES = new Set<IntegrationPillState>([
"connected",
"stale",
"stalled",
"warning",
]);
export function IntegrationStatusPill({
@@ -161,14 +161,19 @@ export function pillStateFor(
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
* repaints a fresh "just now" straight after a manual sync); the two aging
* states read the instant that triggered the verdict.
* repaints a fresh "just now" straight after a manual sync); the aging and
* failing states read the instant that triggered the verdict.
*
* `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That
* field is a connection row's own clock, kept fresh by the very sync that is
* erroring, so a provider that had delivered nothing for a fortnight painted a
* "just now" beside its warning.
*/
export function pillTimestampFor(
status: IntegrationStatusViewModel | undefined,
): string | null {
const verdict = status?.syncHealth?.verdict;
if (verdict === "stale" || verdict === "stalled") {
if (verdict === "stale" || verdict === "stalled" || verdict === "failing") {
return status?.syncHealth?.since ?? null;
}
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;