fix(withings): keep weight and blood pressure when every request is refused

A run where Withings refused every requested type stored all of them, core
types included, as outside the plan for a week; later runs then requested
nothing and moved the watermark on with no readings, losing the gap. A
refusal is now remembered only from a run in which another type was
accepted. A run that read nothing keeps the watermark and reports that it
did not land, and the watermark also holds while plan memory skips a core
type (weight, blood pressure, pulse, free body composition).
This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:45:38 +02:00
1 parent 8ce6a83da5
commit 6feaf6849a
4 files changed
+175 -18

No files matched your search

+112 -2
View File
@@ -105,6 +105,7 @@ import {
import {
releasePlanGatedWithingsParks,
requestWithinPlan,
shouldPersistPlanLeg,
WITHINGS_ADVANCED_MEASTYPES,
WITHINGS_PLAN_MAX_REQUESTS,
} from "../api-plan";
@@ -394,19 +395,128 @@ describe("syncUserMeasurements inside the Withings API plan", () => {
expect(requestedStartdates.every((s) => s === expected)).toBe(true);
});
it("holds the watermark and records no failure when the plan refuses everything", async () => {
it("treats a run where every request was refused as transient: nothing remembered, watermark held", async () => {
refused = new Set([
1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73,
]);
const result = await syncUserMeasurements("user-1");
expect(result).toEqual({ imported: 0, failed: false });
expect(result).toEqual({ imported: 0, failed: true });
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
// A 277 stays off the ledger, and a run that read nothing is no success.
expect(recordSyncFailure).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(requestedTypes.length).toBeLessThanOrEqual(
WITHINGS_PLAN_MAX_REQUESTS,
);
// Weight and blood pressure are not remembered as refused.
expect(persistedLegs()).toEqual([]);
// The fault clears; the next run asks for everything again and reads the
// whole gap since the last good sync.
refused = new Set(WITHINGS_ADVANCED_MEASTYPES);
requestedTypes.length = 0;
requestedStartdates.length = 0;
const next = await syncUserMeasurements("user-1");
expect(next.failed).toBe(false);
expect(requestedTypes[0]).toEqual(expect.arrayContaining([1, 9, 10, 11]));
expect(createdTypes()).toEqual(
expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]),
);
const fromLastGood = String(
Math.floor(
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
),
);
expect(requestedStartdates.every((s) => s === fromLastGood)).toBe(true);
});
it("holds the watermark when plan memory leaves nothing to request", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
connection({
version: 1,
measures: {
unavailable: [...WITHINGS_MEASTYPES],
checkedAt: new Date().toISOString(),
},
}) as never,
);
const result = await syncUserMeasurements("user-1");
expect(requestedTypes).toHaveLength(0);
expect(result).toEqual({ imported: 0, failed: true });
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("holds the watermark when a core type was skipped from plan memory", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
connection({
version: 1,
measures: {
unavailable: [1, ...WITHINGS_ADVANCED_MEASTYPES],
checkedAt: new Date().toISOString(),
},
}) as never,
);
const result = await syncUserMeasurements("user-1");
expect(requestedTypes).toHaveLength(1);
expect(requestedTypes[0]).not.toContain(1);
expect(result.failed).toBe(false);
expect(createdTypes()).toContain("BLOOD_PRESSURE_SYS");
// Weight was not read in this window, so the window stays open for it.
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
});
it("still moves the watermark when only advanced types are remembered", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
connection({
version: 1,
measures: {
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
checkedAt: new Date().toISOString(),
},
}) as never,
);
await syncUserMeasurements("user-1");
expect(prisma.withingsConnection.update).toHaveBeenCalledWith({
where: { userId: "user-1" },
data: { lastSyncedAt: expect.any(Date) },
});
});
});
describe("shouldPersistPlanLeg", () => {
it("remembers a refusal only from a run where something else was accepted", () => {
expect(
shouldPersistPlanLeg({ gated: true, reprobed: false, acceptedAny: true }),
).toBe(true);
expect(
shouldPersistPlanLeg({
gated: true,
reprobed: false,
acceptedAny: false,
}),
).toBe(false);
expect(
shouldPersistPlanLeg({ gated: true, reprobed: true, acceptedAny: false }),
).toBe(false);
expect(
shouldPersistPlanLeg({ gated: false, reprobed: true, acceptedAny: true }),
).toBe(true);
expect(
shouldPersistPlanLeg({
gated: false,
reprobed: false,
acceptedAny: true,
}),
).toBe(false);
});
});
+24 -4
View File
@@ -58,6 +58,18 @@ export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
91, 155, 170, 123, 73,
];
/**
* The meastypes Withings lists as free in every plan and that carry the core
* of the measure leg: weight (1), blood pressure (9, 10), pulse (11) and the
* body-composition readings the table lists as free (fat-free mass 5, fat
* mass 8, muscle mass 76, hydration 77, bone mass 88). A run that leaves one
* of them out on the strength of plan memory has not read the window for it,
* so the measure watermark must not move past it.
*/
export const WITHINGS_CORE_MEASTYPES: readonly number[] = [
1, 5, 8, 9, 10, 11, 76, 77, 88,
];
/**
* Sleep-summary fields HealthLog requests that the table marks advanced: the
* sleep score, the overnight HRV and the respiration rate. The nightly average
@@ -323,15 +335,23 @@ export async function persistWithingsPlanLeg(
}
/**
* Decide whether a run's outcome is worth writing down. A run that met a 277
* always is; so is a re-probe (the full set was asked for), whatever it found;
* a run that skipped remembered keys and was accepted changes nothing.
* Decide whether a run's outcome is worth writing down.
*
* A run that met a 277 is, but only when some other request in the same run
* was accepted: a plan never refuses everything, so a run where every request
* was refused (a transient fault upstream, a misconfigured developer app)
* proves nothing about any single key, and remembering it would skip weight
* and blood pressure for a week. A re-probe that met no 277 is written down
* too, so a cleared refusal is forgotten. A run that skipped remembered keys
* and was accepted changes nothing.
*/
export function shouldPersistPlanLeg(opts: {
gated: boolean;
reprobed: boolean;
acceptedAny: boolean;
}): boolean {
return opts.gated || opts.reprobed;
if (opts.gated) return opts.acceptedAny;
return opts.reprobed;
}
// ── lifting parks an earlier build left ──────────────────────────────────────
+7 -1
View File
@@ -566,7 +566,13 @@ export async function syncUserSleep(
),
});
summaries = plan.results;
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
await persistWithingsPlanLeg(userId, "sleepSummary", {
unavailable: [...skip, ...plan.unavailable],
checkedAt: now.toISOString(),
+32 -11
View File
@@ -22,6 +22,7 @@ import {
requestWithinPlan,
shouldPersistPlanLeg,
WITHINGS_ADVANCED_MEASTYPES,
WITHINGS_CORE_MEASTYPES,
WITHINGS_PLAN_GATED_STATUS,
type WithinPlanResult,
} from "./api-plan";
@@ -273,7 +274,13 @@ export async function syncUserMeasurements(
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
throw err;
}
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
if (
shouldPersistPlanLeg({
gated: plan.gated,
reprobed,
acceptedAny: plan.acceptedAny,
})
) {
try {
await persistWithingsPlanLeg(userId, "measures", {
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
@@ -297,15 +304,26 @@ export async function syncUserMeasurements(
},
});
}
if (plan.gated && !plan.acceptedAny) {
// The plan refused every type asked for. Nothing was read, so the
// watermark stays where it is (a later plan change re-reads the gap), and
// nothing failed: the connection is healthy, the plan is the limit. The
// settings card names what the plan withholds.
await recordSyncSuccess(userId, "withings", {
leg: WITHINGS_LEG_MEASURES,
if (!plan.acceptedAny) {
// Nothing was read: either plan memory left nothing to ask for, or every
// request was refused. Every plan carries weight and blood pressure, so a
// run where all of it was refused is a fault upstream, not a plan answer.
// Nothing is remembered (above), the watermark stays where it is so the
// next run reads the whole gap, and the run reports that it did not land.
// It stays off the failure ledger like every other 277: a plan answer must
// never climb the strike ladder or park the connection.
getEvent()?.addWarning(
requested.length === 0
? "withings: plan memory left no measure type to request"
: "withings: every measure request was refused with 277",
);
annotate({
action: {
name: "withings.sync.plan_nothing_read",
details: { requested: requested.length, requests: plan.requests },
},
});
return { imported: 0, failed: false };
return { imported: 0, failed: true };
}
const fetched = plan.results;
@@ -491,12 +509,15 @@ export async function syncUserMeasurements(
// 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;
// The same holds for a core type left out on the strength of plan memory:
// that memory may be wrong, and the re-probe must find the window intact.
const skippedCore = skip.filter((t) => WITHINGS_CORE_MEASTYPES.includes(t));
const holdWatermark = plan.unverified.length > 0 || skippedCore.length > 0;
if (holdWatermark) {
annotate({
action: {
name: "withings.sync.plan_watermark_held",
details: { unverified: plan.unverified },
details: { unverified: plan.unverified, skippedCore },
},
});
} else {