Reference
Idempotency
Reference for go-sequent's tenant-scoped idempotency: the (TenantID, IdempotencyKey) key space, memory and Postgres stores, default no-op replay, Strict mode with ErrReplay, and wiring order.
Idempotency
Package: idempotency
Idempotency lets a client retry a command safely. If a command with a given key already succeeded, a retry with the same key does not run the handler again.
Key space
The key is the pair (TenantID, IdempotencyKey). Two tenants can use the same key string without colliding. Setting an IdempotencyKey without a TenantID is an error: idempotency: TenantID required when IdempotencyKey is set.
ctx = tenancy.WithTenant(ctx, tenantID)
cmd.Meta().IdempotencyKey = "merchant-order:42"
err := app.Service.Execute(ctx, cmd)
A command with no IdempotencyKey skips the check completely.
Behaviour
The middleware does the following for each command with a key:
- Look up
(TenantID, IdempotencyKey)in the store. - If a record exists:
- Default: return
nil. The retry is a silent no-op success. Strict: return an error wrappingidempotency.ErrReplay, including the original command id and type.
- Default: return
- If not, run the rest of the chain, which includes the handler and
Repo.Commit. - After success, store a record with the command id, type, status
succeededand time.
If the command fails, no record is written and a retry runs normally.
err := app.Service.Execute(ctx, cmd)
if idempotency.IsReplay(err) {
// Strict mode only: this key was already processed
}
Stores
type Store interface {
Get(ctx context.Context, tenantID uuid.UUID, key string) (*Record, error)
Put(ctx context.Context, rec Record) error
}
| Store | Use |
|---|---|
idempotency.NewMemory() |
Tests and demos |
idempotency.NewPostgres(pool) |
Production. Uses table idempotency_keys with primary key (tenant_id, key). |
On Postgres, the record is written in the same transaction as the command. idempotency.Postgres joins the connection from WithinTx, so the foreign key to command_id can see the command row that has not been committed yet. If the transaction rolls back, so does the key.
Wiring
app := sequent.New(sequent.Config{},
sequent.WithIdempotency(idempotency.NewPostgres(pool), false), // second argument: strict
)
app.Service.Middleware = append(
[]command.Middleware{tenancy.Middleware},
app.Service.Middleware...,
)
Tenancy must run first, so Meta.TenantID is set when idempotency looks it up. Middleware runs outer-first. You can also build it directly: idempotency.Middleware(idempotency.Config{Store: s, Strict: false}).
Interaction with AuthZ
sequent.WithAuthorizer installs a filter, and filters run after the middleware chain. A replayed key therefore returns before the filter runs. The first attempt was authorized when it ran. If you want authorization on every attempt, put security.Middleware(auth) ahead of the idempotency middleware. See AuthZ.
Choosing keys
- Derive keys from the business intent, not from a random value per attempt. A merchant order id, a payment reference, or a client-generated request id that survives retries all work.
- A key must be stable across retries and distinct across intents. If a client generates a new key per retry, idempotency cannot help.
- Keys are tenant-scoped. Do not encode the tenant into the key.
- Reusing one key for a different command type is a client bug. The store keeps the first command’s id and type.
What it does not do
- It does not replace OCC. Two different requests with different keys can still race on one aggregate. See Unique keys and OCC.
- It does not protect a request with no key.
- It does not return the original result. The default replay returns
nil; read the current state or the projection if the client needs the outcome.
Handlers should still be defensive, with natural keys and guards, because idempotency is a transport-level safety net and not a replacement for domain rules.
Related
Docs track the go-sequent library. When APIs drift, the source wins.