Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Idempotency

Use request keys to make retried controls safe.

developer

POST /v1/sessions is optionally idempotent. Send an Idempotency-Key when a client may retry a create and needs the completed 201 identifiers replayed. The implementation is deliberately per Handler process, not a distributed coordination service.

Key and body identity

The header name is exactly Idempotency-Key. An absent or empty value leaves the normal create path untouched. A nonempty key is limited to 255 bytes. The server hashes the exact raw request body with SHA-256 before JSON decoding, so the key is bound to the bytes that the client sent, not to a normalized object.

InputBoundary behavior
No header or empty valueNormal create; no idempotency entry is read or written.
Key up to 255 bytesLook up the key with the raw-body hash.
Key over 255 bytes400 with invalid_parameter; the rig and store are not touched.
Existing key, different body bytes409 with idempotency_conflict; no new session is created.

The store entry contains the body hash, the successful createResponse, and an expiry time. The default time-to-live is 24 hours. Expiry is lazy: the next lookup removes an expired entry and treats it as a miss.

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
	baseURL+"/v1/sessions", bytes.NewReader(body))
if err != nil {
	return err
}
req.Header.Set("Idempotency-Key", clientKey)
resp, err := http.DefaultClient.Do(req)

The raw body includes the distinction between an absent or empty body and a body such as {}. Retrying must therefore send the same bytes if it expects a replay.

Retry behavior

The lookup and create sequence has three outcomes:

%%{init: {"theme":"dark"}}%%
flowchart TD
    A[POST /v1/sessions] --> B{Idempotency-Key?}
    B -- no --> C[Decode and create normally]
    B -- yes --> D[Hash raw body]
    D --> E{Fresh entry?}
    E -- miss --> F[Decode, NewSession, attach, optional Submit]
    F --> G{Full success?}
    G -- yes --> H[Store 201 response for 24h]
    G -- no --> I[Return error; store nothing]
    E -- same hash --> J[Replay cached 201 and identifiers]
    E -- different hash --> K[409 idempotency_conflict]
    E -- expired --> F

A sequential retry with the same key and byte-identical body returns 201 and the exact original session_id. If the original body included blocks, the replayed response also carries the same command_id; NewSession and Submit are not called again. A failed create is never cached, so retrying it starts a fresh attempt.

Concurrency and scope

The store mutex protects only its in-memory map. It is not held across NewSession or Submit. Consequently, two truly concurrent requests with the same key can both observe a miss and create two sessions. The last completed success becomes the cached replay. A subsequent request after those calls have finished uses the stored result. A caller requiring single-flight behavior must serialize retries or provide coordination above this handler.

The record lives only in the Handler’s process. It is not shared across pods, restarted processes, or independent Handler values. A key that reaches a different pod can therefore create another session even within the 24-hour window.

Source and runnable proof

The create path and response shape are implemented in handlers_lifecycle.go. The store’s bounds, hash comparison, TTL, and lazy eviction are in idempotency.go. Sequential, expiry, oversized-key, replay, and concurrent behavior are covered by handlers_lifecycle_test.go and idempotency_test.go. Run:

go test ./pkg/serve

← back to documentation