inkan

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/idempotency
import {  } 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: 1

A 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 400 idempotency-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 preHandler hook, after the input was checked: a request whose input breaks the contract, or that an onRequest hook (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 scope the caller is the first of: ctx.user.id (what @inkanjs/auth sets, so a user is the same caller by cookie or by token), the Authorization header, an API key the route's security takes, a session cookie named in cookies (all three hashed), and last the client's address (ctx.ip; mind trustProxy).
  • No caller, no key. A route with security never falls back to the address: when nothing there names the caller, the answer is a 500 idempotency-no-scope that asks for scope. When your callers are known some other way, say how: scope: (ctx) => String(ctx.user.id).

Options

optiondefault
storememoryStore()any inkan Store: where answers are kept (below)
methods["POST", "PATCH"]
header"Idempotency-Key"
requiredfalsea request without the key is a 400 idempotency-key-missing
ttl86400000ms an answer is kept, a day
lockTtl60000ms a running request holds its key, should its process die midway
keep2xx, and 4xx but 409 and 429(status) => boolean: which answers are kept
scopethe 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.

On this page