diff --git a/src/lib/integrations/__tests__/http-status-classifier.test.ts b/src/lib/integrations/__tests__/http-status-classifier.test.ts new file mode 100644 index 000000000..6754fe085 --- /dev/null +++ b/src/lib/integrations/__tests__/http-status-classifier.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { + classifyHttpStatus, + classifyIntegrationError, + IntegrationApiError, + isOAuthInvalidGrant, +} from "../http-status-classifier"; +import { toFailureKind } from "../status"; + +describe("classifyHttpStatus", () => { + it("classifies 2xx as success", () => { + for (const status of [200, 201, 204, 299]) { + const v = classifyHttpStatus(status); + expect(v.classification).toBe("success"); + expect(v.httpStatus).toBe(status); + expect(v.reason).toBe("ok"); + } + }); + + it("classifies 429 as transient (rate-limit)", () => { + const v = classifyHttpStatus(429); + expect(v.classification).toBe("transient"); + expect(v.reason).toBe("http_429"); + }); + + it("classifies 401 and 403 as reauth_required", () => { + expect(classifyHttpStatus(401).classification).toBe("reauth_required"); + expect(classifyHttpStatus(403).classification).toBe("reauth_required"); + expect(classifyHttpStatus(401).reason).toBe("http_401"); + }); + + it("classifies other 4xx as persistent", () => { + for (const status of [400, 404, 422]) { + const v = classifyHttpStatus(status); + expect(v.classification).toBe("persistent"); + expect(v.reason).toBe(`http_${status}`); + } + }); + + it("classifies 5xx as transient", () => { + for (const status of [500, 502, 503]) { + expect(classifyHttpStatus(status).classification).toBe("transient"); + } + }); + + it("classifies an unknown / 3xx status as transient with _unknown reason", () => { + const v = classifyHttpStatus(302); + expect(v.classification).toBe("transient"); + expect(v.reason).toBe("http_302_unknown"); + }); +}); + +describe("IntegrationApiError", () => { + it("carries the vendor label in the message prefix and on the field", () => { + const err = new IntegrationApiError({ + vendor: "Acme", + verb: "fetch", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }); + expect(err.message).toBe("Acme fetch error: 401"); + expect(err.vendor).toBe("Acme"); + expect(err.classification).toBe("reauth_required"); + }); + + it("appends the upstream error segment and caps at 1024 chars", () => { + const err = new IntegrationApiError({ + vendor: "Acme", + verb: "token", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_grant", + }); + expect(err.message).toBe("Acme token error: 400 - invalid_grant"); + + const huge = new IntegrationApiError({ + vendor: "Acme", + verb: "fetch", + classification: "transient", + httpStatus: 500, + reason: "http_500", + upstreamError: "x".repeat(5000), + }); + expect(huge.message.length).toBe(1024); + }); +}); + +describe("isOAuthInvalidGrant", () => { + it("lifts a 400 invalid_grant from the upstreamError field", () => { + const err = new IntegrationApiError({ + vendor: "Acme", + verb: "token", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_grant", + }); + expect(isOAuthInvalidGrant(err, "Acme")).toBe(true); + }); + + it("does not lift a non-400 or a non-invalid_grant body", () => { + const wrongStatus = new IntegrationApiError({ + vendor: "Acme", + verb: "token", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + upstreamError: "invalid_grant", + }); + expect(isOAuthInvalidGrant(wrongStatus, "Acme")).toBe(false); + + const wrongBody = new IntegrationApiError({ + vendor: "Acme", + verb: "token", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_client", + }); + expect(isOAuthInvalidGrant(wrongBody, "Acme")).toBe(false); + }); + + it("falls back to the vendor-scoped message shape across a lost prototype", () => { + expect( + isOAuthInvalidGrant( + new Error("Acme token error: 400 - invalid_grant"), + "Acme", + ), + ).toBe(true); + // Wrong vendor label in the message → no match. + expect( + isOAuthInvalidGrant( + new Error("Other token error: 400 - invalid_grant"), + "Acme", + ), + ).toBe(false); + }); + + it("returns false for a plain non-API error", () => { + expect(isOAuthInvalidGrant(new Error("network down"), "Acme")).toBe(false); + expect(isOAuthInvalidGrant("boom", "Acme")).toBe(false); + }); +}); + +describe("classifyIntegrationError", () => { + it("reads the classification off an IntegrationApiError", () => { + const err = new IntegrationApiError({ + vendor: "Acme", + verb: "fetch", + classification: "persistent", + httpStatus: 404, + reason: "http_404", + }); + expect(classifyIntegrationError(err, "Acme")).toBe("persistent"); + }); + + it("parses the status out of a legacy vendor-scoped message", () => { + expect( + classifyIntegrationError(new Error("Acme fetch error: 503"), "Acme"), + ).toBe("transient"); + expect( + classifyIntegrationError(new Error("Acme fetch error: 401"), "Acme"), + ).toBe("reauth_required"); + }); + + it("defaults to transient for an unparseable error", () => { + expect(classifyIntegrationError(new Error("network down"), "Acme")).toBe( + "transient", + ); + expect(classifyIntegrationError("boom", "Acme")).toBe("transient"); + }); +}); + +describe("toFailureKind", () => { + it("maps reauth_required and persistent through unchanged", () => { + expect(toFailureKind("reauth_required")).toBe("reauth_required"); + expect(toFailureKind("persistent")).toBe("persistent"); + }); + + it("collapses success and transient onto transient", () => { + expect(toFailureKind("transient")).toBe("transient"); + expect(toFailureKind("success")).toBe("transient"); + }); + + it("maps the boundary HTTP codes end-to-end", () => { + expect(toFailureKind(classifyHttpStatus(401).classification)).toBe( + "reauth_required", + ); + expect(toFailureKind(classifyHttpStatus(403).classification)).toBe( + "reauth_required", + ); + expect(toFailureKind(classifyHttpStatus(404).classification)).toBe( + "persistent", + ); + expect(toFailureKind(classifyHttpStatus(500).classification)).toBe( + "transient", + ); + expect(toFailureKind(classifyHttpStatus(429).classification)).toBe( + "transient", + ); + }); +}); diff --git a/src/lib/integrations/http-status-classifier.ts b/src/lib/integrations/http-status-classifier.ts new file mode 100644 index 000000000..fe10349c8 --- /dev/null +++ b/src/lib/integrations/http-status-classifier.ts @@ -0,0 +1,182 @@ +/** + * v1.17.0 — shared HTTP-status response classifier for OAuth integrations. + * + * Polar, Oura, WHOOP and Fitbit all signal failure through the HTTP status + * code itself (no `status` field embedded in a 200 body), so their off-response + * classifiers were byte-identical copies of one another. This module lifts the + * shared core; each vendor module re-exports it under its existing names so + * call sites keep their current types. + * + * Classification buckets: + * + * - `success` → HTTP 2xx (incl. 204 No Content). + * - `transient` → retryable: 429 (rate-limited), 5xx upstream outages, + * 3xx CDN/redirect glitches, and an unknown status. + * - `reauth_required` → 401 / 403 — the access token is rejected or the grant + * was revoked; the user must reconnect. + * - `persistent` → any other 4xx — a malformed request / contract + * mismatch that a retry won't 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 shared across the HTTP-status-driven integrations. */ +export type IntegrationClassification = + | "success" + | "transient" + | "reauth_required" + | "persistent"; + +export interface ClassifiedHttpResponse { + classification: IntegrationClassification; + /** 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; +} + +/** + * Classify a single integration API response by its HTTP status code. + * + * @param httpStatus The HTTP status from fetch. + */ +export function classifyHttpStatus(httpStatus: number): ClassifiedHttpResponse { + if (httpStatus >= 200 && httpStatus < 300) { + return { classification: "success", httpStatus, reason: "ok" }; + } + // 429 is rate-limit — honour the vendor's backoff header 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`, + }; +} + +/** + * Typed Error base carrying the classification verdict so downstream + * `try/catch` blocks can branch without re-parsing the message. Each vendor + * subclass sets `vendor` (the wire label used in the message prefix) and its + * own `name`; the message shape (` error: - `, + * capped at 1024 chars) is identical across vendors. + */ +export class IntegrationApiError extends Error { + readonly vendor: string; + readonly classification: IntegrationClassification; + 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; + + constructor(opts: { + vendor: string; + verb: string; + classification: IntegrationClassification; + httpStatus: number | undefined; + reason: string; + upstreamError?: string; + }) { + const statusLabel = + typeof opts.httpStatus === "number" ? opts.httpStatus : "?"; + const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : ""; + // Cap the message at 1024 chars so a misbehaving upstream returning a + // multi-MB error body can't bloat an AuditLog row downstream. + const raw = `${opts.vendor} ${opts.verb} error: ${statusLabel}${errSegment}`; + super(raw.slice(0, 1024)); + this.name = "IntegrationApiError"; + this.vendor = opts.vendor; + this.verb = opts.verb; + this.classification = opts.classification; + this.httpStatus = opts.httpStatus; + this.reason = opts.reason; + this.upstreamError = opts.upstreamError; + } +} + +/** + * True when a caught error is an OAuth `invalid_grant` on a 400 from the token + * endpoint — the canonical signal that the stored refresh token was revoked + * (the user disconnected the app, or the upstream expired the grant). A plain + * `classifyHttpStatus(400)` buckets this as `persistent`, which never prompts a + * reconnect; lifting it to `reauth_required` makes the connection 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. The `vendor` + * label scopes the message-shape fallback to the calling integration. + */ +export function isOAuthInvalidGrant(err: unknown, vendor: string): boolean { + if (err instanceof IntegrationApiError) { + 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); + const re = new RegExp( + `${vendor}\\s+\\w+\\s+error:\\s*400\\b[\\s\\S]*\\binvalid_grant\\b`, + ); + return re.test(msg); +} + +/** + * Read the classification from any caught `IntegrationApiError`, falling back + * to parsing the HTTP status out of the legacy `" error: + * "` message shape for callers that lose the original prototype across + * a pg-boss retry. A non-`IntegrationApiError` with no parseable status + * defaults to `transient` so a plain `Error` retries rather than permanently + * disabling the integration. + */ +export function classifyIntegrationError( + err: unknown, + vendor: string, +): IntegrationClassification { + if (err instanceof IntegrationApiError) return err.classification; + const msg = err instanceof Error ? err.message : String(err); + const re = new RegExp(`${vendor}\\s+\\w+\\s+error:\\s*(\\d+)`); + const m = re.exec(msg); + if (!m) return "transient"; + const status = Number.parseInt(m[1]!, 10); + if (!Number.isFinite(status)) return "transient"; + return classifyHttpStatus(status).classification; +} diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index ef0fd09c9..41c3fda75 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -38,6 +38,7 @@ import { encrypt, decrypt } from "@/lib/crypto"; import { auditLog } from "@/lib/auth/audit"; import { getEvent } from "@/lib/logging/context"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; export type IntegrationKey = | "withings" @@ -74,6 +75,23 @@ export type IntegrationKey = */ export type FailureKind = "transient" | "reauth_required" | "persistent"; +/** + * Map a shared `IntegrationClassification` onto the ledger `FailureKind`. + * + * `success` never reaches a failure path, so it collapses into `transient` + * alongside the genuinely-retryable verdicts; `reauth_required` and + * `persistent` pass through unchanged. Lifted out of the per-vendor + * `classifyXFailure` adapters (Polar / Oura / Nightscout), which each carried a + * byte-identical copy of this mapping. + */ +export function toFailureKind( + classification: IntegrationClassification, +): FailureKind { + if (classification === "reauth_required") return "reauth_required"; + if (classification === "persistent") return "persistent"; + return "transient"; +} + /** * Recognised IntegrationStatus states. * diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index 764c0fb71..cfd6fefc8 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -26,7 +26,8 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { upsert: upsertMock } }, })); -vi.mock("@/lib/integrations/status", () => ({ +vi.mock("@/lib/integrations/status", async (importOriginal) => ({ + ...(await importOriginal()), recordSyncSuccess: recordSuccessMock, recordSyncFailure: recordFailureMock, })); diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 06e5e39c9..185b2e417 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -25,8 +25,10 @@ import { getEvent } from "@/lib/logging/context"; import { recordSyncFailure, recordSyncSuccess, + toFailureKind, type FailureKind, } from "@/lib/integrations/status"; +import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, @@ -72,14 +74,18 @@ export function redactNightscoutSecret(message: string): string { /** Map a Nightscout HTTP status / network error onto the shared ledger kind. */ export function classifyNightscoutFailure(err: unknown): FailureKind { + let classification: IntegrationClassification = "transient"; if (err instanceof NightscoutApiError && err.status != null) { // Wrong / missing token, or a token whose role can't read SGV. - if (err.status === 401 || err.status === 403) return "reauth_required"; - // 4xx other than auth = a contract / config problem worth surfacing. - if (err.status >= 400 && err.status < 500) return "persistent"; + if (err.status === 401 || err.status === 403) { + classification = "reauth_required"; + } else if (err.status >= 400 && err.status < 500) { + // 4xx other than auth = a contract / config problem worth surfacing. + classification = "persistent"; + } } - // 5xx, timeout, DNS, connection refused — retry on the next tick. - return "transient"; + // 5xx, timeout, DNS, connection refused stay `transient` — retry next tick. + return toFailureKind(classification); } /** diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index e242401d5..2d0fc91eb 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -37,7 +37,8 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { upsert: upsertMock } }, })); -vi.mock("@/lib/integrations/status", () => ({ +vi.mock("@/lib/integrations/status", async (importOriginal) => ({ + ...(await importOriginal()), recordSyncSuccess: recordSuccessMock, recordSyncFailure: recordFailureMock, })); diff --git a/src/lib/oura/response-classifier.ts b/src/lib/oura/response-classifier.ts index 0f3e39ea8..0477cee1d 100644 --- a/src/lib/oura/response-classifier.ts +++ b/src/lib/oura/response-classifier.ts @@ -1,6 +1,8 @@ /** * v1.17.0 (F4) — off-response classifier for Oura Cloud API v2 replies. - * Mirrors the WHOOP / Polar classifiers: HTTP-status-driven. + * HTTP-status-driven; the shared core lives in + * `src/lib/integrations/http-status-classifier.ts` and is re-exported here + * under the Oura-specific names callers already use. * * - `success` → HTTP 2xx * - `transient` → 429 (rate-limited), 5xx, 3xx, unknown. @@ -8,62 +10,26 @@ * - `persistent` → any other 4xx — malformed request / contract drift. */ -export type OuraClassification = - | "success" - | "transient" - | "reauth_required" - | "persistent"; +import { + classifyHttpStatus, + classifyIntegrationError, + IntegrationApiError, + isOAuthInvalidGrant, + type ClassifiedHttpResponse, + type IntegrationClassification, +} from "@/lib/integrations/http-status-classifier"; -export interface ClassifiedOuraResponse { - classification: OuraClassification; - httpStatus: number | undefined; - reason: string; -} +export type OuraClassification = IntegrationClassification; + +export type ClassifiedOuraResponse = ClassifiedHttpResponse; export function classifyOuraResponse( httpStatus: number, ): ClassifiedOuraResponse { - if (httpStatus >= 200 && httpStatus < 300) { - return { classification: "success", httpStatus, reason: "ok" }; - } - if (httpStatus === 429) { - return { classification: "transient", httpStatus, reason: "http_429" }; - } - if (httpStatus === 401 || httpStatus === 403) { - return { - classification: "reauth_required", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - if (httpStatus >= 400 && httpStatus < 500) { - return { - classification: "persistent", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - if (httpStatus >= 500 && httpStatus < 600) { - return { - classification: "transient", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - return { - classification: "transient", - httpStatus, - reason: `http_${httpStatus}_unknown`, - }; + return classifyHttpStatus(httpStatus); } -export class OuraApiError extends Error { - readonly classification: OuraClassification; - readonly httpStatus: number | undefined; - readonly reason: string; - readonly verb: string; - readonly upstreamError: string | undefined; - +export class OuraApiError extends IntegrationApiError { constructor(opts: { verb: string; classification: OuraClassification; @@ -71,17 +37,8 @@ export class OuraApiError extends Error { reason: string; upstreamError?: string; }) { - const statusLabel = - typeof opts.httpStatus === "number" ? opts.httpStatus : "?"; - const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : ""; - const raw = `Oura ${opts.verb} error: ${statusLabel}${errSegment}`; - super(raw.slice(0, 1024)); + super({ vendor: "Oura", ...opts }); this.name = "OuraApiError"; - this.verb = opts.verb; - this.classification = opts.classification; - this.httpStatus = opts.httpStatus; - this.reason = opts.reason; - this.upstreamError = opts.upstreamError; } } @@ -92,22 +49,10 @@ export class OuraApiError extends Error { * surfaces the reconnect prompt. */ export function isInvalidGrant(err: unknown): boolean { - if (err instanceof OuraApiError) { - 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 /Oura\s+\w+\s+error:\s*400\b[\s\S]*\binvalid_grant\b/.test(msg); + return isOAuthInvalidGrant(err, "Oura"); } export function classifyOuraError(err: unknown): OuraClassification { if (isInvalidGrant(err)) return "reauth_required"; - if (err instanceof OuraApiError) return err.classification; - const msg = err instanceof Error ? err.message : String(err); - const m = /Oura\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 classifyOuraResponse(status).classification; + return classifyIntegrationError(err, "Oura"); } diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index cfdafba35..58d110e54 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -23,6 +23,7 @@ import { getEvent } from "@/lib/logging/context"; import { recordSyncFailure, recordSyncSuccess, + toFailureKind, type FailureKind, } from "@/lib/integrations/status"; import { @@ -50,10 +51,7 @@ import { OuraApiError, classifyOuraError } from "./response-classifier"; export const OURA_SYNC_LOOKBACK_DAYS = 7; export function classifyOuraFailure(err: unknown): FailureKind { - const c = classifyOuraError(err); - if (c === "reauth_required") return "reauth_required"; - if (c === "persistent") return "persistent"; - return "transient"; + return toFailureKind(classifyOuraError(err)); } function ymd(d: Date): string { diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index c2bebb1a1..dda649bf6 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -30,7 +30,8 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { upsert: upsertMock } }, })); -vi.mock("@/lib/integrations/status", () => ({ +vi.mock("@/lib/integrations/status", async (importOriginal) => ({ + ...(await importOriginal()), recordSyncSuccess: recordSuccessMock, recordSyncFailure: recordFailureMock, })); diff --git a/src/lib/polar/response-classifier.ts b/src/lib/polar/response-classifier.ts index 7ec824425..30297e60b 100644 --- a/src/lib/polar/response-classifier.ts +++ b/src/lib/polar/response-classifier.ts @@ -1,9 +1,10 @@ /** * v1.17.0 (F4) — off-response classifier for Polar AccessLink replies. * - * Mirrors the WHOOP classifier (`src/lib/whoop/response-classifier.ts`): Polar - * signals failure through the HTTP status code, so the classifier is - * status-driven. + * Polar signals failure through the HTTP status code, 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 Polar-specific names callers already use. * * - `success` → HTTP 2xx (incl. 204 No Content — Polar returns 204 * when a collection window holds no records). @@ -15,63 +16,26 @@ * mismatch that a retry won't fix. */ -export type PolarClassification = - | "success" - | "transient" - | "reauth_required" - | "persistent"; +import { + classifyHttpStatus, + classifyIntegrationError, + IntegrationApiError, + type ClassifiedHttpResponse, + type IntegrationClassification, +} from "@/lib/integrations/http-status-classifier"; -export interface ClassifiedPolarResponse { - classification: PolarClassification; - httpStatus: number | undefined; - reason: string; -} +export type PolarClassification = IntegrationClassification; + +export type ClassifiedPolarResponse = ClassifiedHttpResponse; export function classifyPolarResponse( httpStatus: number, ): ClassifiedPolarResponse { - if (httpStatus >= 200 && httpStatus < 300) { - return { classification: "success", httpStatus, reason: "ok" }; - } - if (httpStatus === 429) { - return { classification: "transient", httpStatus, reason: "http_429" }; - } - if (httpStatus === 401 || httpStatus === 403) { - return { - classification: "reauth_required", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - if (httpStatus >= 400 && httpStatus < 500) { - return { - classification: "persistent", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - if (httpStatus >= 500 && httpStatus < 600) { - return { - classification: "transient", - httpStatus, - reason: `http_${httpStatus}`, - }; - } - return { - classification: "transient", - httpStatus, - reason: `http_${httpStatus}_unknown`, - }; + return classifyHttpStatus(httpStatus); } -/** Typed error carrying the classification verdict. Mirrors `WhoopApiError`. */ -export class PolarApiError extends Error { - readonly classification: PolarClassification; - readonly httpStatus: number | undefined; - readonly reason: string; - readonly verb: string; - readonly upstreamError: string | undefined; - +/** Typed error carrying the classification verdict. Extends the shared base. */ +export class PolarApiError extends IntegrationApiError { constructor(opts: { verb: string; classification: PolarClassification; @@ -79,17 +43,8 @@ export class PolarApiError extends Error { reason: string; upstreamError?: string; }) { - const statusLabel = - typeof opts.httpStatus === "number" ? opts.httpStatus : "?"; - const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : ""; - const raw = `Polar ${opts.verb} error: ${statusLabel}${errSegment}`; - super(raw.slice(0, 1024)); + super({ vendor: "Polar", ...opts }); this.name = "PolarApiError"; - this.verb = opts.verb; - this.classification = opts.classification; - this.httpStatus = opts.httpStatus; - this.reason = opts.reason; - this.upstreamError = opts.upstreamError; } } @@ -100,11 +55,5 @@ export class PolarApiError extends Error { * caller that lost the prototype across a pg-boss retry. */ export function classifyPolarError(err: unknown): PolarClassification { - if (err instanceof PolarApiError) return err.classification; - const msg = err instanceof Error ? err.message : String(err); - const m = /Polar\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 classifyPolarResponse(status).classification; + return classifyIntegrationError(err, "Polar"); } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 329b02a27..38ec71e69 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -26,6 +26,7 @@ import { getEvent } from "@/lib/logging/context"; import { recordSyncFailure, recordSyncSuccess, + toFailureKind, type FailureKind, } from "@/lib/integrations/status"; import { @@ -47,10 +48,7 @@ import { PolarApiError, classifyPolarError } from "./response-classifier"; /** Map a Polar error onto the shared integration-ledger failure kind. */ export function classifyPolarFailure(err: unknown): FailureKind { - const c = classifyPolarError(err); - if (c === "reauth_required") return "reauth_required"; - if (c === "persistent") return "persistent"; - return "transient"; + return toFailureKind(classifyPolarError(err)); } /** One mapped reading with its `externalId` resolved. */