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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:44:05 +02:00
1 parent 787aafaee1
commit 8ce6a83da5
3 files changed
+116 -20

No files matched your search

+73 -2
View File
@@ -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<number>();
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",
+24 -13
View File
@@ -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<K, R> {
/** 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<K, R> {
* 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<K, R>(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<K, R>(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<K, R>(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<K, R>(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<K, R>(opts: {
stack.push(...halves(subset).reverse());
}
return { results, unavailable, gated, acceptedAny, requests };
return { results, unavailable, unverified, gated, acceptedAny, requests };
}
function halves<K>(list: K[]): K[][] {
+19 -5
View File
@@ -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