diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 0b2132806..a3376b757 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -222,7 +222,7 @@ describe("syncUserBody — tier degradation", () => { expect(markSyncedMock).not.toHaveBeenCalled(); }); - it("records + rethrows a 401 (genuine reauth)", async () => { + it("rethrows a 401 (genuine reauth) instead of soft-skipping", async () => { fetchBodyMeasurementMock.mockRejectedValue( new WhoopApiError({ verb: "fetchBodyMeasurement", @@ -232,8 +232,13 @@ describe("syncUserBody — tier degradation", () => { }), ); + // A 401 is not a per-class tier gate: it propagates so the connection + // parks (vs the 403 case above, which returns 0). The catch delegates to + // the shared `handleCollectionFetchError` (sync.ts), whose "401 records a + // reauth failure + rethrows" contract is unit-tested in sync.test.ts; here + // we assert the resource sync surfaces the error and writes nothing. await expect(syncUserBody("user1")).rejects.toThrow(); - expect(recordWhoopSyncFailureMock).toHaveBeenCalledTimes(1); + expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled(); }); it("returns 0 without fetching when there is no valid token", async () => { diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 80b7ebb96..ece003b60 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -23,9 +23,8 @@ import { fetchBodyMeasurement, mapBody } from "./client"; import { getValidToken, - isCollectionForbidden, + handleCollectionFetchError, markSynced, - recordWhoopSyncFailure, upsertWhoopMeasurements, type WhoopMeasurementUpsert, } from "./sync"; @@ -54,18 +53,7 @@ export async function syncUserBody( try { body = await fetchBodyMeasurement(tokenInfo.accessToken); } catch (err) { - // A per-resource collection 403 soft-skips this data class: log + skip + - // return 0, leaving the connection connected so sibling resources still - // sync. A 401 (or any other failure) still records + rethrows so a genuine - // grant revoke parks the connection. - if (isCollectionForbidden(err)) { - getEvent()?.addWarning( - `whoop body sync skipped for ${userId}: collection 403 (soft-skip)`, - ); - return 0; - } - await recordWhoopSyncFailure(userId, err); - throw err; + return handleCollectionFetchError("body", userId, err); } const mapped = mapBody(body); diff --git a/src/lib/whoop/sync-cycle.ts b/src/lib/whoop/sync-cycle.ts index 5adc8df62..81bb2a9bb 100644 --- a/src/lib/whoop/sync-cycle.ts +++ b/src/lib/whoop/sync-cycle.ts @@ -9,14 +9,12 @@ import { fetchCycles, mapCycle } from "./client"; import { getValidToken, incrementalStart, - isCollectionForbidden, + handleCollectionFetchError, markSynced, - recordWhoopSyncFailure, upsertWhoopMeasurements, type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; -import { getEvent } from "@/lib/logging/context"; export async function syncUserCycle( userId: string, @@ -39,16 +37,7 @@ export async function syncUserCycle( try { records = await fetchCycles(tokenInfo.accessToken, { start }); } catch (err) { - // A per-resource 403 soft-skips this data class rather than parking the - // whole connection — sibling resources still sync. - if (isCollectionForbidden(err)) { - getEvent()?.addWarning( - `whoop cycle sync skipped for ${userId}: collection 403 (soft-skip)`, - ); - return 0; - } - await recordWhoopSyncFailure(userId, err); - throw err; + return handleCollectionFetchError("cycle", userId, err); } const readings: WhoopMeasurementUpsert[] = []; diff --git a/src/lib/whoop/sync-recovery.ts b/src/lib/whoop/sync-recovery.ts index f6fb4bd63..c348db88d 100644 --- a/src/lib/whoop/sync-recovery.ts +++ b/src/lib/whoop/sync-recovery.ts @@ -13,15 +13,13 @@ import { fetchRecoveries, mapRecovery } from "./client"; import { getValidToken, incrementalStart, - isCollectionForbidden, + handleCollectionFetchError, markSynced, - recordWhoopSyncFailure, upsertWhoopMeasurements, WHOOP_RECOVERY_SLEEP_OVERLAP_MS, type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; -import { getEvent } from "@/lib/logging/context"; export async function syncUserRecovery( userId: string, @@ -45,16 +43,7 @@ export async function syncUserRecovery( try { records = await fetchRecoveries(tokenInfo.accessToken, { start }); } catch (err) { - // A per-resource 403 soft-skips this data class rather than parking the - // whole connection — sibling resources still sync. - if (isCollectionForbidden(err)) { - getEvent()?.addWarning( - `whoop recovery sync skipped for ${userId}: collection 403 (soft-skip)`, - ); - return 0; - } - await recordWhoopSyncFailure(userId, err); - throw err; + return handleCollectionFetchError("recovery", userId, err); } const readings: WhoopMeasurementUpsert[] = []; diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index deebe9d73..82f0f7b84 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -11,15 +11,13 @@ import { fetchSleeps, mapSleep } from "./client"; import { getValidToken, incrementalStart, - isCollectionForbidden, + handleCollectionFetchError, markSynced, - recordWhoopSyncFailure, upsertWhoopMeasurements, WHOOP_RECOVERY_SLEEP_OVERLAP_MS, type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; -import { getEvent } from "@/lib/logging/context"; export async function syncUserSleep( userId: string, @@ -43,16 +41,7 @@ export async function syncUserSleep( try { records = await fetchSleeps(tokenInfo.accessToken, { start }); } catch (err) { - // A per-resource 403 soft-skips this data class rather than parking the - // whole connection — sibling resources still sync. - if (isCollectionForbidden(err)) { - getEvent()?.addWarning( - `whoop sleep sync skipped for ${userId}: collection 403 (soft-skip)`, - ); - return 0; - } - await recordWhoopSyncFailure(userId, err); - throw err; + return handleCollectionFetchError("sleep", userId, err); } const readings: WhoopMeasurementUpsert[] = []; diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index 296b31856..c960b2124 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -17,9 +17,8 @@ import { fetchWorkouts, KJ_TO_KCAL } from "./client"; import { getValidToken, incrementalStart, - isCollectionForbidden, + handleCollectionFetchError, markSynced, - recordWhoopSyncFailure, } from "./sync"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; @@ -53,16 +52,7 @@ export async function syncUserWorkout( try { records = await fetchWorkouts(tokenInfo.accessToken, { start }); } catch (err) { - // A per-resource 403 soft-skips this data class rather than parking the - // whole connection — sibling resources still sync. - if (isCollectionForbidden(err)) { - getEvent()?.addWarning( - `whoop workout sync skipped for ${userId}: collection 403 (soft-skip)`, - ); - return 0; - } - await recordWhoopSyncFailure(userId, err); - throw err; + return handleCollectionFetchError("workout", userId, err); } let imported = 0; diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 519682518..9cb028313 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -55,6 +55,28 @@ export function isCollectionForbidden(err: unknown): boolean { return err instanceof WhoopApiError && err.httpStatus === 403; } +/** + * Single-source the per-resource collection-fetch error handling. A 403 on one + * data class soft-skips it (warn + return 0) so sibling resources still sync; + * anything else records a classified sync failure and rethrows. Call as + * `return handleCollectionFetchError("recovery", userId, err)` from a resource + * sync's catch block. + */ +export async function handleCollectionFetchError( + resource: string, + userId: string, + err: unknown, +): Promise { + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop ${resource} sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } + await recordWhoopSyncFailure(userId, err); + throw err; +} + /** Refresh the access token this many ms before `tokenExpiresAt`. */ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;