mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user