From 82ccac2fd0c5fe8df05209bd0e735cc4eadaa12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 21:53:37 +0200 Subject: [PATCH] fix(ecg): let a whole sweep of recordings through the ingest limit The iPhone app posts every ECG its sweep has not yet consumed and, on a 429, stops and restarts the same sweep on its next wake. With a limit of 60 per minute a sweep of 61 or more could never finish: the same last recording was refused on every sync, so the newest ECG never arrived. The limit is 600 per minute now, enough for more than a decade of weekly strips in one sweep. Refs #1060 --- src/app/api/insights/ecg/route.ts | 13 +++++++++---- tests/integration/ecg-ingest-roundtrip.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 1a0bc0f9d..c6d17269c 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -64,10 +64,15 @@ const MAX_SAMPLES = 32_768; // any real recording. A DoS ceiling, not a tight bound. const MAX_BODY_BYTES = 2 * 1024 * 1024; -// One recording per request, so the limit is per recording. A person records -// a handful of strips a week; 60/min still drains a full watch history in -// minutes and stops a leaked wildcard token from saturating the write path. -const INGEST_RATE_LIMIT_MAX = 60; +// One recording per request, so the limit is per recording. The iPhone app +// sends every recording its sweep has not yet consumed in one go and, on a +// 429, stops and starts the same sweep again on its next wake. A window that +// held more than the limit could therefore never finish: with 60 per minute +// the 61st recording was refused on every sync and the newest ECG never +// arrived (#1060). 600 covers more than a decade of weekly strips in one +// sweep and still stops a leaked token from saturating the write path; a +// request is at most 2 MB and usually about 100 KB. +const INGEST_RATE_LIMIT_MAX = 600; const INGEST_RATE_LIMIT_WINDOW_MS = 60 * 1000; // Only the three ECG verdicts. `RhythmClassification` in the database has six diff --git a/tests/integration/ecg-ingest-roundtrip.test.ts b/tests/integration/ecg-ingest-roundtrip.test.ts index 2f321980e..24ac2f631 100644 --- a/tests/integration/ecg-ingest-roundtrip.test.ts +++ b/tests/integration/ecg-ingest-roundtrip.test.ts @@ -205,6 +205,24 @@ describe("POST /api/insights/ecg — round trip through the real routes", () => expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10); }); + it("takes a whole sweep of recordings in one go (#1060)", async () => { + // The app posts every recording its sweep has not consumed and restarts + // the sweep after a 429; a limit below the sweep's size refused the same + // last recording on every sync. Seventy distinct strips must all land. + const statuses: number[] = []; + for (let i = 0; i < 70; i += 1) { + const posted = await ingest( + ingestPayload({ + externalRecordingId: `sweep-${i}`, + recordedAt: new Date(Date.UTC(2026, 5, 1, 8, i)).toISOString(), + }), + ); + statuses.push(posted.status); + } + expect(statuses.filter((s) => s === 429)).toHaveLength(0); + expect(await getPrismaClient().ecgRecording.count()).toBe(70); + }); + it("never writes the waveform as plaintext", async () => { const posted = await ingest(ingestPayload()); const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({