mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(withings): keep the sync inside the account's API plan
Withings split its public API into plans on 2026-10-12. A request naming one meastype outside the free plan is refused whole with status 277, so every measure sync stopped importing anything, and the 200..299 reauth range parked the connection at error_reauth. The measure, sleep-summary and ECG legs now drop the documented advanced types on a 277, split the rest until the refused keys are isolated (bounded), remember the refusal per connection and re-probe weekly. 277 is classified persistent and never enters the ledger; connections parked over it are lifted by the fallback tick, and the held watermark lets the next run read the whole gap.
This commit is contained in:
1 parent
bc7f3bf209
commit
39b0cabb43
12 files changed
+722
-13
No files matched your search
@@ -0,0 +1,18 @@
|
||||
-- v1.42.3 — remember what an integration's upstream refuses by contract.
|
||||
--
|
||||
-- Withings split its public API into plans on 2026-10-12. A self-hoster's own
|
||||
-- developer app sits on the free plan, and a request that names a meastype
|
||||
-- outside that plan is refused whole with status 277. The sync now drops the
|
||||
-- gated types and keeps the rest; this column remembers which ones per
|
||||
-- connection so later runs skip them without a refused round trip, plus when
|
||||
-- that was last checked so a plan upgrade is noticed within a week.
|
||||
--
|
||||
-- Oura's current authorization server enforces scopes per collection. A grant
|
||||
-- issued before HealthLog asked for `spo2` and `heart_health` answers 401 on
|
||||
-- the collections behind them while every other collection succeeds. This
|
||||
-- column holds those collection names so the Oura card can ask for a single
|
||||
-- reconnect instead of painting the connection as broken.
|
||||
--
|
||||
-- Additive only: two nullable columns, no default, no rewrite, no data change.
|
||||
ALTER TABLE "withings_connections" ADD COLUMN IF NOT EXISTS "api_plan_state" JSONB;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_scope_denied" JSONB;
|
||||
@@ -264,6 +264,13 @@ model User {
|
||||
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
|
||||
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
|
||||
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
|
||||
/// v1.42.3 — Oura collections the current grant does not cover, as a JSON
|
||||
/// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
|
||||
/// `resilience`). Written by the sync when a collection answers 401/403 while
|
||||
/// its siblings succeed with the same token, which is a scope the grant never
|
||||
/// carried, not a dead token. The Oura card reads it to ask for one
|
||||
/// reconnect. Cleared on connect, disconnect and credential removal.
|
||||
ouraScopeDenied Json? @map("oura_scope_denied")
|
||||
|
||||
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
|
||||
// level). Strava caps every newly-created API app at single-player mode
|
||||
@@ -4149,6 +4156,13 @@ model WithingsConnection {
|
||||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||||
/// v1.42.3 — what this connection's Withings API plan refuses (status 277),
|
||||
/// per request family: `{ measures, sleepSummary, ecg }`, each
|
||||
/// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without
|
||||
/// paying a refused round trip each time, re-probe them weekly, and lets the
|
||||
/// Withings card name what the plan withholds. Versioned and validated on
|
||||
/// read (`src/lib/withings/api-plan.ts`); reset on reconnect.
|
||||
apiPlanState Json? @map("api_plan_state")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
webhookSubscriptionState: Prisma.DbNull,
|
||||
webhookSubscriptionRetryAt: subscriptionRetryAt,
|
||||
// A reconnect may come with another developer app (another plan), so
|
||||
// what the old grant's plan refused is asked for again.
|
||||
apiPlanState: Prisma.DbNull,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
syncUserMeasurements,
|
||||
} from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
import { releasePlanGatedWithingsParks } from "@/lib/withings/api-plan";
|
||||
import { syncUserSleep } from "@/lib/withings/sync-sleep";
|
||||
import { syncUserEcg } from "@/lib/withings/sync-ecg";
|
||||
import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
|
||||
@@ -74,6 +75,15 @@ export async function handleWithingsFallbackSync(
|
||||
"Withings subscription repair failed; continuing fallback sync",
|
||||
);
|
||||
}
|
||||
// Connections an earlier build parked at `error_reauth` over a Withings
|
||||
// API-plan refusal (277) are lifted first, so this pass and the activity,
|
||||
// sleep and ECG legs read again. Best-effort: a failure here leaves the
|
||||
// rows parked until the next tick, it never blocks the pass.
|
||||
try {
|
||||
await releasePlanGatedWithingsParks();
|
||||
} catch {
|
||||
evt.addWarning("Withings plan-park release failed; continuing");
|
||||
}
|
||||
const connections = await prisma.withingsConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
WITHINGS_MEASTYPES: [1, 9, 10, 11],
|
||||
fetchMeasurements: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
subscribeWebhook: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Withings API plans (since 2026-10-12).
|
||||
*
|
||||
* Withings split its public API into plans. A self-hoster's own developer app
|
||||
* sits on the free "Start for Free" plan, which carries the core biomarkers
|
||||
* (activity, sleep, body, heart). The FDA/CE-cleared biomarkers and a handful
|
||||
* of body-composition extras need the paid "Advanced Biomarkers" plan. A
|
||||
* request that names anything outside the plan is refused WHOLE with status
|
||||
* 277 ("One of the data type requested in this API request is not available in
|
||||
* your API plan") — so one gated meastype in a `getmeas` call used to stop
|
||||
* weight and blood pressure from importing too.
|
||||
*
|
||||
* This module keeps the sync inside the plan:
|
||||
*
|
||||
* - `requestWithinPlan` retries a refused request without the types Withings
|
||||
* 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.
|
||||
* - `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.
|
||||
* - `releasePlanGatedWithingsParks` lifts connections an earlier build parked
|
||||
* at `error_reauth` over a 277. Status 277 sits inside the 200..299 OAuth
|
||||
* range the classifier used to read as a revoked grant; it is a contract
|
||||
* limit, and reconnecting does not change it.
|
||||
*
|
||||
* Which keys are advanced comes from Withings' own biomarker table
|
||||
* (https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/,
|
||||
* the "Advanced Biomarkers" column) and the plans page
|
||||
* (https://static.withings.com/content/api/plans/plans.html).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
|
||||
import { WithingsApiError } from "./response-classifier";
|
||||
|
||||
/** The Withings body status for "not available in your API plan". */
|
||||
export const WITHINGS_PLAN_GATED_STATUS = 277;
|
||||
|
||||
/** Upper bound on requests one plan probe may spend, the first one included. */
|
||||
export const WITHINGS_PLAN_MAX_REQUESTS = 12;
|
||||
|
||||
/** How long a remembered refusal is trusted before the full set is re-probed. */
|
||||
export const WITHINGS_PLAN_REPROBE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Meastypes HealthLog requests that Withings lists under the Advanced
|
||||
* Biomarkers plan: pulse-wave velocity (91), vascular age (155), visceral fat
|
||||
* (170), VO2 max (123) and skin temperature (73). Manual SpO2 (54), body
|
||||
* temperature (71), body composition and blood pressure are listed as free.
|
||||
* Types the table does not list at all (fat ratio 6, legacy temperature 12,
|
||||
* legacy SpO2 35) are left to the split, which finds out instead of guessing.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
|
||||
91, 155, 170, 123, 73,
|
||||
];
|
||||
|
||||
/**
|
||||
* Sleep-summary fields HealthLog requests that the table marks advanced: the
|
||||
* sleep score, the overnight HRV and the respiration rate. The nightly average
|
||||
* heart rate is listed both ways (free for trackers, advanced for the Sleep
|
||||
* Analyzer) and the nightly SpO2 average is not listed, so both go to the split.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [
|
||||
"sleep_score",
|
||||
"sdnn_1",
|
||||
"rr_average",
|
||||
];
|
||||
|
||||
/** The Withings plans page, linked from the settings card and the docs. */
|
||||
export const WITHINGS_API_PLANS_URL =
|
||||
"https://static.withings.com/content/api/plans/plans.html";
|
||||
|
||||
// ── error recognition ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when a caught error is Withings' plan refusal. Reads the typed error
|
||||
* first and falls back to the message shape for errors that lost their
|
||||
* prototype on a job round trip.
|
||||
*/
|
||||
export function isPlanGatedError(err: unknown): boolean {
|
||||
if (err instanceof WithingsApiError) {
|
||||
return err.withingsStatus === WITHINGS_PLAN_GATED_STATUS;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return PLAN_GATED_MESSAGE.test(msg);
|
||||
}
|
||||
|
||||
const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/;
|
||||
|
||||
// ── probing ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WithinPlanResult<K, R> {
|
||||
/** Everything the accepted requests returned. */
|
||||
results: R[];
|
||||
/** The requested keys the plan refused (or the budget could not clear). */
|
||||
unavailable: K[];
|
||||
/** True when at least one request was refused with 277. */
|
||||
gated: boolean;
|
||||
/** True when at least one request was accepted. */
|
||||
acceptedAny: boolean;
|
||||
/** How many requests the call spent. */
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fetch` over `keys`, staying inside the account's API plan.
|
||||
*
|
||||
* The first request asks for every key. On a 277 the documented advanced keys
|
||||
* 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.
|
||||
*
|
||||
* Any error other than a 277 propagates unchanged: this only decides what the
|
||||
* plan allows, never whether the connection works.
|
||||
*/
|
||||
export async function requestWithinPlan<K, R>(opts: {
|
||||
keys: readonly K[];
|
||||
knownGated: readonly K[];
|
||||
fetch: (keys: K[]) => Promise<R[]>;
|
||||
maxRequests?: number;
|
||||
}): Promise<WithinPlanResult<K, R>> {
|
||||
const budget = opts.maxRequests ?? WITHINGS_PLAN_MAX_REQUESTS;
|
||||
const keys = [...opts.keys];
|
||||
const results: R[] = [];
|
||||
const unavailable: K[] = [];
|
||||
let requests = 0;
|
||||
let gated = false;
|
||||
let acceptedAny = false;
|
||||
|
||||
// One request. `true` = accepted (results kept), `false` = refused by plan.
|
||||
const attempt = async (subset: K[]): Promise<boolean> => {
|
||||
requests++;
|
||||
try {
|
||||
results.push(...(await opts.fetch(subset)));
|
||||
acceptedAny = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!isPlanGatedError(err)) throw err;
|
||||
gated = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(keys)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
const knownGated = new Set(opts.knownGated);
|
||||
const dropped = keys.filter((k) => knownGated.has(k));
|
||||
let remaining = keys.filter((k) => !knownGated.has(k));
|
||||
if (dropped.length > 0) {
|
||||
unavailable.push(...dropped);
|
||||
if (remaining.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(remaining)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
}
|
||||
|
||||
// `remaining` is known to be refused as a whole. Split it until each accepted
|
||||
// part has been read and each refused key stands alone.
|
||||
if (remaining.length === 1) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
const stack: K[][] = halves(remaining).reverse();
|
||||
remaining = [];
|
||||
while (stack.length > 0) {
|
||||
const subset = stack.pop()!;
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...subset);
|
||||
continue;
|
||||
}
|
||||
if (await attempt(subset)) continue;
|
||||
if (subset.length === 1) {
|
||||
unavailable.push(subset[0]!);
|
||||
continue;
|
||||
}
|
||||
stack.push(...halves(subset).reverse());
|
||||
}
|
||||
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
function halves<K>(list: K[]): K[][] {
|
||||
const mid = Math.ceil(list.length / 2);
|
||||
return [list.slice(0, mid), list.slice(mid)];
|
||||
}
|
||||
|
||||
// ── remembered state ───────────────────────────────────────────────────────
|
||||
|
||||
/** The request families the plan can refuse independently. */
|
||||
export type WithingsPlanLeg = "measures" | "sleepSummary" | "ecg";
|
||||
|
||||
export interface WithingsPlanLegState<K> {
|
||||
/** What the plan refused at the last probe. Empty = nothing refused. */
|
||||
unavailable: K[];
|
||||
/** When the full set was last asked for. */
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface WithingsApiPlanState {
|
||||
version: 1;
|
||||
measures?: WithingsPlanLegState<number>;
|
||||
sleepSummary?: WithingsPlanLegState<string>;
|
||||
/** `unavailable` holds `"heart"` when the Heart endpoints are refused. */
|
||||
ecg?: WithingsPlanLegState<string>;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseLeg<K>(
|
||||
value: unknown,
|
||||
isKey: (v: unknown) => v is K,
|
||||
): WithingsPlanLegState<K> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const { unavailable, checkedAt } = value;
|
||||
if (!Array.isArray(unavailable) || !unavailable.every(isKey))
|
||||
return undefined;
|
||||
if (typeof checkedAt !== "string" || !Number.isFinite(Date.parse(checkedAt)))
|
||||
return undefined;
|
||||
return { unavailable: [...new Set(unavailable)], checkedAt };
|
||||
}
|
||||
|
||||
const isMeastype = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isSafeInteger(v) && v > 0;
|
||||
const isFieldName = (v: unknown): v is string =>
|
||||
typeof v === "string" && /^[a-z0-9_]{1,64}$/.test(v);
|
||||
|
||||
/**
|
||||
* Persisted JSON is an untrusted boundary. A leg that does not validate is
|
||||
* dropped, which only means the next run asks for the full set again.
|
||||
*/
|
||||
export function parseWithingsApiPlanState(
|
||||
value: unknown,
|
||||
): WithingsApiPlanState {
|
||||
const state: WithingsApiPlanState = { version: 1 };
|
||||
if (!isObject(value) || value.version !== 1) return state;
|
||||
const measures = parseLeg(value.measures, isMeastype);
|
||||
const sleepSummary = parseLeg(value.sleepSummary, isFieldName);
|
||||
const ecg = parseLeg(value.ecg, isFieldName);
|
||||
if (measures) state.measures = measures;
|
||||
if (sleepSummary) state.sleepSummary = sleepSummary;
|
||||
if (ecg) state.ecg = ecg;
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when a remembered refusal is old enough to ask for the full set. */
|
||||
export function isPlanReprobeDue(
|
||||
leg: WithingsPlanLegState<unknown> | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!leg || leg.unavailable.length === 0) return false;
|
||||
return now.getTime() - Date.parse(leg.checkedAt) >= WITHINGS_PLAN_REPROBE_MS;
|
||||
}
|
||||
|
||||
/** The keys a run should leave out: the remembered refusal, unless it is due. */
|
||||
export function planSkipFor<K>(
|
||||
leg: WithingsPlanLegState<K> | undefined,
|
||||
now: Date = new Date(),
|
||||
): K[] {
|
||||
if (!leg || isPlanReprobeDue(leg, now)) return [];
|
||||
return leg.unavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one leg of the plan state. A single statement merges the leg into the
|
||||
* stored object, so the measures, sleep and ECG legs (separate crons, separate
|
||||
* minutes) cannot overwrite each other's memory with a stale read.
|
||||
*/
|
||||
export async function persistWithingsPlanLeg(
|
||||
userId: string,
|
||||
leg: WithingsPlanLeg,
|
||||
state: WithingsPlanLegState<number> | WithingsPlanLegState<string>,
|
||||
): Promise<void> {
|
||||
const value = JSON.stringify({
|
||||
unavailable: [...new Set<unknown>(state.unavailable)],
|
||||
checkedAt: state.checkedAt,
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "withings_connections"
|
||||
SET "api_plan_state" =
|
||||
COALESCE(
|
||||
CASE WHEN jsonb_typeof("api_plan_state") = 'object'
|
||||
AND "api_plan_state"->'version' = '1'::jsonb
|
||||
THEN "api_plan_state" END,
|
||||
'{}'::jsonb
|
||||
)
|
||||
|| jsonb_build_object('version', 1)
|
||||
|| jsonb_build_object(${leg}::text, ${value}::jsonb),
|
||||
"updated_at" = NOW()
|
||||
WHERE "user_id" = ${userId}`;
|
||||
annotate({
|
||||
meta: {
|
||||
withings_plan: {
|
||||
leg,
|
||||
unavailable: state.unavailable.map(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function shouldPersistPlanLeg(opts: {
|
||||
gated: boolean;
|
||||
reprobed: boolean;
|
||||
}): boolean {
|
||||
return opts.gated || opts.reprobed;
|
||||
}
|
||||
|
||||
// ── what the settings card names ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The closed set of reading names the Withings card can show for a plan
|
||||
* refusal, in display order. Server-resolved so every client renders the same
|
||||
* list; each has a label under `settings.integrations.withings.plan.items`.
|
||||
*/
|
||||
export const WITHINGS_PLAN_FEATURES = [
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"vo2Max",
|
||||
"visceralFat",
|
||||
"skinTemperature",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
"sleepHrv",
|
||||
"sleepHeartRate",
|
||||
"sleepRespiratoryRate",
|
||||
"sleepSpo2",
|
||||
"bodyTemperature",
|
||||
"bodyFat",
|
||||
"fatMass",
|
||||
"fatFreeMass",
|
||||
"muscleMass",
|
||||
"bodyWater",
|
||||
"boneMass",
|
||||
"heartRate",
|
||||
"bloodPressure",
|
||||
"weight",
|
||||
] as const;
|
||||
|
||||
export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number];
|
||||
|
||||
const MEASTYPE_FEATURE: Record<number, WithingsPlanFeature> = {
|
||||
1: "weight",
|
||||
5: "fatFreeMass",
|
||||
6: "bodyFat",
|
||||
8: "fatMass",
|
||||
9: "bloodPressure",
|
||||
10: "bloodPressure",
|
||||
11: "heartRate",
|
||||
12: "bodyTemperature",
|
||||
35: "spo2",
|
||||
54: "spo2",
|
||||
71: "bodyTemperature",
|
||||
73: "skinTemperature",
|
||||
76: "muscleMass",
|
||||
77: "bodyWater",
|
||||
88: "boneMass",
|
||||
91: "pulseWaveVelocity",
|
||||
123: "vo2Max",
|
||||
155: "vascularAge",
|
||||
170: "visceralFat",
|
||||
};
|
||||
|
||||
const SLEEP_FIELD_FEATURE: Record<string, WithingsPlanFeature> = {
|
||||
hr_average: "sleepHeartRate",
|
||||
rr_average: "sleepRespiratoryRate",
|
||||
sdnn_1: "sleepHrv",
|
||||
spo2_average: "sleepSpo2",
|
||||
sleep_score: "sleepScore",
|
||||
};
|
||||
|
||||
/**
|
||||
* The readings this connection's plan currently withholds, deduplicated and in
|
||||
* display order. Empty when nothing is refused.
|
||||
*/
|
||||
export function withingsPlanUnavailableFeatures(
|
||||
state: WithingsApiPlanState,
|
||||
): WithingsPlanFeature[] {
|
||||
const found = new Set<WithingsPlanFeature>();
|
||||
for (const t of state.measures?.unavailable ?? []) {
|
||||
const f = MEASTYPE_FEATURE[t];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
for (const field of state.sleepSummary?.unavailable ?? []) {
|
||||
const f = SLEEP_FIELD_FEATURE[field];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg");
|
||||
return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f));
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lift every Withings connection parked over a plan refusal.
|
||||
*
|
||||
* Before 2026-10-14 the classifier read 277 as a revoked grant (it falls in the
|
||||
* 200..299 OAuth range), so the first refused `getmeas` parked the connection
|
||||
* at `error_reauth` and every leg stopped. Reconnecting cannot fix a plan
|
||||
* limit, so these rows are resumed instead: the next run reads inside the plan
|
||||
* and, because a refused run never advanced `lastSyncedAt`, imports everything
|
||||
* since the last good sync. The decision reads the stored error message, which
|
||||
* carries the Withings status the park was recorded with.
|
||||
*
|
||||
* Returns how many rows were lifted.
|
||||
*/
|
||||
export async function releasePlanGatedWithingsParks(): Promise<number> {
|
||||
const rows = await prisma.integrationStatus.findMany({
|
||||
where: {
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
},
|
||||
select: { userId: true, lastError: true },
|
||||
});
|
||||
let released = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.lastError || !parkedOverPlan(row.lastError)) continue;
|
||||
await resumeIntegrationFromPark(row.userId, "withings");
|
||||
released++;
|
||||
}
|
||||
if (released > 0) {
|
||||
annotate({ meta: { withings_plan_parks_released: released } });
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
function parkedOverPlan(lastErrorCiphertext: string): boolean {
|
||||
try {
|
||||
return PLAN_GATED_MESSAGE.test(decrypt(lastErrorCiphertext));
|
||||
} catch {
|
||||
getEvent()?.addWarning("withings plan release: undecryptable lastError");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -244,17 +244,26 @@ export interface WithingsMeasureGroup {
|
||||
measures: Array<{ type: number; value: number; unit: number }>;
|
||||
}
|
||||
|
||||
/** Every meastype HealthLog maps, in ascending order. */
|
||||
export const WITHINGS_MEASTYPES: readonly number[] =
|
||||
Object.keys(MEASURE_TYPE_MAP).map(Number);
|
||||
|
||||
/**
|
||||
* Fetch measurements from Withings API.
|
||||
*
|
||||
* `measTypes` narrows the request to a subset of `MEASURE_TYPE_MAP` (the sync
|
||||
* leaves out what the account's API plan refuses); omitted, every mapped type
|
||||
* is asked for.
|
||||
*/
|
||||
export async function fetchMeasurements(
|
||||
accessToken: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
opts: { measTypes?: readonly number[] } = {},
|
||||
): Promise<WithingsMeasure[]> {
|
||||
const baseParams: Record<string, string> = {
|
||||
action: "getmeas",
|
||||
meastypes: Object.keys(MEASURE_TYPE_MAP).join(","),
|
||||
meastypes: (opts.measTypes ?? WITHINGS_MEASTYPES).join(","),
|
||||
};
|
||||
if (startDate) {
|
||||
baseParams.startdate = String(Math.floor(startDate.getTime() / 1000));
|
||||
|
||||
@@ -100,12 +100,20 @@ const TRANSIENT_CODES = new Set<number>([503, 601, 2554, 2555, 2556]);
|
||||
* - 294 = "already subscribed" (subscribeWebhook treats it as success;
|
||||
* any OTHER endpoint receiving 294 is a contract bug)
|
||||
*
|
||||
* - 277 = "One of the data type requested in this API request is not
|
||||
* available in your API plan" (Withings API plans, 2026-10-12).
|
||||
* A contract limit on the developer app, not a revoked grant, so it
|
||||
* must never park the connection at `error_reauth` the way the
|
||||
* 200..299 range below would. The sync paths handle it before it
|
||||
* reaches the ledger (`src/lib/withings/api-plan.ts`); this entry is
|
||||
* the floor for any path that does not.
|
||||
*
|
||||
* Note: 294 is intentionally listed here. `subscribeWebhook` overrides
|
||||
* the classification for its own call site (already-subscribed is
|
||||
* idempotent there); every other client call that sees 294 surfaces it
|
||||
* as a persistent failure so the bug doesn't hide.
|
||||
*/
|
||||
const PERSISTENT_CODES = new Set<number>([293, 294]);
|
||||
const PERSISTENT_CODES = new Set<number>([277, 293, 294]);
|
||||
|
||||
/**
|
||||
* Classify a single Withings API response.
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import { isPlanGatedError } from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
parkIntegrationAtReauth,
|
||||
@@ -298,6 +299,19 @@ export async function syncUserActivity(
|
||||
// (Withings emits 403 only through the HTTP layer for scope-gated
|
||||
// resources), so the override has to live at the call site rather
|
||||
// than in the shared classifier.
|
||||
//
|
||||
// Steps, distance and calories are free-plan biomarkers, so a plan refusal
|
||||
// (277) is not expected. If Withings ever moves one, the leg reads nothing
|
||||
// rather than failing, so a plan limit cannot park every Withings leg.
|
||||
if (isPlanGatedError(err)) {
|
||||
getEvent()?.addWarning(
|
||||
"withings activity: refused by the API plan (277), skipped",
|
||||
);
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_ACTIVITY,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
|
||||
@@ -69,6 +69,12 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
recordSyncFailure,
|
||||
@@ -370,6 +376,22 @@ export async function syncUserEcg(
|
||||
? Math.max(defaultEndUnix, options.enddate!)
|
||||
: defaultEndUnix;
|
||||
|
||||
// Withings API plans (2026-10-12): ECG signals and the AFib verdicts are
|
||||
// Advanced Biomarkers. On the free plan the Heart endpoints answer 277; that
|
||||
// is remembered, and the leg stays quiet until the weekly re-probe asks again.
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const ecgPlan = parseWithingsApiPlanState(planRow?.apiPlanState).ecg;
|
||||
const ecgRefused = (ecgPlan?.unavailable.length ?? 0) > 0;
|
||||
const ecgReprobe = isPlanReprobeDue(ecgPlan, now);
|
||||
if (ecgRefused && !ecgReprobe) {
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entries: WithingsHeartEntry[];
|
||||
try {
|
||||
entries = await fetchWithingsHeartList(
|
||||
@@ -378,6 +400,12 @@ export async function syncUserEcg(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isPlanGatedError(err)) {
|
||||
await rememberEcgPlan(userId, ["heart"], now);
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
// The Heart List endpoint rides the user.metrics scope, so a 403 here is a
|
||||
// genuine token-revoked / reauth case rather than the activity-scope skip
|
||||
// the sleep/activity syncs guard against.
|
||||
@@ -398,6 +426,9 @@ export async function syncUserEcg(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// A re-probe the plan now accepts: forget the refusal.
|
||||
if (ecgRefused) await rememberEcgPlan(userId, [], now);
|
||||
|
||||
let imported = 0;
|
||||
let waveformsCaptured = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
@@ -520,3 +551,18 @@ export async function syncUserEcg(
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function rememberEcgPlan(
|
||||
userId: string,
|
||||
unavailable: string[],
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "ecg", {
|
||||
unavailable,
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`withings ecg: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
@@ -60,6 +60,16 @@ import {
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -94,8 +104,13 @@ const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
|
||||
* requested; snoring / breathing-disturbance intensity have no enum and are
|
||||
* deliberately left out (no home to store them).
|
||||
*/
|
||||
const SLEEP_SUMMARY_DATA_FIELDS =
|
||||
"hr_average,rr_average,sdnn_1,spo2_average,sleep_score";
|
||||
export const SLEEP_SUMMARY_DATA_FIELDS: readonly string[] = [
|
||||
"hr_average",
|
||||
"rr_average",
|
||||
"sdnn_1",
|
||||
"spo2_average",
|
||||
"sleep_score",
|
||||
];
|
||||
|
||||
/**
|
||||
* One nightly summary row from `POST /v2/sleep?action=getsummary`. The vitals
|
||||
@@ -276,12 +291,13 @@ export async function fetchWithingsSleepSummary(
|
||||
accessToken: string,
|
||||
startymd: string,
|
||||
endymd: string,
|
||||
dataFields: readonly string[] = SLEEP_SUMMARY_DATA_FIELDS,
|
||||
): Promise<WithingsSleepSummary[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: "getsummary",
|
||||
startdateymd: startymd,
|
||||
enddateymd: endymd,
|
||||
data_fields: SLEEP_SUMMARY_DATA_FIELDS,
|
||||
data_fields: dataFields.join(","),
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
@@ -384,6 +400,15 @@ export async function syncUserSleep(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
// The stage timeline is a free-plan biomarker, so a plan refusal here is
|
||||
// not expected. If Withings ever moves it, the leg reads nothing rather
|
||||
// than failing: a 277 must not climb the strike ladder and park the
|
||||
// measures, activity and ECG legs along with it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
|
||||
return 0;
|
||||
}
|
||||
// v1.4.43 W7-B3 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
@@ -513,13 +538,40 @@ export async function syncUserSleep(
|
||||
// v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
|
||||
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
|
||||
// isolated from the stage writes; database failures are hard sync failures.
|
||||
//
|
||||
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
|
||||
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
|
||||
// has the whole summary refused with 277. Ask only for what the plan allows,
|
||||
// remember what it refused, and re-probe weekly.
|
||||
let summaries: WithingsSleepSummary[] = [];
|
||||
try {
|
||||
summaries = await fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
);
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const sleepPlan = parseWithingsApiPlanState(
|
||||
planRow?.apiPlanState,
|
||||
).sleepSummary;
|
||||
const reprobed = isPlanReprobeDue(sleepPlan, now);
|
||||
const skip = planSkipFor(sleepPlan, now);
|
||||
const plan = await requestWithinPlan({
|
||||
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
|
||||
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
fetch: (fields) =>
|
||||
fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
fields,
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: summary fetch failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -10,7 +10,21 @@ import {
|
||||
fetchMeasurements,
|
||||
refreshAccessToken,
|
||||
subscribeWebhook,
|
||||
WITHINGS_MEASTYPES,
|
||||
type WithingsMeasure,
|
||||
} from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_GATED_STATUS,
|
||||
type WithinPlanResult,
|
||||
} from "./api-plan";
|
||||
import { getUserWithingsCredentials } from "./credentials";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -233,13 +247,66 @@ export async function syncUserMeasurements(
|
||||
)
|
||||
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
|
||||
|
||||
let fetched: Awaited<ReturnType<typeof fetchMeasurements>>;
|
||||
// Withings API plans (2026-10-12): a request naming one meastype outside the
|
||||
// account's plan is refused whole with 277. Leave out what the plan refused
|
||||
// last time (unless the weekly re-probe is due), and let `requestWithinPlan`
|
||||
// narrow the request if the plan refuses something new. A refused run never
|
||||
// reaches the watermark stamp below, so the run after this fix reads the
|
||||
// whole gap since the last good sync.
|
||||
const now = new Date();
|
||||
const planState = parseWithingsApiPlanState(connection.apiPlanState);
|
||||
const reprobed = isPlanReprobeDue(planState.measures, now);
|
||||
const skip = planSkipFor(planState.measures, now);
|
||||
const requested = WITHINGS_MEASTYPES.filter((t) => !skip.includes(t));
|
||||
|
||||
let plan: WithinPlanResult<number, WithingsMeasure>;
|
||||
try {
|
||||
fetched = await fetchMeasurements(tokenInfo.accessToken, startDate);
|
||||
plan = await requestWithinPlan({
|
||||
keys: requested,
|
||||
knownGated: WITHINGS_ADVANCED_MEASTYPES,
|
||||
fetch: (measTypes) =>
|
||||
fetchMeasurements(tokenInfo.accessToken, startDate, undefined, {
|
||||
measTypes,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
|
||||
throw err;
|
||||
}
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "measures", {
|
||||
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
// Losing the memory only costs a refused round trip next run.
|
||||
getEvent()?.addWarning(`withings: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
if (plan.gated) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_gated",
|
||||
details: {
|
||||
unavailable: plan.unavailable,
|
||||
requests: plan.requests,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
return { imported: 0, failed: false };
|
||||
}
|
||||
const fetched = plan.results;
|
||||
|
||||
// The app's own plausibility band applies to a provider reading exactly as
|
||||
// it applies to a typed one. A remote that reports a pulse in the millions
|
||||
@@ -470,6 +537,15 @@ export async function recordWithingsSyncFailure(
|
||||
err: unknown,
|
||||
leg: WithingsSyncLeg,
|
||||
): Promise<void> {
|
||||
// A plan refusal (277) is a limit on the developer app, not a failure of the
|
||||
// connection. It never enters the ledger, so no path that forgets to handle
|
||||
// it can climb the strike ladder or park every leg over it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({
|
||||
meta: { withings_plan_gated_unhandled: { leg } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
@@ -531,6 +607,8 @@ export function isWithingsRefreshReauthFailure(message: string): boolean {
|
||||
const n = Number.parseInt(status, 10);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
if (n === 100 || n === 101 || n === 102) return true;
|
||||
// 277 is the API-plan refusal: a limit on the developer app, not a grant.
|
||||
if (n === WITHINGS_PLAN_GATED_STATUS) return false;
|
||||
if (n >= 200 && n <= 299) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user