refactor(whoop): single-source the collection-403 soft-skip handling

Extract handleCollectionFetchError so the five resource syncs share one
soft-skip-vs-rethrow path instead of five copies. Behaviour is unchanged:
a per-resource 403 returns 0 and keeps the connection connected, anything
else records a classified failure and rethrows.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 15:57:14 +02:00
1 parent a8c0a30f29
commit 1a88a3e6cf
7 files changed
+39 -67

No files matched your search

+7 -2
View File
@@ -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 () => {
+2 -14
View File
@@ -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);
+2 -13
View File
@@ -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[] = [];
+2 -13
View File
@@ -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[] = [];
+2 -13
View File
@@ -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[] = [];
+2 -12
View File
@@ -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;
+22
View File
@@ -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<number> {
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;