Idempotency
Idempotency-Key: a POST sent twice runs once.
Needs inkan 0.8
@inkanjs/idempotency 0.2 needs inkan 0.8.0 or newer, below 1.0. It keeps answers in any
inkan Store.
npm i @inkanjs/idempotencyimport { } from "@inkanjs/idempotency";
.(())
.("/payments", { : .({ : .() }) }, () => charge(..));POST /payments idempotency-key: 5f0c… {"amount":10} → 201, runs
POST /payments idempotency-key: 5f0c… {"amount":10} → 201, the same answer, idempotent-replayed: true
POST /payments idempotency-key: 5f0c… {"amount":99} → 422 idempotency-key-reused
(the same key while the first one still runs) → 409 idempotency-in-flight, retry-after: 1A client that sends a POST again, because the answer got lost on the way, gets the first answer again instead of a second order or a second payment. As Stripe and the IETF draft do it.
Behaviour
- No key, no change. A request without the header runs as always, unless
required. A key is 1 to 255 visible ASCII characters (a UUID is right); anything else is a 400idempotency-key-invalid. - The same request is the same method, path, query and body (compared as JSON with its keys sorted), hashed with SHA-256. The same key with anything else is a 422.
- What is kept. The status, the headers (not content-length, set-cookie, the request id, etag or content-encoding) and the body. A replay goes through the route's contract and the other hooks again, so it is compressed like a fresh answer. 5xx, 409 and 429 answers are not kept, nor is a streamed body: the key is free again.
- When it decides. In a
preHandlerhook, after the input was checked: a request whose input breaks the contract, or that anonRequesthook (auth, rate limit) turned away, is not kept. - At the same time. Two requests with the same key in one process never both run: the
second is a 409. Across processes too: the key is claimed with the store's atomic
add, so of two processes only one runs it. - Whose key. Keys are the client's, so they are kept per caller, and the caller is part
of the fingerprint too. Without
scopethe caller is the first of:ctx.user.id(what@inkanjs/authsets, so a user is the same caller by cookie or by token), the Authorization header, an API key the route'ssecuritytakes, a session cookie named incookies(all three hashed), and last the client's address (ctx.ip; mindtrustProxy). - No caller, no key. A route with
securitynever falls back to the address: when nothing there names the caller, the answer is a 500idempotency-no-scopethat asks forscope. When your callers are known some other way, say how:scope: (ctx) => String(ctx.user.id).
Options
| option | default | |
|---|---|---|
store | memoryStore() | any inkan Store: where answers are kept (below) |
methods | ["POST", "PATCH"] | |
header | "Idempotency-Key" | |
required | false | a request without the key is a 400 idempotency-key-missing |
ttl | 86400000 | ms an answer is kept, a day |
lockTtl | 60000 | ms a running request holds its key, should its process die midway |
keep | 2xx, and 4xx but 409 and 429 | (status) => boolean: which answers are kept |
scope | the user, else the credentials, else the address | (ctx) => string: whose key it is (above) |
cookies | ["sid", "__Host-sid"] | session cookies that name the caller when nothing else does |
Store
Answers are kept in an inkan Store, by default memoryStore() from
@vxnsin/inkan: one process, at most 10000 keys, the one used longest ago dropped first. For
several processes any shared Store works:
import { redisStore } from "@inkanjs/redis";
app.register(idempotency({ store: redisStore(redis) }));The plugin uses get, set, delete and the atomic add, with TTLs in milliseconds
(ttl, lockTtl); a store without add is refused when the plugin is made. A key the store
does not have is undefined; anything else under it, null too, is somebody's and never a
free key. Records are plain JSON, a binary body as base64.
OpenAPI
Every route it acts on gets the optional Idempotency-Key header parameter (required with
required) and the 409 and 422 answers, as problem documents. It is a shared plugin:
registered on the app it serves every route, inside a plugin only that plugin's routes.