refactor(integrations): share the HTTP status and error classifier

Lift the byte-identical Polar and Oura response classifiers onto one shared
classifyHttpStatus over the integration classification union, give the vendor
API errors a common base carrying the vendor label, and funnel the duplicated
failure-kind adapters through a single toFailureKind. Behaviour is unchanged —
the per-vendor names, signatures and the Oura invalid-grant lift are preserved.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:10:06 +02:00
commit 95f5257572
11 files changed
+463 -160

No files matched your search

@@ -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",
);
});
});
@@ -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 (`<Vendor> <verb> error: <status> - <err>`,
* 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
* `"<Vendor> <verb> 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 `"<Vendor> <verb> error:
* <status>"` 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;
}
+18
View File
@@ -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.
*
+2 -1
View File
@@ -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<typeof import("@/lib/integrations/status")>()),
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
+11 -5
View File
@@ -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);
}
/**
+2 -1
View File
@@ -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<typeof import("@/lib/integrations/status")>()),
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
+19 -74
View File
@@ -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");
}
+2 -4
View File
@@ -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 {
+2 -1
View File
@@ -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<typeof import("@/lib/integrations/status")>()),
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
+19 -70
View File
@@ -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");
}
+2 -4
View File
@@ -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. */