fix(idempotency): replay a key only for the same request body

The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.

Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.

Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 10:14:22 +02:00
1 parent 808b2672cd
commit b55850967d
12 files changed
+761 -22

No files matched your search

+8 -6
View File
@@ -162,12 +162,14 @@ paths:
pattern: ^[A-Za-z0-9_\-:.]{8,128}$
minLength: 8
maxLength: 128
description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the
work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED
rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no
key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different
route is a different key. While a first request under this key is still in flight, a second is refused with
409 rather than queued."
description: "Makes this write safe to retry. Send the same key with the same body again and the first response is
replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as
JSON with key order ignored; a different body under the same key is not a retry and runs as a new write,
while the first response stays stored for retries of the first request. A key that does not match the
pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a
request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the
same key on a different route is a different key. While a first request under this key is still in flight, a
second with the same body is refused with 409 rather than queued."
requestBody:
required: true
content: