feat(withings): classify off-responses into transient/reauth/persistent

Withings sometimes replies HTTP 200 with a non-zero body status, empty
measuregrps, or a transient 503. Pre-v1.4.42 the client surfaced all of
those as identical `Error("Withings <verb> error: <status>")` throws,
and the sync layer used a message regex to bucket reauth vs transient.
That made rate-limit (601) and contract-mismatch (293/294) responses
silently retry forever without ever reaching an admin alert.

Adds `src/lib/withings/response-classifier.ts` — a pure
`(httpStatus, body) -> {success | transient | reauth_required |
persistent}` function plus a `WithingsApiError` subclass carrying the
verdict. The four client entrypoints (exchangeCode, refreshAccessToken,
fetchMeasurements, subscribeWebhook) and both v2-sync paths
(sync-activity, sync-sleep) now throw the typed error; sync.ts reads
err.classification directly via classifyError() and falls back to the
legacy regex when the prototype is lost on a pg-boss retry.

Extends FailureKind with `persistent`. Persistent failures audit with
kind="persistent" and surface a distinct admin-alert label
("persistent error" / "investigate the upstream contract") but keep
state=error_transient so the next sync still runs once the upstream
side fixes the mismatch.

Three test branches pinned (37 new tests, 103 -> 140 passing in
src/lib/withings + src/lib/integrations).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 19:01:15 +02:00
1 parent cb601fcadd
commit 5bb9b39dec
9 files changed
+881 -40

No files matched your search

+38 -3
View File
@@ -162,7 +162,26 @@ export async function recordSyncSuccess(
});
}
export type FailureKind = "transient" | "reauth_required";
/**
* Failure kinds carried into `recordSyncFailure`.
*
* - `transient` : retry on the next sync; user not blocked.
* - `reauth_required` : permanent revoke / invalid_grant; park at
* `error_reauth` until the user reconnects.
* - `persistent` : contract mismatch (invalid params, missing
* field, unknown action). Surfaces in the
* integration-status card AND audit log so an
* operator can investigate, but does NOT skip
* future sync attempts — those may succeed once
* the upstream side resolves.
*
* v1.4.42 W6 extended this union from `transient | reauth_required` to
* the three-state taxonomy above; the state-mapping function turns
* `persistent` into `error_transient` for now (a Withings 293 still
* lets the next sync run), but the audit detail carries the explicit
* kind so operations can filter.
*/
export type FailureKind = "transient" | "reauth_required" | "persistent";
export interface RecordSyncFailureInput {
userId: string;
@@ -193,6 +212,12 @@ export async function recordSyncFailure(
): Promise<void> {
const { userId, integration, kind, message, errorCode } = input;
const now = new Date();
// State mapping:
// reauth_required → error_reauth (sync entry-point short-circuits)
// transient → error_transient (next sync still runs)
// persistent → error_transient (next sync still runs, but the
// audit detail carries `kind: "persistent"` so
// operations can grep for contract-bug bursts)
const newState: IntegrationState =
kind === "reauth_required" ? "error_reauth" : "error_transient";
@@ -486,7 +511,11 @@ export function formatAdminAlertPayload(input: AlertInput): {
input.integration === "withings" ? "Withings" : "moodLog";
const subjectLabel = input.subjectLabel ?? input.userId;
const reasonLabel =
input.kind === "reauth_required" ? "re-auth required" : "transient error";
input.kind === "reauth_required"
? "re-auth required"
: input.kind === "persistent"
? "persistent error"
: "transient error";
const codeLabel = input.errorCode ? ` (${input.errorCode})` : "";
const trimmed =
input.message.length > 280
@@ -497,7 +526,13 @@ export function formatAdminAlertPayload(input: AlertInput): {
const message =
`${integrationLabel} sync has failed ${input.consecutiveFailures} times in a row for ${subjectLabel}.\n` +
`Last error: ${reasonLabel}${codeLabel} — ${trimmed}\n` +
`Action: ${input.kind === "reauth_required" ? "ask the user to reconnect the integration." : "investigate the upstream service."}`;
`Action: ${
input.kind === "reauth_required"
? "ask the user to reconnect the integration."
: input.kind === "persistent"
? "investigate the upstream contract — params/scope/action likely mismatched."
: "investigate the upstream service."
}`;
return {
title,