mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
1 parent
cb601fcadd
commit
5bb9b39dec
9 files changed
+881
-40
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user