mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
a8c0a30f29
commit
1a88a3e6cf
7 files changed
+39
-67
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user