mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
787aafaee1
commit
8ce6a83da5
3 files changed
+116
-20
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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[][] {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user