Idempotency
Idempotency middleware makes POST requests safe to retry over a flaky
network. The first request runs the pipeline normally; the response is
cached keyed on the Idempotency-Key header. Subsequent requests with
the same key and the same body short-circuit the pipeline and replay
the cached response.
The pattern is borrowed from Stripe’s API. This page documents the
shipped implementation in maniflex/middleware/idempotency.
The contract
An Idempotency-Key identifies one logical operation. Sending the
same key twice with the same body means “I’m retrying — give me the
same result as last time, do not run the operation again.” Sending the
same key with a different body means “I am confused about my own
state and you should refuse me.” Sending no key means “do not apply
idempotency to this request.”
Idempotency-Key: e3b0c442-98fc-1c14-9afb-...
The key is opaque to the framework — any string the client chooses. A UUID per logical operation is the conventional choice.
Registering
The middleware lives on the Deserialize step at maniflex.After
position, scoped to the operations that should be retryable:
import (
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/middleware/idempotency"
)
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: maniflex.NewMemoryCache(),
TTL: 24 * time.Hour,
}),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
Why After on Deserialize: the middleware needs ctx.RawBody to compute
the body hash, and the default Deserialize handler populates that. Running
after the default ensures the body is present.
Config:
| Field | Default | Purpose |
|---|---|---|
Store | required | the cache backend (anything implementing maniflex.CacheStore) |
TTL | 24h | how long a cached response is replayable |
KeyFunc | ctx.Auth.UserID then ctx.Request.RemoteAddr | derives the per-caller scope |
HeaderRequired | false | when true, requests without Idempotency-Key are rejected with 400 |
Locker | in-process singleflight | serialises concurrent first-misses on the same key. Supply a Redis-SETNX implementation of idempotency.Locker for multi-replica deployments; see Concurrent first-misses |
The cache key
The cache key is composed of four parts:
<KeyFunc(ctx)>:<model>:<operation>:<idempotency-key>
KeyFunc(ctx)— the per-caller scope. Defaults to the authenticated user ID, falling back to the remote IP for anonymous requests. Override to use the API token or any other identifier.model:operation— limits a key’s effect to one (model, op) pair. The sameIdempotency-Keycan be reused safely for, say,POST /api/ordersandPOST /api/refunds— they are different cache keys.idempotency-key— the client-supplied value.
The body hash is not part of the key — it is part of the cached
entry and compared on lookup. This is intentional: it lets the
middleware detect “same key, different body” and respond with
422 IDEMPOTENCY_KEY_REUSED.
What gets cached
Only successful responses (2xx). Failed responses are not cached, on purpose — retrying a failed write is the whole point of idempotency. A first attempt that 5xx’d should be re-run on the retry, not replayed.
The cached entry carries:
type Entry struct {
maniflex.APIResponse // StatusCode, Data, Error, Meta
BodyHash string
StoredAt time.Time
}
StatusCode— replayed verbatim.Data,Meta— replayed verbatim.BodyHash— SHA-256 ofctx.RawBody, used to detect body mismatch.
The replayed response carries the header Idempotent-Replayed: true
so the client can tell a replay from a fresh execution.
What happens on each call
| Request | Effect |
|---|---|
First request with Idempotency-Key: K | runs the pipeline; if 2xx, caches the response |
| Repeat with same key, same body | skips the pipeline; replays cached response; adds Idempotent-Replayed: true |
| Repeat with same key, different body | 422 IDEMPOTENCY_KEY_REUSED |
| Repeat with same key after TTL | runs the pipeline as if it were the first time |
Request with no Idempotency-Key | passes through (unless HeaderRequired is true) |
Choosing a store
Two implementations cover the common cases.
maniflex.NewMemoryCache
In-process, per-replica:
idempotency.Config{Store: maniflex.NewMemoryCache(), TTL: time.Hour}
Suitable for single-replica development. In a multi-replica deployment each replica has its own cache — a retry routed to a different replica gets a fresh run, defeating the purpose.
Redis (or any shared store)
There is no built-in Redis cache. maniflex.CacheStore is a three-method
interface you implement against whatever shared store you run:
type CacheStore interface {
Get(ctx context.Context, key string) (any, bool)
Set(ctx context.Context, key string, value any, ttl time.Duration)
Delete(ctx context.Context, key string)
}
The value the idempotency middleware stores is an idempotency.Entry, so a
cross-process backend must serialise it on Set and decode it back into an
idempotency.Entry on Get. A store that returns the raw bytes (or a decoded
map) instead makes the middleware abort with IDEMPOTENCY_CACHE_CORRUPT.
Register the middleware with your implementation exactly as with the in-process
cache:
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: myRedisCache, // your maniflex.CacheStore implementation
TTL: 24 * time.Hour,
}),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
Any backend that can store a TTL’d key/value (Redis, Memcached, DynamoDB with TTL) works, provided it round-trips the stored value’s type.
Concurrent first-misses
Two requests carrying the same Idempotency-Key and identical bodies that
arrive at exactly the same moment both miss the cache. Without
serialisation, both would run the full pipeline and both would write —
silently breaking the contract that one key represents one logical
operation. The middleware uses a Locker to serialise these first-misses.
The default Locker is in-process (singleflight-style): the second
goroutine blocks on a channel until the first releases, then re-checks
the cache and replays. This handles single-replica deployments correctly
out of the box.
For multi-replica deployments, supply Config.Locker with a backend that
synchronises across processes — typically Redis SETNX with a short TTL:
type Locker interface {
Acquire(ctx context.Context, key string, ttl time.Duration) (acquired bool, release func(), err error)
}
Acquire returns acquired=true to exactly one caller per key per TTL
window. The caller must invoke release once the cache entry is written
(or the work has failed). acquired=false means another caller holds (or
held) the lock — singleflight-style lockers block first, then return
false so the loser can replay from cache; SETNX-style lockers return
immediately and the loser re-checks the cache itself.
A Locker error (e.g. Redis network blip, request context cancelled)
fails open: the middleware runs the pipeline directly, mirroring the
pre-Locker behaviour rather than returning 503. This trades correctness
under partial outages for availability — appropriate for a feature whose
whole purpose is “make retries safe.”
Scoping to specific endpoints
For most APIs, idempotency belongs only on a handful of write endpoints —
payment, order placement, account creation. Scope with ForModel so
unrelated POST requests are unaffected:
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{Store: store}),
maniflex.ForModel("Payment", "Order"),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
The middleware passes through for unscoped requests with no measurable cost.
Requiring the header
For endpoints where retries without a key are dangerous, set
HeaderRequired: true:
idempotency.Middleware(idempotency.Config{
Store: store,
HeaderRequired: true,
})
A scoped registration is the right shape — make the header mandatory on payment but optional on lower-stakes resources:
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: store, HeaderRequired: true,
}),
maniflex.ForModel("Payment"), maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
A missing header on a covered endpoint returns
400 IDEMPOTENCY_KEY_REQUIRED.
Use with custom actions
Action endpoints run a trimmed pipeline that skips Deserialize, so
pipeline-level idempotency does not apply automatically. To get the
same behaviour for an action, include the middleware in the action’s
Middleware list:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/place",
Handler: placeOrder,
Middleware: []maniflex.MiddlewareFunc{
auth.JWTAuth(secret),
idempotency.Middleware(idempotency.Config{Store: store}),
},
})
An action skips the Deserialize step that populates ctx.RawBody, so here the
middleware reads the body itself (via ctx.EnsureRawBody) and restores it — the
action handler’s ctx.BindJSON then works exactly as it would without
idempotency, in whatever order. Before this was fixed (v0.2.3), the middleware
hashed an empty body in an action, so the same key, different body check
silently never fired and a reused key replayed the first response regardless of
the payload.
Edge cases
- Same key, different body. Returns
422 IDEMPOTENCY_KEY_REUSED. The contract is that one key represents one logical operation; reusing it for a different payload is almost certainly a client bug. - Request currently in flight when retry arrives. The default
in-process
Locker(singleflight-style) holds the second goroutine until the first finishes, at which point it replays from cache — only one pipeline execution runs per process. For multi-replica deployments, supply aConfig.Lockerthat uses RedisSETNXso two replicas don’t both run the pipeline. See Concurrent first-misses below. - Cache eviction before TTL. The retry runs the pipeline again. This is correct behaviour: the cache is a replay mechanism, not a deduplication mechanism. The application’s own uniqueness constraints handle “this thing was already created.”
- Operation that mutates external state. Idempotency caches the
response, not the side effect. A payment that charged a card once on
the first request will return the same
paidresponse on a retry without charging again — because the first request returned with the payment recorded as committed. The action handler is responsible for being idempotent against the external system; the middleware just prevents the framework from issuing duplicate writes.
Operational checklist
- One shared
Storeacross replicas. Don’t useMemoryCachein multi-replica deployments. - TTL longer than your client’s longest retry window. 24h is generous; for mobile clients on flaky networks, 7d is reasonable.
- Scope to the endpoints that benefit. Don’t blanket-apply.
- Pair with
HeaderRequired: trueon payment-like endpoints where client correctness depends on it. - Surface the
Idempotent-Replayedheader in client SDKs so consumers can tell a replay from a fresh execution. - Combine with a uniqueness constraint at the DB level for defence-in-depth. A retry that hits a different replica after cache eviction will run the pipeline; the DB constraint catches the duplicate.