Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FieldDefaultPurpose
Storerequiredthe cache backend (anything implementing maniflex.CacheStore)
TTL24hhow long a cached response is replayable
KeyFuncctx.Auth.UserID then ctx.Request.RemoteAddrderives the per-caller scope
HeaderRequiredfalsewhen true, requests without Idempotency-Key are rejected with 400
Lockerin-process singleflightserialises 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 same Idempotency-Key can be reused safely for, say, POST /api/orders and POST /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 of ctx.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

RequestEffect
First request with Idempotency-Key: Kruns the pipeline; if 2xx, caches the response
Repeat with same key, same bodyskips the pipeline; replays cached response; adds Idempotent-Replayed: true
Repeat with same key, different body422 IDEMPOTENCY_KEY_REUSED
Repeat with same key after TTLruns the pipeline as if it were the first time
Request with no Idempotency-Keypasses 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 a Config.Locker that uses Redis SETNX so 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 paid response 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 Store across replicas. Don’t use MemoryCache in 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: true on payment-like endpoints where client correctness depends on it.
  • Surface the Idempotent-Replayed header 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.