Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/__tests__/whoop/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
WhoopClient,
WhoopResponseSchemaError,
WhoopRequestError,
WhoopUnauthorizedError,
} from "../../services/whoop/client";
Expand Down Expand Up @@ -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");
});
});
15 changes: 14 additions & 1 deletion src/__tests__/whoop/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
Expand Down
16 changes: 14 additions & 2 deletions src/services/whoop/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -146,7 +158,7 @@ const parseProviderPayload = <T>(schema: z.ZodType<T>, 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);
};
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 11 additions & 7 deletions src/services/whoop/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" =>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading