Pular para o conteúdo
Esta página ainda não foi traduzida. O conteúdo abaixo está em inglês. Ler em inglês

APIs

Idempotency keys

Make retried POST and PATCH requests safe with the Idempotency-Key header.

Última atualização:

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.

Go
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.

SituationAnswer
Same key, same request, finishedstored response, replayed
Same key while the first is still running409
Same key, different method, URL or body422 (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 unavailable503: the request is refused rather than risking a duplicate
OptionDefault
MethodsPOST, PATCH
Requiredfalse (400 when the header is missing if true)
TTL24h for stored responses
LockTTL1 minute for in-flight requests
MaxBody1 MiB fingerprinted
Scopethe 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 Idempotency after auth.Required (or auth.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 the Authorization header is available, and a revoked token could still replay its stored responses until they expire.
  • Cookie sessions behind auth.Required are scoped by user too. For guests or a custom identity, set Scope to something that identifies the caller. Without it, a request carrying a key is refused with 400 instead of sharing one scope between every visitor:
Go
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-Cookie is never replayed.
  • Any cache.Store works: memory, database or Redis.

Step by step#

1. Install the middleware after authentication

routes/api.go
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

Terminal
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 order

3. Retry with the same key in your client

TypeScript
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

Go
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#

Go
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=database or Redis.
  • TTL shorter than your clients' retry window. After TTL (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. Raise LockTTL for 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 TTL no longer than needed. Set-Cookie is never stored or replayed.