From f469749efcf18b0245eb1583f057c449972e87cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 21:30:53 +0200 Subject: [PATCH] refactor(withings): route activity sync catch-block through typed classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export `recordWithingsSyncFailure` from sync.ts and migrate the activity-sync catch-block to consume it. The shared helper routes through `classifyError`, which reads `WithingsApiError.classification` directly and falls back to the legacy regex only for un-prototyped errors (pg-boss JSON round-trip). Drops the call-site re-parse of `extractWithingsStatus` + `isWithingsRefreshReauthFailure`, removing one duplicate of the classification taxonomy. The body-status 293 / 601 cases now classify as `persistent` / `transient` respectively instead of the legacy regex default of `transient` for everything outside 100/101/102 + 200-299. BL-P3-2 defence-in-depth — the body-status 403 short-circuit stays inline as a call-site override because 403 is not a documented Withings response code (only the HTTP layer emits it for scope-gated resources), so it has no entry in the shared classifier table. --- src/lib/withings/sync-activity.ts | 55 ++++++++++++++++++------------- src/lib/withings/sync.ts | 2 +- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index e58d2c96b..36450b5f3 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -48,11 +48,7 @@ import { WithingsApiError, classifyWithingsResponse, } from "./response-classifier"; -import { - extractWithingsStatus, - isWithingsRefreshReauthFailure, -} from "./sync"; -import { getValidToken } from "./sync"; +import { getValidToken, recordWithingsSyncFailure } from "./sync"; import { isReauthRequired, parkIntegrationAtReauth, @@ -278,24 +274,37 @@ export async function syncUserActivity( ymd(now), ); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const status = extractWithingsStatus(message); - // v1.4.26 defence-in-depth — a 403 from a scope-gated endpoint - // always means scope-missing or token-revoked. Park at - // `error_reauth` rather than `transient` so pg-boss stops - // retrying and the 3-strike alert doesn't fire. The early - // scope-skip above covers the common case; this catches the - // race where Withings revokes scope between OAuth and the next - // sync without invalidating the refresh token. - const isReauth = - isWithingsRefreshReauthFailure(message) || status === "403"; - await recordSyncFailure({ - userId, - integration: "withings", - kind: isReauth ? "reauth_required" : "transient", - message, - errorCode: status, - }); + // v1.4.43 W7-B3 — typed-classification path. The thrown + // `WithingsApiError` from `fetchWithingsActivity` carries the + // verdict + `withingsStatus` directly; `recordWithingsSyncFailure` + // routes it through `classifyError`, which falls back to the regex + // parse for plain `Error` instances (e.g. a pg-boss retry that lost + // the prototype during JSON round-trip). Pre-v1.4.43 this branch + // re-parsed the message via `extractWithingsStatus` + + // `isWithingsRefreshReauthFailure`, duplicating logic the + // response-classifier already owns. + // + // BL-P3-2 defence-in-depth — a Withings 403 on this scope-gated + // endpoint always means scope-missing or token-revoked; force the + // verdict to `reauth_required` so pg-boss stops retrying and the + // 3-strike alert still pages on the unexpected case. The + // response-classifier's body-status taxonomy doesn't list 403 + // (Withings emits 403 only through the HTTP layer for scope-gated + // resources), so the override has to live at the call site rather + // than in the shared classifier. + const withingsStatus = + err instanceof WithingsApiError ? err.withingsStatus : undefined; + if (withingsStatus === 403) { + await recordSyncFailure({ + userId, + integration: "withings", + kind: "reauth_required", + message: err instanceof Error ? err.message : String(err), + errorCode: "403", + }); + throw err; + } + await recordWithingsSyncFailure(userId, err); throw err; } diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 3fdd20b7f..8491b630a 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -287,7 +287,7 @@ export async function syncUserMeasurements( * `Error` instances (e.g. a pg-boss job retry that lost the prototype * during the JSON round-trip). */ -async function recordWithingsSyncFailure( +export async function recordWithingsSyncFailure( userId: string, err: unknown, ): Promise {