mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(whoop): classify a token-endpoint invalid_grant as reauth_required
A revoked refresh token returns HTTP 400 with an OAuth body of `error: invalid_grant` from the token endpoint. The status-only classifier buckets every 400 as persistent, so the connection never prompts a reconnect even though the grant is gone. Store the upstream OAuth error on WhoopApiError and lift a 400 invalid_grant to reauth_required in classifyWhoopError; every other 400 (a malformed request, invalid_client) stays persistent.
This commit is contained in:
1 parent
4aa8b1cad7
commit
8eb8717b90
2 files changed
+127
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 <verb> error: <status>"` 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);
|
||||
|
||||
Reference in new issue
Block a user