HTTP idempotency middleware for Go: length-prefixed body fingerprinting, true wait-for-in-progress, and pluggable storage (in-memory, Postgres, Redis).
Released as v0.3.0. 0.x semver — API may shift before v1.0; pin to a specific version in production. v0.3 ships additively on top of v0.2; the
Storeinterface and existingConfigfields are unchanged. See CHANGELOG.md for the full list.
idemkit is a net/http middleware that gives any Go service idempotent POST / PUT / PATCH / DELETE. Same Idempotency-Key + same body → cached replay. Same key + different body → 422 conflict (Stripe-style, configurable to IETF 409). Concurrent duplicates → second waits for the first.
Shipping in v0.3:
net/httpmiddleware- In-memory
Store(single-instance, race-safe, zero non-stdlib deps) with optional proactive expiry janitor - Postgres
Store(store/pg, pgx/v5 — cross-instance coordination viaINSERT ... ON CONFLICT+ row-based reclaim, pollingWait, opt-in LISTEN/NOTIFY) - Redis
Store(store/redis, go-redis/v9 — Lua-scripted single-RTT atomicBegin/Save/Release, pollingWait, opt-in pub/sub overlay; Cluster-compatible without hash tags) - Length-prefixed request fingerprinting (method + path + query + body)
- Streaming safe-skip on
http.Flusher— the silent foot-gun every prior library misses MaxRequestBytes/MaxResponseBytescapsKeyScopefor tenant isolation- Generation tokens for safe
Saveunder lock-timeout-reclaim race Result.Clone()— defensive copying at store boundaries- Stripe-style 422 (
ConflictStripe, default) or IETF draft-07 §2.6 409 (ConflictIETF) on body mismatch, both configurable viaOnConflict - Conformance test suite documenting each mode's contract
go get github.com/polanski13/idemkitRequires Go 1.25 or later. The core idemkit package and store/mem have zero non-stdlib runtime dependencies. Backend subpackages bring their own driver as a direct dep (store/pg → github.com/jackc/pgx/v5, store/redis → github.com/redis/go-redis/v9); users who only need in-memory storage don't link either.
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/polanski13/idemkit"
"github.com/polanski13/idemkit/store/mem"
)
func main() {
store := mem.New(mem.Config{
TTL: time.Hour,
LockTimeout: 30 * time.Second,
})
mw := idemkit.Middleware(store, idemkit.Config{})
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"id":"ch_%d","status":"succeeded"}`, time.Now().UnixNano())
})
http.Handle("/v1/charges", mw(h))
log.Fatal(http.ListenAndServe(":8080", nil))
}Test it:
curl -X POST -i -H "Idempotency-Key: ch_001" http://localhost:8080/v1/charges
# 201 Created
# {"id":"ch_1715533101...","status":"succeeded"}
curl -X POST -i -H "Idempotency-Key: ch_001" http://localhost:8080/v1/charges
# Same ID. Response includes header: X-Idemkit-Replayed: trueFull runnable example: examples/nethttp/main.go.
A runnable chi example lives in examples/chi. It uses a separate Go module so chi stays out of the core idemkit dependency graph.
cd examples/chi
go run .In another terminal, send the same idempotency key and body twice:
curl -i -X POST http://localhost:8080/v1/charges \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ch_001" \
-H "X-Tenant-ID: tenant_a" \
-d '{"amount":1000}'
# 201 Created
# {"id":"ch_1715533101...","status":"succeeded"}
curl -i -X POST http://localhost:8080/v1/charges \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ch_001" \
-H "X-Tenant-ID: tenant_a" \
-d '{"amount":1000}'
# Same ID. Response includes header: X-Idemkit-Replayed: trueA replay with the same key but a different body returns the default Stripe-style conflict:
curl -i -X POST http://localhost:8080/v1/charges \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ch_001" \
-H "X-Tenant-ID: tenant_a" \
-d '{"amount":2000}'
# 422 Unprocessable Entity
# idemkit: idempotency-key conflict (body_mismatch)X-Tenant-ID is read by the example's fake auth middleware and passed to Config.KeyScope, isolating identical keys per tenant.
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/polanski13/idemkit"
"github.com/polanski13/idemkit/store/pg"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
if err := pg.ApplySchema(ctx, pool); err != nil {
log.Fatal(err)
}
store := pg.New(pool, pg.Config{
TTL: 24 * time.Hour,
LockTimeout: 30 * time.Second,
PollInterval: 100 * time.Millisecond,
})
mw := idemkit.Middleware(store, idemkit.Config{})
http.Handle("/v1/charges", mw(handler()))
log.Fatal(http.ListenAndServe(":8080", nil))
}The schema is in store/pg/schema.sql. ApplySchema is idempotent (uses IF NOT EXISTS); production deployments typically run it via a migration tool (goose, atlas, sqlx-migrate) instead of at startup.
Wait is polling-based by default (100 ms). For reactive wake-up, enable LISTEN/NOTIFY by passing a dedicated *pgx.Conn via Config.ListenConn:
listenConn, _ := pgx.Connect(ctx, dsn) // dedicated, not from the pool
defer listenConn.Close(ctx)
store := pg.New(pool, pg.Config{
TTL: 24 * time.Hour,
LockTimeout: 30 * time.Second,
PollInterval: 100 * time.Millisecond,
ListenConn: listenConn,
})
defer store.Close()The conn must be a dedicated *pgx.Conn outside pgxpool (LISTEN is connection-state). Polling stays as the correctness backstop — LISTEN is a latency hint, not a replacement.
package main
import (
"log"
"net/http"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/polanski13/idemkit"
"github.com/polanski13/idemkit/store/redis"
)
func main() {
client := goredis.NewClient(&goredis.Options{Addr: "localhost:6379"})
defer client.Close()
store := redis.New(client, redis.Config{
TTL: 24 * time.Hour,
LockTimeout: 30 * time.Second,
PollInterval: 100 * time.Millisecond,
})
mw := idemkit.Middleware(store, idemkit.Config{})
http.Handle("/v1/charges", mw(handler()))
log.Fatal(http.ListenAndServe(":8080", nil))
}redis.New accepts redis.UniversalClient — *Client, *ClusterClient, *Ring, and *FailoverClient all work transparently. Every Lua script in the store touches exactly one key, so the store is Redis Cluster-compatible without hash tags.
For reactive Wait (sub-poll-interval wake-up on Save / Release), enable the opt-in pub/sub overlay:
store := redis.New(client, redis.Config{
TTL: 24 * time.Hour,
LockTimeout: 30 * time.Second,
PollInterval: 100 * time.Millisecond,
PubSub: true,
})
defer store.Close()Polling stays as the correctness backstop — Redis pub/sub has no persistence, so the overlay is purely a latency optimization, never the sole signal.
Idempotency keys arrive in a client-supplied header. Treat them as untrusted.
A malicious or buggy client can flood the store with distinct keys, growing memory until TTL expires entries. Mitigations the library exposes:
MaxRequestBytescaps per-request memory (1 MiB default).TTLbounds entry lifetime (24h default).- Rate-limit upstream of this middleware by source IP or authenticated principal —
idemkitdoes not.
Two users submitting the same key + same body to the same endpoint would otherwise share a cache entry — user B could see user A's response. To prevent:
cfg := idemkit.Config{
KeyScope: func(r *http.Request) string {
return userIDFromContext(r.Context())
},
}KeyScope is folded into the storage key, namespacing cache entries per principal. Without it, the cache namespace is global per endpoint.
idemkit does not validate keys in v0.1. Recommended (not enforced): UUIDv7 or similar opaque identifier, max 255 bytes. Wire validation via a custom KeyExtractor if you need stricter rules.
Begin(key, hash) ─► Fresh ─► run handler ─► Save / Release
├─► InFlight ─► Wait ─► replay (or retry on release)
└─► Done ─► replay
- First request: middleware claims the key (
BeginreturnsFresh), runs your handler, captures the response, callsSave. - Duplicate, same body: middleware sees
Doneand replays the cached response withX-Idemkit-Replayed: true. - Concurrent duplicate: middleware sees
InFlight, blocks inWait, then replays the result. - Same key, different body: 422 Unprocessable Entity (Stripe default; override via
OnConflict).
SHA-256 over length-prefixed framing of:
- Method
- Path
- Canonicalised query (keys sorted, values sorted within each key)
- Body bytes
- Optional selected headers (default: none)
- Optional
KeyScopevalue (when used directly, not via middleware)
Length-prefixing prevents boundary-ambiguity collisions like (method="POST", path="/foo") vs (method="POS", path="T/foo") that affect concat-with-separator schemes used by other libraries.
If a handler exercises http.Flusher.Flush() or writes more than MaxResponseBytes, the response is marked uncacheable and passed through to the client unchanged. SSE, long-polling, and large file downloads work transparently — they just aren't cached.
For known-streaming endpoints, opt out explicitly:
cfg.SkipFunc = func(r *http.Request) bool {
return r.URL.Path == "/v1/events" // SSE stream
}type Config struct {
Header string // default: "Idempotency-Key"
TTL time.Duration // default: 24h
LockTimeout time.Duration // default: 30s
Methods []string // default: POST, PUT, PATCH, DELETE
MaxRequestBytes int64 // default: 1 MiB
MaxResponseBytes int64 // default: 1 MiB
CacheServerErrors bool // default: false (skip 5xx)
ConflictMode ConflictMode // default: ConflictStripe
Hasher func([]byte) []byte // default: SHA-256
KeyExtractor func(r *http.Request) (string, error) // default: reads Header
KeyScope func(r *http.Request) string // optional: tenant prefix
SkipFunc func(r *http.Request) bool // optional: opt-out predicate
OnConflict func(http.ResponseWriter, *http.Request, ConflictReason)
Logger *slog.Logger // default: slog.Default()
}Zero values are replaced with defaults at Middleware construction time. To opt into caching 5xx responses, set CacheServerErrors: true.
| Backend | Status | Package | Use case |
|---|---|---|---|
| In-memory | v0.1 | github.com/polanski13/idemkit/store/mem |
Tests, single-instance deployments |
| Postgres | ✅ v0.2 (LISTEN/NOTIFY ✅ v0.3) | github.com/polanski13/idemkit/store/pg |
Production, cross-instance coordination |
| Redis | ✅ v0.3 | github.com/polanski13/idemkit/store/redis |
Production, lowest-latency cross-instance; Cluster-compatible |
| Custom | always | implement idemkit.Store |
Anything else |
- Stripe semantics (default,
ConflictMode: ConflictStripe) — 422 Unprocessable Entity on body-hash mismatch, replay on match, wait on concurrent duplicate. Tested ininternal/conformance/stripe_test.go. - IETF
draft-ietf-httpapi-idempotency-key-header-07(ConflictMode: ConflictIETF) — 409 Conflict on body-hash mismatch per §2.6, otherwise identical replay/wait semantics. Tested ininternal/conformance/ietf_draft07_test.go. What's not (yet) implemented from the draft: RFC 7807 Problem Details response bodies, per-method validation. Status-code conformance is the substantive difference; the rest of the middleware (Methods filter, KeyScope, replay headers) is mode-independent and equally compliant under either mode.
Q: Why not just use a Postgres unique constraint?
A: That covers "second request fails", but not "second request waits for the first" or "second request replays the cached response". idemkit does all three.
Q: What happens on server crash mid-request?
A: The in-flight claim is held until LockTimeout (30s default) expires, after which the entry is reclaimable by the next caller. For the in-memory store, the entire entry is lost on process restart — appropriate for in-mem (no stale claim survives).
Q: Can the same key be reused across endpoints? A: Not in v0.1 — path is part of the request fingerprint, so reusing a key with a different path produces a 422 conflict. Best practice: generate a unique key per request (UUIDv7 is recommended).
Q: Can I cache 5xx responses?
A: Off by default (CacheServerErrors: false). 5xx replays can mask transient infrastructure errors; safer to let clients retry. Set CacheServerErrors: true to opt in.
Q: How big is the perf overhead? A: Replay path adds ~1.4 μs marginal overhead vs no middleware; fresh path (claim + handler + Save) adds ~3.6 μs. Pass-through routes (wrong method or no key) cost essentially nothing. On a 10K rps service that's about 1.4% of a CPU core for replays and 3.6% for fresh claims — typically dwarfed by actual handler work. Numbers are Apple M4 steady-state; expect tighter variance on dedicated server hardware. See BENCHMARKS.md for the full breakdown, methodology, and reproduction commands.
Q: What about SSE / streaming endpoints?
A: idemkit detects http.Flusher.Flush() and silently skips caching. Pass-through to the client is unaffected. For endpoints you know upfront should bypass caching (file downloads, WebSocket upgrades), use SkipFunc.
Q: How is this different from velmie/idempo?
A: See COMPARISON.md. Short version: idemkit adds Postgres + Redis with consistent semantics (v0.2 + v0.3), opt-in pub/sub-coordinated wait for both, selectable conflict semantics, streaming safe-skip in the v0.1 default, and an explicit threat-model section. velmie/idempo is Redis-only with polling wait, but is also smaller, simpler, and has shipped.
| Version | Adds | Status |
|---|---|---|
| v0.1.0 | net/http middleware, in-mem store, fingerprinting, Stripe conflict, streaming safe-skip, threat model |
✅ released |
| v0.2.0 | Postgres store, IETF conflict mode, chi example, conformance test suite, generation tokens, Result.Clone(), optional janitor for in-mem |
✅ released |
| v0.3.0 | Redis store, opt-in LISTEN/NOTIFY (Postgres), opt-in Redis pub/sub overlay | ✅ released |
| v1.0 | Stable API, semver guarantees | planned |
Out of scope for v1.0: Prometheus / OpenTelemetry hooks (use the Logger field and your own observability stack), request-body streaming, custom codecs.
See DESIGN.md for architectural decisions, deviations from the original plan, and named limitations.
MIT — see LICENSE.
- Brandur Leach — the original 2017 blueprint that everyone re-implements.
- Stripe API docs — for the 422-on-mismatch convention that
idemkitfollows by default. - IETF httpapi WG — for finally drafting a spec.
- velmie/idempo — prior art whose existence made
idemkit's differentiation crisp.