From 6cd3c79db2756ca13b32fe1da802514c70baf690 Mon Sep 17 00:00:00 2001 From: Anurag Dhungana <36888347+Aarekaz@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:56:06 -0400 Subject: [PATCH] fix: surface safe WHOOP schema diagnostics --- src/__tests__/whoop/client.test.ts | 13 +++++++++++-- src/__tests__/whoop/sync.test.ts | 15 ++++++++++++++- src/services/whoop/client.ts | 16 ++++++++++++++-- src/services/whoop/sync.ts | 18 +++++++++++------- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/__tests__/whoop/client.test.ts b/src/__tests__/whoop/client.test.ts index 576b63d..3557135 100644 --- a/src/__tests__/whoop/client.test.ts +++ b/src/__tests__/whoop/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { WhoopClient, + WhoopResponseSchemaError, WhoopRequestError, WhoopUnauthorizedError, } from "../../services/whoop/client"; @@ -264,9 +265,17 @@ describe("WHOOP v2 client", () => { }); it("rejects malformed successful provider payloads without treating them as HTTP retry errors", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ user_id: 42 })); + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ + user_id: 42, + access_token: "must-not-leak", + })); const client = new WhoopClient(ENV, "access"); - await expect(client.getProfile()).rejects.not.toBeInstanceOf(WhoopRequestError); + const request = client.getProfile(); + await expect(request).rejects.toBeInstanceOf(WhoopResponseSchemaError); + await expect(request).rejects.toMatchObject({ + message: "WHOOP profile response schema mismatch at email:invalid_type, first_name:invalid_type, last_name:invalid_type", + }); + await expect(request).rejects.not.toThrow("must-not-leak"); }); }); diff --git a/src/__tests__/whoop/sync.test.ts b/src/__tests__/whoop/sync.test.ts index c599199..b620d1d 100644 --- a/src/__tests__/whoop/sync.test.ts +++ b/src/__tests__/whoop/sync.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../../index"; -import { WhoopRequestError } from "../../services/whoop/client"; +import { WhoopRequestError, WhoopResponseSchemaError } from "../../services/whoop/client"; import { enqueueReconciliation, handleWhoopQueue, @@ -602,6 +602,19 @@ describe("WHOOP queue synchronization", () => { expect(message.ack).not.toHaveBeenCalled(); }); + it("checkpoints a bounded provider schema summary without response values", async () => { + const { client, dependencies, env, repository } = createHarness(); + client.getCollection.mockRejectedValue(new WhoopResponseSchemaError("list sleep", [])); + const batch = batchOf({ kind: "backfill", whoopUserId: 42, resource: "sleep" }); + + await handleWhoopQueue(batch, env, dependencies); + + expect(repository.upsertCheckpoint).toHaveBeenCalledWith(expect.objectContaining({ + status: "retrying", + lastError: "WHOOP list sleep response schema mismatch at response:invalid", + })); + }); + it("durably terminates an explicit permanent 4xx without a retry loop", async () => { const { client, dependencies, env, repository } = createHarness(); client.getCollection.mockRejectedValue(new WhoopRequestError("list workout", 404)); diff --git a/src/services/whoop/client.ts b/src/services/whoop/client.ts index a76e6c3..00f35e6 100644 --- a/src/services/whoop/client.ts +++ b/src/services/whoop/client.ts @@ -81,6 +81,18 @@ export class WhoopRequestError extends Error { } } +export class WhoopResponseSchemaError extends Error { + readonly name = "WhoopResponseSchemaError"; + + constructor(operation: string, issues: z.ZodIssue[]) { + const summary = issues.slice(0, 3).map((issue) => { + const path = issue.path.length === 0 ? "response" : issue.path.map(String).join("."); + return `${path}:${issue.code}`; + }).join(", "); + super(`WHOOP ${operation} response schema mismatch at ${summary || "response:invalid"}`); + } +} + export class WhoopUnauthorizedError extends WhoopRequestError { readonly name = "WhoopUnauthorizedError"; @@ -146,7 +158,7 @@ const parseProviderPayload = (schema: z.ZodType, payload: unknown, operati const rawJson = JSON.stringify(payload); const parsed = schema.safeParse(payload); if (!parsed.success) { - throw new Error(`WHOOP ${operation} response did not match the provider schema`); + throw new WhoopResponseSchemaError(operation, parsed.error.issues); } return asProviderRecord(parsed.data, rawJson); }; @@ -214,7 +226,7 @@ export class WhoopClient { : []; const parsed = whoopCollectionResponseSchema(definition.schema).safeParse(payload); if (!parsed.success) { - throw new Error(`WHOOP list ${resource} response did not match the provider schema`); + throw new WhoopResponseSchemaError(`list ${resource}`, parsed.error.issues); } return { diff --git a/src/services/whoop/sync.ts b/src/services/whoop/sync.ts index fe1cd7f..a888245 100644 --- a/src/services/whoop/sync.ts +++ b/src/services/whoop/sync.ts @@ -4,7 +4,7 @@ import type { WhoopResource, WhoopWebhookEventType, } from "../../types/whoop"; -import { WhoopClient, WhoopRequestError } from "./client"; +import { WhoopClient, WhoopRequestError, WhoopResponseSchemaError } from "./client"; import { WhoopRepository, WhoopStaleConnectionError } from "./repository"; const RECONCILIATION_WINDOW_MILLISECONDS = 14 * 24 * 60 * 60 * 1000; @@ -79,6 +79,14 @@ const requireCurrentWrite = (written: boolean | void): void => { if (written === false) throw new WhoopStaleConnectionError(); }; +const sanitizedSyncError = (error: unknown): string => { + if (error instanceof WhoopRequestError && error.status !== undefined) { + return `WHOOP request failed with status ${error.status}`; + } + if (error instanceof WhoopResponseSchemaError) return error.message; + return "WHOOP synchronization failed"; +}; + const isCollectionResource = ( resource: WhoopResource, ): resource is "cycle" | "recovery" | "sleep" | "workout" => @@ -564,9 +572,7 @@ export async function handleWhoopQueue( continue; } if (body.kind === "webhook") { - const lastError = error instanceof WhoopRequestError && error.status !== undefined - ? `WHOOP request failed with status ${error.status}` - : "WHOOP synchronization failed"; + const lastError = sanitizedSyncError(error); const permanentClientError = error instanceof WhoopRequestError && error.status !== undefined && error.status >= 400 @@ -614,9 +620,7 @@ export async function handleWhoopQueue( continue; } const failedAt = now().toISOString(); - const lastError = error instanceof WhoopRequestError && error.status !== undefined - ? `WHOOP request failed with status ${error.status}` - : "WHOOP synchronization failed"; + const lastError = sanitizedSyncError(error); const permanentClientError = error instanceof WhoopRequestError && error.status !== undefined && error.status >= 400