From 8ce6a83da53eb1b866bf5230a4653d6aee578d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:44:05 +0200 Subject: [PATCH] fix(withings): never remember a type the plan probe could not resolve When the request budget ran out mid-split, every subset not yet tried was stored as refused, so free types (temperature, SpO2, body composition) could be skipped for a week and named as withheld by the plan. Those types are now unverified: not remembered, asked for again on the next run, and the measure watermark holds until every type was read or proven refused. --- src/lib/withings/__tests__/sync-plan.test.ts | 75 +++++++++++++++++++- src/lib/withings/api-plan.ts | 37 ++++++---- src/lib/withings/sync.ts | 24 +++++-- 3 files changed, 116 insertions(+), 20 deletions(-) diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index 24ab77259..b94b29b54 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -109,6 +109,7 @@ import { WITHINGS_PLAN_MAX_REQUESTS, } from "../api-plan"; import { withingsPlanUnavailableFeatures } from "../plan-features"; +import { WITHINGS_MEASTYPES } from "../client"; const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z"); @@ -315,6 +316,73 @@ describe("syncUserMeasurements inside the Withings API plan", () => { expect(recordSyncFailure).not.toHaveBeenCalled(); }); + it("never remembers a type the budget could not resolve, and converges over runs", async () => { + // Two undocumented refusals on top of the documented set. Over the 19 + // types the first run runs out of budget before it reaches 12, 54, 71, + // 76, 77 and 88, all free. + refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6, 35]); + let stored: unknown = null; + let lastSyncedAt = LAST_GOOD_SYNC; + vi.mocked(prisma.withingsConnection.findUnique).mockImplementation( + (async () => connection(stored, lastSyncedAt)) as never, + ); + vi.mocked(prisma.withingsConnection.update).mockImplementation( + (async (args: { data: { lastSyncedAt: Date } }) => { + lastSyncedAt = args.data.lastSyncedAt; + return {}; + }) as never, + ); + const runs: Array<{ unavailable: number[]; startdates: string[] }> = []; + const read = new Set(); + + for (let run = 0; run < 4; run++) { + vi.mocked(prisma.$executeRaw).mockClear(); + vi.mocked(prisma.measurement.create).mockClear(); + requestedStartdates.length = 0; + await syncUserMeasurements("user-1"); + const [persisted] = persistedLegs(); + if (persisted) { + const value = persisted.value as { + unavailable: number[]; + checkedAt: string; + }; + stored = { version: 1, measures: value }; + } + for (const types of requestedTypes.splice(0)) { + if (!types.some((t) => refused.has(t))) + types.forEach((t) => read.add(t)); + } + const current = stored as { + measures?: { unavailable: number[] }; + } | null; + runs.push({ + unavailable: current?.measures?.unavailable ?? [], + startdates: [...requestedStartdates], + }); + } + + const truly = [...refused].sort((a, b) => a - b); + // No run ever stored a type Withings did not refuse. + for (const r of runs) { + expect(r.unavailable.every((t) => refused.has(t))).toBe(true); + } + expect(runs.at(-1)!.unavailable).toEqual(truly); + // Every type the plan allows was read in an accepted request, the free + // ones the first run never reached included. + expect(read).toEqual( + new Set(WITHINGS_MEASTYPES.filter((t) => !refused.has(t))), + ); + expect([...read]).toEqual(expect.arrayContaining([12, 54, 71, 76, 77, 88])); + // The first run read nothing for the unresolved types, so the second + // still reads from the last good sync. + const fromLastGood = String( + Math.floor( + (LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000, + ), + ); + expect(runs[1]!.startdates.every((s) => s === fromLastGood)).toBe(true); + }); + it("reads the whole gap since the last good sync", async () => { await syncUserMeasurements("user-1"); @@ -423,7 +491,7 @@ describe("requestWithinPlan", () => { expect(second.unavailable.sort()).toEqual(["c", "f"]); }); - it("stops at the request budget and counts what is left as unavailable", async () => { + it("stops at the request budget and leaves what is left unverified, not unavailable", async () => { const result = await requestWithinPlan({ keys: ["a", "b", "c", "d", "e", "f", "g", "h"], knownGated: [], @@ -433,7 +501,10 @@ describe("requestWithinPlan", () => { }, }); expect(result.requests).toBe(3); - expect(result.unavailable.sort()).toEqual([ + // Only the requests actually made prove anything: [a..h], [a..d] and [a,b] + // were refused, which isolates no single key. + expect(result.unavailable).toEqual([]); + expect(result.unverified.sort()).toEqual([ "a", "b", "c", diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index 50704a36e..bbb3cd7ac 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -16,7 +16,8 @@ * documents as advanced, then, if it is still refused, splits what is left * in halves until every accepted subset has been read and every refused key * is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget - * could not resolve counts as unavailable until the next probe. + * could not resolve is unverified, never remembered as refused, and asked + * for again on the next run, which starts from what this one proved. * - `WithingsApiPlanState` remembers per request family what the plan * refused, so later runs skip those keys without paying a refused round * trip, and re-probes them weekly in case the account's plan changes. @@ -91,8 +92,16 @@ const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/; export interface WithinPlanResult { /** Everything the accepted requests returned. */ results: R[]; - /** The requested keys the plan refused (or the budget could not clear). */ + /** + * The requested keys the plan refused: isolated alone, or dropped as + * documented advanced after the whole request was refused. + */ unavailable: K[]; + /** + * The requested keys the budget ran out on. Neither read nor proven + * refused: not to be remembered as unavailable, and to be asked for again. + */ + unverified: K[]; /** True when at least one request was refused with 277. */ gated: boolean; /** True when at least one request was accepted. */ @@ -108,8 +117,9 @@ export interface WithinPlanResult { * are dropped and the rest asked for again; on a further 277 the remainder is * split in halves, depth first and in input order, so the same refusal always * spends the same requests. A single key that is still refused is unavailable. - * When the budget runs out, the subsets not yet tried count as unavailable for - * now; the weekly re-probe asks for them again. + * When the budget runs out, the subsets not yet resolved are unverified: the + * caller remembers only what was proven, so the next run skips that and spends + * its budget on the rest. * * Any error other than a 277 propagates unchanged: this only decides what the * plan allows, never whether the connection works. @@ -124,6 +134,7 @@ export async function requestWithinPlan(opts: { const keys = [...opts.keys]; const results: R[] = []; const unavailable: K[] = []; + const unverified: K[] = []; let requests = 0; let gated = false; let acceptedAny = false; @@ -143,10 +154,10 @@ export async function requestWithinPlan(opts: { }; if (keys.length === 0) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (await attempt(keys)) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } const knownGated = new Set(opts.knownGated); @@ -155,14 +166,14 @@ export async function requestWithinPlan(opts: { if (dropped.length > 0) { unavailable.push(...dropped); if (remaining.length === 0) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (requests >= budget) { - unavailable.push(...remaining); - return { results, unavailable, gated, acceptedAny, requests }; + unverified.push(...remaining); + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (await attempt(remaining)) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } } @@ -170,14 +181,14 @@ export async function requestWithinPlan(opts: { // part has been read and each refused key stands alone. if (remaining.length === 1) { unavailable.push(...remaining); - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } const stack: K[][] = halves(remaining).reverse(); remaining = []; while (stack.length > 0) { const subset = stack.pop()!; if (requests >= budget) { - unavailable.push(...subset); + unverified.push(...subset); continue; } if (await attempt(subset)) continue; @@ -188,7 +199,7 @@ export async function requestWithinPlan(opts: { stack.push(...halves(subset).reverse()); } - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } function halves(list: K[]): K[][] { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 67392e7b6..cf9eadff4 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -290,6 +290,7 @@ export async function syncUserMeasurements( name: "withings.sync.plan_gated", details: { unavailable: plan.unavailable, + unverified: plan.unverified, requests: plan.requests, acceptedAny: plan.acceptedAny, }, @@ -486,11 +487,24 @@ export async function syncUserMeasurements( return { imported, failed: true }; } - // Update last synced timestamp - await prisma.withingsConnection.update({ - where: { userId }, - data: { lastSyncedAt: new Date() }, - }); + // Types the probe budget could not resolve were not read this run. Moving + // the watermark past them would drop their readings in this window for + // good, so it stays put until a run reads (or proves refused) every type. + // Re-reading the accepted types is idempotent. + const holdWatermark = plan.unverified.length > 0; + if (holdWatermark) { + annotate({ + action: { + name: "withings.sync.plan_watermark_held", + details: { unverified: plan.unverified }, + }, + }); + } else { + await prisma.withingsConnection.update({ + where: { userId }, + data: { lastSyncedAt: new Date() }, + }); + } // Record success — even when measures.length === 0, completing the // round-trip is the signal "the connection is healthy". A user who