diff --git a/src/lib/whoop/__tests__/response-classifier.test.ts b/src/lib/whoop/__tests__/response-classifier.test.ts index e24ba2466..073ce522a 100644 --- a/src/lib/whoop/__tests__/response-classifier.test.ts +++ b/src/lib/whoop/__tests__/response-classifier.test.ts @@ -3,6 +3,7 @@ import { WhoopApiError, classifyWhoopError, classifyWhoopResponse, + isInvalidGrant, } from "../response-classifier"; describe("classifyWhoopResponse", () => { @@ -90,4 +91,93 @@ describe("classifyWhoopError", () => { expect(classifyWhoopError(new Error("network down"))).toBe("transient"); expect(classifyWhoopError("boom")).toBe("transient"); }); + + it("lifts a 400 invalid_grant from the token endpoint to reauth_required", () => { + const err = new WhoopApiError({ + verb: "refreshAccessToken", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_grant", + }); + // The construction-time verdict is persistent (status-only), but the + // refined classifier promotes the revoked-grant case to reauth. + expect(err.classification).toBe("persistent"); + expect(classifyWhoopError(err)).toBe("reauth_required"); + }); + + it("keeps other 400s persistent (only invalid_grant is reauth)", () => { + const badClient = new WhoopApiError({ + verb: "refreshAccessToken", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_client", + }); + expect(classifyWhoopError(badClient)).toBe("persistent"); + + const bareBadRequest = new WhoopApiError({ + verb: "fetchRecoveries", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + }); + expect(classifyWhoopError(bareBadRequest)).toBe("persistent"); + }); + + it("detects invalid_grant from a legacy unwrapped message too", () => { + const err = new Error( + "WHOOP refreshAccessToken error: 400 - invalid_grant", + ); + expect(classifyWhoopError(err)).toBe("reauth_required"); + }); +}); + +describe("isInvalidGrant", () => { + it("is true only for a 400 carrying invalid_grant", () => { + expect( + isInvalidGrant( + new WhoopApiError({ + verb: "refreshAccessToken", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_grant", + }), + ), + ).toBe(true); + }); + + it("is false for invalid_grant on a non-400 status", () => { + expect( + isInvalidGrant( + new WhoopApiError({ + verb: "refreshAccessToken", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + upstreamError: "invalid_grant", + }), + ), + ).toBe(false); + }); + + it("is false for a 400 with a different OAuth error", () => { + expect( + isInvalidGrant( + new WhoopApiError({ + verb: "refreshAccessToken", + classification: "persistent", + httpStatus: 400, + reason: "http_400", + upstreamError: "invalid_client", + }), + ), + ).toBe(false); + }); + + it("is false for a non-WhoopApiError without the signal", () => { + expect(isInvalidGrant(new Error("network down"))).toBe(false); + expect(isInvalidGrant("boom")).toBe(false); + }); }); diff --git a/src/lib/whoop/response-classifier.ts b/src/lib/whoop/response-classifier.ts index 793ed3510..b6b5538ce 100644 --- a/src/lib/whoop/response-classifier.ts +++ b/src/lib/whoop/response-classifier.ts @@ -100,6 +100,12 @@ export class WhoopApiError extends Error { 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: { verb: string; @@ -121,17 +127,48 @@ export class WhoopApiError extends Error { 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 WHOOP expired the grant). A plain + * `classifyWhoopResponse(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. + */ +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); +} + /** * Read the classification from any caught error. Returns `transient` for a * non-`WhoopApiError` input so a call site surfacing a plain `Error` retries * rather than permanently disabling the integration. Falls back to parsing the * HTTP status out of the legacy `"WHOOP error: "` message shape * for callers that lose the original prototype across a pg-boss retry. + * + * A 400 `invalid_grant` from the token endpoint is the one 4xx that is NOT + * persistent: the refresh token was revoked, so the verdict is + * `reauth_required` and the user is prompted to reconnect. */ 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);