Networks fail. A mobile client sends POST /orders, the server creates the order, and the response is lost on the way back. The client cannot tell whether the order exists, so it retries, and the customer is charged twice. An idempotency key fixes this: the client sends a unique Idempotency-Key header with the request and the same key on every retry; the server runs the request once and replays the stored answer to the retries.
c, _ := app.Cache()
r.Group("/api/v1", func(g *router.Group) {
g.Use(auth.Required(apiGuard)) // first, so the scope is the authenticated user
g.Use(api.Idempotency(c.Store(), api.IdempotencyOptions{}))
})The first POST or PATCH carrying an Idempotency-Key header runs and its response is stored; a retry with the same key gets the stored response with Idempotent-Replayed: true instead of running again.
| Situation | Answer |
|---|---|
| Same key, same request, finished | stored response, replayed |
| Same key while the first is still running | 409 |
| Same key, different method, URL or body | 422 (and an idempotency.key_reused security event) |
| Invalid key (1–255 printable characters) | 400 |
Key sent but Scope is empty (no authenticated user, no Authorization, no custom Scope) | 400 (and an idempotency.unscoped security event) |
| Store unavailable | 503: the request is refused rather than risking a duplicate |
| Option | Default |
|---|---|
Methods | POST, PATCH |
Required | false (400 when the header is missing if true) |
TTL | 24h for stored responses |
LockTTL | 1 minute for in-flight requests |
MaxBody | 1 MiB fingerprinted |
Scope | the authenticated user (auth.Principal: guard and user id, length-prefixed), else a SHA-256 of the Authorization header with any auth.Tokens prefix removed, so one token keeps one scope under any prefix |
- Keys are scoped to the caller, so one user can never replay another's response.
- Put
Idempotencyafterauth.Required(orauth.Optional): the scope is then the authenticated user, the same across token rotation, and a revoked token is refused before any replay. In front of auth only a hash of theAuthorizationheader is available, and a revoked token could still replay its stored responses until they expire. - Cookie sessions behind
auth.Requiredare scoped by user too. For guests or a custom identity, setScopeto something that identifies the caller. Without it, a request carrying a key is refused with 400 instead of sharing one scope between every visitor:
api.IdempotencyOptions{
Scope: func(r *http.Request) string {
return currentUserID(r) // "" for guests: their keyed requests get 400
},
}- To share keys between all callers on purpose (rare), return a constant such as
"public". - Responses with status 500 or above are not stored, so the client can retry.
Set-Cookieis never replayed. - Any
cache.Storeworks: memory, database or Redis.
Step by step#
1. Install the middleware after authentication
c, err := app.Cache() // CACHE_STORE=database or Redis when several servers run
if err != nil {
return err
}
r.Group("/api/v1", func(g *router.Group) {
g.Use(auth.Required(apiGuard))
g.Use(api.Idempotency(c.Store(), api.IdempotencyOptions{}))
g.Post("/orders", orders.Store).Name("orders.store")
})2. Send a key from the client
KEY=$(uuidgen) # one new key per logical operation
curl -s -X POST localhost:3000/api/v1/orders -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' -d '{"product_id": 7, "quantity": 1}'
# 201 Created, the order runs
curl -si -X POST localhost:3000/api/v1/orders -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' -d '{"product_id": 7, "quantity": 1}'
# 201 Created again, with "Idempotent-Replayed: true": no second order3. Retry with the same key in your client
async function createOrder(body: unknown, token: string) {
const key = crypto.randomUUID() // created once, before the first attempt
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch("/api/v1/orders", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.status !== 409 && res.status < 500) return res // 409: the first attempt is still running
} catch {
// network error: retry with the same key
}
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))
}
throw new Error("order failed")
}4. Make the key mandatory where duplicates are expensive
g.Post("/payments", payments.Store).Use(api.Idempotency(c.Store(), api.IdempotencyOptions{Required: true}))Requests without the header now get 400 instead of running unprotected.
Testing#
client := app.Client(t).WithToken(plain).WithHeader("Idempotency-Key", "order-1")
client.PostJSON("/api/v1/orders", body).AssertCreated()
client.PostJSON("/api/v1/orders", body).AssertCreated().AssertHeader("Idempotent-Replayed", "true")Pitfalls#
- A new key per retry defeats the purpose. Generate the key once per operation, before the first attempt, and reuse it for every retry.
- The same key for a different request gets 422. A key identifies one operation (method, URL and body), not a session.
- The memory store with several servers. A retry that lands on another server runs again. Use
CACHE_STORE=databaseor Redis. TTLshorter than your clients' retry window. AfterTTL(24h by default) the key is forgotten and a late retry runs again.- Handlers that take longer than
LockTTL(1 minute): after it, a retry is no longer answered 409 and may run concurrently. RaiseLockTTLfor slow endpoints, or move the work to a queue. - 5xx responses are not stored, so the client can retry them. Make sure a handler that failed half-way can run again safely (use a transaction).
Security notes#
- Keys are scoped to the caller, so one user can never read another user's stored response by guessing a key.
- If the cache store is unavailable the request is refused with 503 (fail closed) rather than risking a duplicate charge.
- Stored responses live in the cache store: they can include personal data from the response body. Use a store you trust with that data, and keep
TTLno longer than needed.Set-Cookieis never stored or replayed.