idempo is a small idempotency engine and net/http middleware for implementing the Idempotency-Key pattern in Go services.
It stores the first completed response (configurable) and replays it on subsequent requests with the same key, while preventing accidental key reuse with a different request fingerprint (method + URL + body hash, optionally selected headers).
Requires Go 1.20+.
go get github.com/velmie/idempoOptional Redis store:
go get github.com/velmie/idempo/redispackage main
import (
"log"
"net/http"
"github.com/velmie/idempo"
"github.com/velmie/idempo/middleware"
"github.com/velmie/idempo/memory"
)
func main() {
store := memory.New()
defer func() { _ = store.Close() }()
engine := idempo.NewEngine(store, idempo.WithWaitForInProgress(true))
mw := middleware.Middleware(
middleware.WithEngine(engine),
middleware.WithRequireKey(true),
middleware.WithAllowedResponseHeaders("Content-Type"),
)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
log.Fatal(http.ListenAndServe(":8080", handler))
}- Replays return
X-Idempotent-Replay: true. - Responses larger than the configured limit are streamed, stored as truncated, and replayed without a body (
X-Idempotent-Truncated: true).
Idempotency-Key request -> fingerprint -> Engine.Process(key, fp):
Result.Response != nil-> replay the stored response (X-Idempotent-Replay: true).Result.IsOwner == true-> run the handler ->Engine.Commit(key, token, resp)(orEngine.Unlock(...)on failure).- Otherwise -> another request is in progress (default: return
ErrInProgress, or enable waiting viaidempo.WithWaitForInProgress(true)).
Defaults in middleware.Config (override via middleware.With* options):
- Header:
Idempotency-Key(required only whenmiddleware.WithRequireKey(true)is set). - Methods:
POST,PATCH. - Fingerprint: method + path/query (query params sorted) + SHA-256(body) + optional selected headers (
middleware.WithFingerprintHeaders(...)). - Store predicate: store responses with
status < 500(middleware.WithShouldStore(...)). - Stored headers: none unless allowlisted (
middleware.WithAllowedResponseHeaders(...)).
Default error mapping (override via middleware.WithErrorHandler(...)):
- Missing key or invalid key ->
400. - Body too large for fingerprinting ->
413. - Same key, different fingerprint ->
422. - In progress / wait timeout / key not found or expired ->
409. - Everything else ->
500.
- Request bodies are buffered (up to
MaxBodyBytes) to compute the fingerprint, larger bodies are rejected. - This middleware is not intended for SSE/websockets or hijacked connections.
- Persisted bodies and headers can contain sensitive data; keep
WithAllowedResponseHeaders(...)minimal.
- Pluggable storage via
idempo.Store(memory/for dev/tests,redis/for production). - Safe replays with request fingerprinting (method + path/query + body hash and optional selected headers).
- Configurable concurrency behavior: fail fast or wait and replay (
idempo.WithWaitForInProgress(true)). - Middleware controls: methods, key validation/requirement, what status codes/headers to store, size limits.
Redis store
import (
redisv9 "github.com/redis/go-redis/v9"
"github.com/velmie/idempo"
idemporedis "github.com/velmie/idempo/redis"
)
rdb := redisv9.NewClient(&redisv9.Options{Addr: "localhost:6379"})
store := idemporedis.New(rdb) // defaults to Redis key prefix: "idempotency:"
engine := idempo.NewEngine(store)Middleware tuning
- Default methods:
POST,PATCH(override withmiddleware.WithMethods(...)). - Default limits: 1 MiB request body hashing + 1 MiB response buffering (override with
middleware.WithMaxBodyBytes(...),middleware.WithMaxResponseBytes(...)). - By default, no response headers are stored; enable an allowlist via
middleware.WithAllowedResponseHeaders(...). - Customize fingerprints (e.g., include user headers) via
middleware.WithFingerprintHeaders("X-User-Id").
See the full API on pkg.go.dev: https://pkg.go.dev/github.com/velmie/idempo
go test -v -race ./...
docker run --rm -v "$(pwd)":/app:ro -w /app golangci/golangci-lint:v2.5.0 golangci-lint run ./...MIT - see LICENSE.