Skip to content

feat(http): make a retried write safe to send twice - #281

Merged
aquie00t merged 1 commit into
mainfrom
feature/idempotency-key
Sep 6, 2026
Merged

feat(http): make a retried write safe to send twice#281
aquie00t merged 1 commit into
mainfrom
feature/idempotency-key

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes the [deferred] Idempotency-Key on write endpoints item on the mobile roadmap. It was scheduled to land before Play Billing, where a duplicate matters far more than a duplicate post.

Summary

A client that loses the response to a request cannot know whether the request landed. On a mobile network that is routine, and retrying is the only thing it can do — so today that retry is a second post, a second comment, a second uploaded file.

Send Idempotency-Key and the retry is answered from the first attempt instead of running again, with Idempotent-Replay: true on the reply. Records live 24 hours in Redis. A request with no key behaves exactly as it did before, which is what leaves the web client untouched.

The claim is a single SET NX EX through the new CachePort.setIfAbsent, so the store settles the race rather than the API reading and then writing — the same "the write is the claim" shape DigestDelivery uses.

Opt-in per route (config: { idempotency: true }), on the seven writes where a duplicate is real damage: posts, both comment endpoints, articles, messages, and the two upload endpoints — uploads because they cost storage and moderation. Everything else is already repeatable: likes, follows and bookmarks are idempotent by nature, a report has a unique constraint, a device registration is an upsert. Wrapping those would buy a round trip and nothing else.

Notable decisions

  • The record key carries the account (idem:v1:<userId>:<method>:<route>:<key>). A key is a value the client invents; two people picking the same one is ordinary, and a shared bucket would hand one of them the other's response.
  • The record carries a fingerprint of the body, so a key reused with a different request is a 409 rather than the earlier result replayed over a client bug.
  • Only 2xx is stored. A 4xx is deterministic — the handler will say the same thing again — and a 5xx must stay retryable, or a transient failure would block that action for a day. Neither spends the key.
  • It fails open. An unreachable Redis logs and lets the request through. This is a safety net over a write that already works; a hard dependency would turn a cache blip into "nobody can post anything". docs/idempotency.md says plainly that an endpoint which moves money should revisit that trade rather than inherit it.
  • Uploads are guarded by the key alone. A multipart body is a stream that has not been read when the claim is made, so there is nothing to fingerprint. Documented rather than papered over.

One bug worth naming

The first cut fingerprinted with JSON.stringify(body, Object.keys(body).sort()), intending "key order should not matter". That second argument is a filter, applied at every depth — so any nested key absent from the top-level list is dropped, and two bodies differing only somewhere nested fingerprint identically. That is worse than missing a duplicate: it replays the wrong response to a genuinely different request. Replaced with a small recursive stable serialiser, and the three cases now have tests.

Tests

  • Unit, 1514 passing (14 new): key scoping by account and by route, the version prefix, fingerprint stability and key-order independence at depth, a nested difference being noticed, array order staying significant, absent and stream bodies, and which status codes are remembered.
  • E2E tests/e2e/idempotency/idempotency.test.ts: the same key creating one post and replaying the answer; the same key with a different body refused; two accounts using one key kept apart; no key still creating two posts; and a rejected request not spending its key.
  • tsc -p tsconfig.build.json --noEmit, eslint, prettier --check clean.

Nothing to configure — no new environment variables, and the behaviour is inert until a client sends the header.

AI Asistan: Opus 5

A client that loses the response to a request cannot know whether the request
landed, and on a mobile network that is routine. Retrying is the only thing it
can do, and today that retry is a second post, a second comment, a second
uploaded file.

An Idempotency-Key claims a slot through CachePort.setIfAbsent - one SET NX
EX, so the store decides the race rather than the API reading and then
writing - and a retry is answered from the first attempt instead of running
again. Opt-in on the seven writes where a duplicate is real damage; a like, a
follow and a device registration are already repeatable and pay nothing.

The record key carries the account, because a key is a value the client
invents and two people can pick the same one. It also carries a fingerprint of
the body, so a key reused with a different request is refused rather than
answered with the earlier result.

It fails open: an unreachable cache logs and lets the request through. This is
a safety net over a write that already works, and a hard dependency would turn
a cache blip into nobody being able to post.
@aquie00t
aquie00t merged commit 43c0c97 into main Sep 6, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/idempotency-key branch September 6, 2026 09:32
github-actions Bot pushed a commit that referenced this pull request Sep 6, 2026
# [1.27.0](v1.26.0...v1.27.0) (2026-09-06)

### Features

* **billing:** take Google Play purchases and notifications ([#282](#282)) ([a4975fc](a4975fc))
* **http:** make a retried write safe to send twice ([#281](#281)) ([43c0c97](43c0c97))
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.27.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant