Reuse shared HTTP classifier for WHOOP and Fitbit

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:23:55 +02:00
1 parent 2da7ea0881
commit cf2c83236b
2 files changed
+52 -227

No files matched your search

+25 -108
View File
@@ -1,19 +1,17 @@
/**
* Off-response classifier for Fitbit / Google Health API replies (v1.12.0).
*
* Mirrors the WHOOP classifier (`src/lib/whoop/response-classifier.ts`) but
* speaks the Google Health wire. Like WHOOP, Google signals failure through the
* HTTP status code itself (no `status` field embedded in a 200 body), so this
* classifier is HTTP-status-driven.
*
* Classification buckets:
* Like WHOOP, Google signals failure through the HTTP status code itself (no
* `status` field embedded in a 200 body), so the classification is
* status-driven. The shared core lives in
* `src/lib/integrations/http-status-classifier.ts`; this module re-exports it
* under the Fitbit-specific names callers already use.
*
* - `success` → HTTP 2xx
* - `transient` → retryable: HTTP 429 (rate-limited — honour
* `Retry-After` for backoff at the call site), 5xx
* upstream outages, 3xx CDN/network glitches, and an
* empty / off-spec body where we never reached the
* envelope.
* unknown status where we never reached the envelope.
* - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the
* grant was revoked; the user must redo OAuth. (The
* per-data-class 403 soft-skip lives in `sync.ts`'s
@@ -24,99 +22,37 @@
* request or a contract mismatch that retrying without
* operator intervention will not fix.
*
* The classifier is conservative: anything it doesn't recognise defaults to
* `transient` so a single unknown response doesn't hard-disable the
* integration. The 3-strike admin-alert ladder in `recordSyncFailure` already
* catches the "keeps happening" case for recurring transients.
*
* Unlike WHOOP, there is no `invalid_grant` token-body special-case: Google
* signals a revoked refresh token with a 401 on the token endpoint, which the
* status-driven classifier already buckets as `reauth_required`.
*/
/** Outcome buckets for a Fitbit response. Same shape as WHOOP. */
export type FitbitClassification =
| "success"
| "transient"
| "reauth_required"
| "persistent";
import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export interface ClassifiedFitbitResponse {
classification: FitbitClassification;
/** The HTTP status code that drove the verdict (undefined if none seen). */
httpStatus: number | undefined;
/** Short human-readable label for logs / audit details. */
reason: string;
}
/** Outcome buckets for a Fitbit response. */
export type FitbitClassification = IntegrationClassification;
/**
* Classify a single Fitbit / Google Health API response by its HTTP status.
*
* @param httpStatus The HTTP status from fetch.
*/
export type ClassifiedFitbitResponse = ClassifiedHttpResponse;
/** Classify a single Fitbit / Google Health API response by its HTTP status. */
export function classifyFitbitResponse(
httpStatus: number,
): ClassifiedFitbitResponse {
if (httpStatus >= 200 && httpStatus < 300) {
return { classification: "success", httpStatus, reason: "ok" };
}
// 429 is rate-limit — honour `Retry-After` for backoff at the call site, but
// the verdict is a plain transient so the next sync retries.
if (httpStatus === 429) {
return { classification: "transient", httpStatus, reason: "http_429" };
}
// 401/403 — the bearer token is rejected or the grant revoked. The user has
// to reconnect; do not retry silently. (A per-data-class 403 soft-skip is
// applied in sync.ts before this verdict drives a connection-wide reauth.)
if (httpStatus === 401 || httpStatus === 403) {
return {
classification: "reauth_required",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// Any other 4xx is a malformed request / contract mismatch — persistent.
if (httpStatus >= 400 && httpStatus < 500) {
return {
classification: "persistent",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 5xx upstream outage — retryable.
if (httpStatus >= 500 && httpStatus < 600) {
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 3xx (a CDN / redirect glitch — safeFetch uses redirect:"manual", so a
// surfaced 3xx is unexpected) and everything else defaults to transient.
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}_unknown`,
};
return classifyHttpStatus(httpStatus);
}
/**
* Typed Error subclass carrying the classification verdict so downstream
* `try/catch` blocks can branch without re-parsing the message. Mirrors
* `WhoopApiError`.
* Typed error carrying the classification verdict so downstream `try/catch`
* blocks can branch without re-parsing the message. Extends the shared base
* (message shape `Fitbit <verb> error: <status>` is preserved).
*/
export class FitbitApiError extends Error {
readonly classification: FitbitClassification;
readonly httpStatus: number | undefined;
readonly reason: string;
readonly verb: string;
/**
* The OAuth `error` code from a token-endpoint failure body (e.g.
* `invalid_grant`). Captured for the audit trail; the classifier does not
* branch on it (Google signals a revoked grant with a 401 status).
*/
readonly upstreamError: string | undefined;
export class FitbitApiError extends IntegrationApiError {
constructor(opts: {
verb: string;
classification: FitbitClassification;
@@ -124,20 +60,8 @@ export class FitbitApiError extends Error {
reason: string;
upstreamError?: string;
}) {
const statusLabel =
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
// Cap the message at 1024 chars in the constructor (same bound as
// WhoopApiError) so a misbehaving upstream returning a multi-MB error body
// can't bloat an AuditLog row downstream.
const raw = `Fitbit ${opts.verb} error: ${statusLabel}${errSegment}`;
super(raw.slice(0, 1024));
super({ vendor: "Fitbit", ...opts });
this.name = "FitbitApiError";
this.verb = opts.verb;
this.classification = opts.classification;
this.httpStatus = opts.httpStatus;
this.reason = opts.reason;
this.upstreamError = opts.upstreamError;
}
}
@@ -149,12 +73,5 @@ export class FitbitApiError extends Error {
* for callers that lose the original prototype across a pg-boss retry.
*/
export function classifyFitbitError(err: unknown): FitbitClassification {
if (err instanceof FitbitApiError) return err.classification;
const msg = err instanceof Error ? err.message : String(err);
const m = /Fitbit\s+\w+\s+error:\s*(\d+)/.exec(msg);
if (!m) return "transient";
const status = Number.parseInt(m[1]!, 10);
if (!Number.isFinite(status)) return "transient";
return classifyFitbitResponse(status).classification;
return classifyIntegrationError(err, "Fitbit");
}
+27 -119
View File
@@ -1,112 +1,50 @@
/**
* Off-response classifier for WHOOP API replies.
*
* Mirrors the Withings classifier (`src/lib/withings/response-classifier.ts`)
* but speaks the WHOOP wire instead. WHOOP, unlike Withings, signals failure
* through the HTTP status code itself (no `status` field embedded in a 200
* body), so this classifier is HTTP-status-driven.
*
* Classification buckets:
* WHOOP signals failure through the HTTP status code itself (no `status` field
* embedded in a 200 body), so the classification is status-driven. The shared
* core lives in `src/lib/integrations/http-status-classifier.ts`; this module
* re-exports it under the WHOOP-specific names callers already use.
*
* - `success` → HTTP 2xx
* - `transient` → retryable: HTTP 429 (rate-limited — WHOOP caps at
* 100 req/min + 10 000 req/day per app), 5xx upstream
* outages, 3xx CDN/network glitches, and an empty /
* off-spec body where we never reached the envelope.
* outages, 3xx CDN/network glitches, and an unknown
* status where we never reached the envelope.
* - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the
* grant was revoked; the user must redo OAuth.
* - `persistent` → any other 4xx (400, 404, 422, …) — a malformed
* request or a contract mismatch that retrying without
* operator intervention will not fix.
*
* The classifier is conservative: anything it doesn't recognise defaults to
* `transient` so a single unknown response doesn't hard-disable the
* integration. The 3-strike admin-alert ladder in `recordSyncFailure`
* already catches the "keeps happening" case for recurring transients.
*/
/** Outcome buckets for a WHOOP response. Same shape as Withings. */
export type WhoopClassification =
| "success"
| "transient"
| "reauth_required"
| "persistent";
import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export interface ClassifiedWhoopResponse {
classification: WhoopClassification;
/** The HTTP status code that drove the verdict (undefined if none seen). */
httpStatus: number | undefined;
/** Short human-readable label for logs / audit details. */
reason: string;
}
/** Outcome buckets for a WHOOP response. */
export type WhoopClassification = IntegrationClassification;
/**
* Classify a single WHOOP API response by its HTTP status code.
*
* @param httpStatus The HTTP status from fetch.
*/
export type ClassifiedWhoopResponse = ClassifiedHttpResponse;
/** Classify a single WHOOP API response by its HTTP status code. */
export function classifyWhoopResponse(
httpStatus: number,
): ClassifiedWhoopResponse {
if (httpStatus >= 200 && httpStatus < 300) {
return { classification: "success", httpStatus, reason: "ok" };
}
// 429 is rate-limit — honour `X-RateLimit-*` for backoff at the call site,
// but the verdict is a plain transient so the next sync retries.
if (httpStatus === 429) {
return { classification: "transient", httpStatus, reason: "http_429" };
}
// 401/403 — the bearer token is rejected or the grant revoked. The user has
// to reconnect; do not retry silently.
if (httpStatus === 401 || httpStatus === 403) {
return {
classification: "reauth_required",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// Any other 4xx is a malformed request / contract mismatch — persistent.
if (httpStatus >= 400 && httpStatus < 500) {
return {
classification: "persistent",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 5xx upstream outage — retryable.
if (httpStatus >= 500 && httpStatus < 600) {
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}`,
};
}
// 3xx (a CDN / redirect glitch — safeFetch uses redirect:"manual", so a
// surfaced 3xx is unexpected) and everything else defaults to transient.
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}_unknown`,
};
return classifyHttpStatus(httpStatus);
}
/**
* Typed Error subclass carrying the classification verdict so downstream
* `try/catch` blocks can branch without re-parsing the message. Mirrors
* `WithingsApiError`.
* Typed error carrying the classification verdict so downstream `try/catch`
* blocks can branch without re-parsing the message. Extends the shared base
* (message shape `WHOOP <verb> error: <status>` is preserved).
*/
export class WhoopApiError extends Error {
readonly classification: WhoopClassification;
readonly httpStatus: number | undefined;
readonly reason: string;
readonly verb: string;
/**
* The OAuth `error` code from a token-endpoint failure body (e.g.
* `invalid_grant` when a refresh token is revoked). Undefined for the
* collection endpoints, which signal failure through the status code alone.
*/
readonly upstreamError: string | undefined;
export class WhoopApiError extends IntegrationApiError {
constructor(opts: {
verb: string;
classification: WhoopClassification;
@@ -114,20 +52,8 @@ export class WhoopApiError extends Error {
reason: string;
upstreamError?: string;
}) {
const statusLabel =
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
// Cap the message at 1024 chars in the constructor (same bound as
// WithingsApiError) so a misbehaving upstream returning a multi-MB error
// body can't bloat an AuditLog row downstream.
const raw = `WHOOP ${opts.verb} error: ${statusLabel}${errSegment}`;
super(raw.slice(0, 1024));
super({ vendor: "WHOOP", ...opts });
this.name = "WhoopApiError";
this.verb = opts.verb;
this.classification = opts.classification;
this.httpStatus = opts.httpStatus;
this.reason = opts.reason;
this.upstreamError = opts.upstreamError;
}
}
@@ -140,19 +66,9 @@ export class WhoopApiError extends Error {
* surface the reauth prompt. Scoped tightly to a 400 carrying `invalid_grant`
* so other 400s (a malformed request, a bad client_secret → `invalid_client`)
* stay persistent.
*
* Robust to a lost prototype across a pg-boss retry: it reads the dedicated
* `upstreamError` field when present, else falls back to the
* `"... error: 400 - invalid_grant"` message segment.
*/
export function isInvalidGrant(err: unknown): boolean {
if (err instanceof WhoopApiError) {
if (err.httpStatus !== 400) return false;
if (err.upstreamError === "invalid_grant") return true;
return /\binvalid_grant\b/.test(err.message);
}
const msg = err instanceof Error ? err.message : String(err);
return /WHOOP\s+\w+\s+error:\s*400\b[\s\S]*\binvalid_grant\b/.test(msg);
return isOAuthInvalidGrant(err, "WHOOP");
}
/**
@@ -168,13 +84,5 @@ export function isInvalidGrant(err: unknown): boolean {
*/
export function classifyWhoopError(err: unknown): WhoopClassification {
if (isInvalidGrant(err)) return "reauth_required";
if (err instanceof WhoopApiError) return err.classification;
const msg = err instanceof Error ? err.message : String(err);
const m = /WHOOP\s+\w+\s+error:\s*(\d+)/.exec(msg);
if (!m) return "transient";
const status = Number.parseInt(m[1]!, 10);
if (!Number.isFinite(status)) return "transient";
return classifyWhoopResponse(status).classification;
return classifyIntegrationError(err, "WHOOP");
}