Documentation
¶
Overview ¶
Package idempotency runs work at most once per client-supplied key.
It exists for the case where a client sends a request, never sees the response, and retries — and the work in between spent real money. Without a key the server cannot tell that second request apart from a deliberate second purchase, so it charges the card twice.
The client mints the key ¶
This is the part most easily read backwards. The server never issues a key. The client generates one before its first attempt and reuses that same value on every retry of the same logical operation:
ctx, _ := idempotency.WithNewKey(ctx) // once, OUTSIDE the retry loop
err := policy.Do(ctx, func(ctx context.Context) error {
return send(ctx, req) // every attempt carries the same key
})
That ordering is the whole contract. A key minted inside the retry loop is a new key per attempt, which looks like protection and provides none. Nothing on the server can detect the mistake, because a retry and a deliberate duplicate are byte-identical.
Because a timed-out request never returns anything, there is deliberately no round trip to acquire a key. The client already has it.
What it guarantees ¶
At-most-once *effect*, not exactly-once. Those differ, and the gap is worth naming:
- A result that was recorded is replayed instead of re-run.
- Work that has started and not reported back is refused with ErrInFlight, because "did it happen?" is unanswerable and running it again is the worse guess.
- A key reused for a different request is reported with ErrFingerprintMismatch rather than answered with the earlier result.
What it cannot promise: work that has its effect and then fails. The charge landed, the error came back, nothing was recorded, and the retry charges again. Recording failures instead would be worse — a transient error would be pinned for the whole TTL and the client could never succeed. See WithRecordable.
The claim ¶
Do takes a short lock, re-reads, writes an in-flight record, and releases. The work itself runs outside the lock:
- read the record; replay, refuse, or continue
- lock -> re-read -> write the claim -> unlock
- run the work
- record the result, or release the claim
The obvious alternative — hold the lock for the whole execution and let the lock itself mean "in flight" — is wrong here for four separate reasons, any one of which is disqualifying:
The postgres ScopedLocker runs its callback inside a database transaction. Holding the work there means an open transaction per in-flight request: pool exhaustion, blocked vacuums, replication lag.
The postgres advisory lock folds the key into an int64, so unrelated keys can collide. Under a held lock a collision answers a legitimate request with a refusal; under a short one it costs a sub-millisecond wait.
The generic scoped adapter's default TTL is thirty seconds. Any work slower than that loses mutual exclusion while still running — precisely the failure this package exists to prevent.
A lock leaves no evidence. When a process is killed mid-execution the lock evaporates and the retry runs the work again. A record with its own TTL survives, and the retry is correctly refused until it expires.
Two records, one owner ¶
Every execution writes twice: a claim, then an outcome. The claim carries a ClaimID, and only its owner may complete or release it.
That check is not ceremony. If work outruns InFlightTTL, the claim expires, someone else claims the key, and the original execution finishes into a slot it no longer owns. Writing anyway would hand the new owner a result from a different execution. Instead the write is skipped and idempotency_claims_lost counts it.
That counter is the one to alert on. It is the only remaining path to a duplicate effect, and it always means the same thing: InFlightTTL is too short for the work it guards.
Store failure policy ¶
When the store cannot be read, the two available answers fail in opposite directions, so the choice belongs to the caller. FailClosed (the default) refuses the request: a brief outage becomes downtime rather than duplicate charges. FailOpen runs the work anyway, trading the guarantee for availability.
For anything that moves money, FailClosed is the answer. For a store whose outage is more expensive than a duplicate, FailOpen exists.
Choosing TTLs ¶
InFlightTTL is a deadline for the work, not a tuning knob. Set it above the worst case, not the average — every execution slower than it can produce a duplicate. Two minutes suits a request-shaped workload.
TTL is how long a client may usefully retry. A day is the common answer and matches what payment providers publish. Longer costs storage; shorter means a late retry re-executes.
Endpoints that disagree about that answer do not need a Manager each: Do takes WithCallTTL, which overrides the retention of one call's record. InFlightTTL has no per-call equivalent on purpose — it bounds how long a dead process blocks a retry, which is a property of the deployment rather than of the call.
InFlightTTL is also how long a client is refused after a process dies mid-execution. Nothing better is possible: with the outcome unknown, refusing is the conservative answer.
What T must be ¶
The store is a cache.Cache, and the redis provider serializes with gob. So T must be a concrete struct with exported fields. An interface-typed field needs its concrete types registered with gob; `any` does not work at all.
Every record carries a Version. A record written by a different version is ignored rather than misread, so changing the shape of T is a deploy concern rather than an outage: in-flight keys from the old shape read as misses. Bump recordVersion when T changes shape.
A hard decode failure cannot be told apart from a connection failure through the cache interface, so it follows the store failure policy rather than being treated as a miss.
The memory provider is for tests. It hands back the live pointer with no defensive copy — never mutate a record read from the store — and it needs cache/memory's WithJanitor to reclaim a long TTL, since a key written once and never read again is never lazily evicted. Redis is the production answer.
The locker matters ¶
The noop locker acquires unconditionally. With it, replay still works — which covers the ordinary timeout-then-retry case — but two genuinely concurrent requests can both claim and both execute. The locker argument is required and has no default so that nobody arrives there by accident.
Watching it ¶
idempotency_claims_lost the alert. Work outran InFlightTTL and the
claim was taken by someone else.
idempotency_record_failures the effect happened, the record did not land,
and a retry will run the work again.
idempotency_requests by outcome: executed, replayed, in_flight,
mismatch. The four sum to the request total.
idempotency_store_errors store health.
idempotency_stale_records records ignored for carrying another version;
expected to spike once after a shape change
and then return to zero.
idempotency_latency_ms Do, end to end.
A steady stream of in_flight without matching executed usually means work is dying mid-execution. mismatch is always a client bug.
Transports ¶
This package knows nothing about HTTP or gRPC. idempotency/http and idempotency/grpc adapt it to each, and each ships both halves — the server middleware or interceptor, and the client transport or interceptor that stamps the key — so the header name and metadata key are defined once.
Index ¶
- Constants
- Variables
- func ValidateKey(key Key, maxLength int) error
- func WithKey(ctx context.Context, key Key) context.Context
- type DoOption
- type Fingerprint
- type Key
- type Manager
- type Option
- func WithClock(c clock.Clock) Option
- func WithInFlightTTL(ttl time.Duration) Option
- func WithKeyPrefix(prefix string) Option
- func WithLogger(logger logging.Logger) Option
- func WithMaxKeyLength(maxLength int) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithRecordable[T any](recordable func(*T) bool) Option
- func WithStoreFailurePolicy(policy StoreFailurePolicy) Option
- func WithTTL(ttl time.Duration) Option
- func WithTracerProvider(tracerProvider tracing.TracerProvider) Option
- type Record
- type Result
- type State
- type StoreFailurePolicy
Examples ¶
Constants ¶
const ( // DefaultTTL is how long a completed record is replayable for. DefaultTTL = 24 * time.Hour // DefaultInFlightTTL bounds how long a claim survives without being // completed. It must exceed the worst-case duration of the work being // guarded — see the package documentation on choosing it. DefaultInFlightTTL = 2 * time.Minute // DefaultMaxKeyLength is the longest key accepted, matching the limit // Stripe publishes for the same header. DefaultMaxKeyLength = 255 // DefaultKeyPrefix namespaces both the store and lock keys, so an // idempotency key cannot collide with an unrelated entry in a cache or // locker shared with something else. DefaultKeyPrefix = "idempotency:" )
Variables ¶
var ( // ErrInFlight indicates the key names work that is currently running // elsewhere. The caller has no way to know whether it will succeed, so the // only safe answer is to refuse and let the client retry later. ErrInFlight = platformerrors.New("idempotency key is in flight") // ErrFingerprintMismatch indicates the key was already used for a // different request. Replaying the stored result would hide a client bug, // so the reuse is reported instead. ErrFingerprintMismatch = platformerrors.New("idempotency key reused with a different request") // ErrKeyRequired indicates an empty key was supplied. ErrKeyRequired = platformerrors.New("empty idempotency key") // ErrKeyTooLong indicates a key longer than the configured maximum. ErrKeyTooLong = platformerrors.New("idempotency key exceeds the maximum length") // ErrKeyInvalid indicates a key containing bytes outside printable ASCII. ErrKeyInvalid = platformerrors.New("idempotency key contains disallowed characters") // the manager is configured to fail closed. Running the work anyway could // repeat an effect that already happened. ErrStoreUnavailable = platformerrors.New("idempotency store unavailable") // ErrEmptyFingerprint indicates Do was called without a fingerprint. An // empty one would make every request for a key look identical and disable // mismatch detection entirely, so it is rejected rather than defaulted. ErrEmptyFingerprint = platformerrors.New("empty idempotency fingerprint") // ErrNilStore indicates NewManager was called without a record store. It // wraps errors.ErrNilInputParameter, so a caller may check either. ErrNilStore = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil idempotency store") // ErrNilLocker indicates NewManager was called without a locker. It has no // default: an implicit noop would silently remove mutual exclusion. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilLocker = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil idempotency locker") // ErrNilFunc indicates Do was called with no work to run. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilFunc = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil idempotency func") // ErrInvalidTTL indicates a non-positive TTL was configured. ErrInvalidTTL = platformerrors.New("invalid idempotency TTL") // ErrRecordableTypeMismatch indicates WithRecordable was given a predicate // for a type other than the Manager's. Option carries no type parameter, so // the compiler cannot catch this; NewManager reports it instead. ErrRecordableTypeMismatch = platformerrors.New("recordable predicate type does not match manager type") )
Sentinels. errors/http and errors/grpc map these onto status codes, so those packages import this one. That direction is load-bearing: nothing here may import errors/http or errors/grpc, or the cycle closes. It is also why the transport adapters live in their own packages rather than here.
Functions ¶
func ValidateKey ¶
ValidateKey reports whether a client-supplied key is usable.
A key becomes both a store key and a lock key, so it is restricted rather than escaped: printable ASCII with no spaces, which admits the UUIDs, xids, and base64url tokens clients actually send while excluding control characters and anything that would travel badly in a header.
identifiers.Validate is deliberately not used. It accepts only xid, and the keys arriving here are minted by third-party clients — rejecting a well-formed UUID would break every caller that does the ordinary thing. Generating a key is the other direction; see WithNewKey.
A non-positive maxLength disables the length check.
Types ¶
type DoOption ¶
type DoOption func(*doOptions)
DoOption overrides a Manager-level setting for one call.
The Manager's own settings are the defaults for every call through it; these exist so that one Manager can serve endpoints whose requirements differ, rather than forcing a second Manager per variation.
Like Option, it carries no type parameter: nothing here depends on the Manager's T, and one would only force it onto every call site.
func WithCallTTL ¶
WithCallTTL overrides how long this call's completed record is retained.
Retention is the window in which a retry replays instead of re-running, so it belongs to the operation rather than to the Manager: a payment worth protecting for a day and a profile update worth protecting for a minute can then share one Manager. A non-positive value inherits the Manager's TTL.
type Fingerprint ¶
type Fingerprint string
Key identifies a logical operation, so that a retry of it can be recognized as the same operation rather than a new one. It is minted by the client and arrives over the wire.
Fingerprint identifies what the operation was, and the two are distinct types on purpose. Do takes one of each, adjacent, and both are strings underneath: as bare strings a transposed pair compiles, runs, and silently disables mismatch detection — every request would fingerprint-match itself, so one key reused for two different requests would replay the first answer instead of being reported. That is a security control failing open with no signal, which makes it worth the conversions at the wire boundary.
type Key ¶
type Key string
Key identifies a logical operation, so that a retry of it can be recognized as the same operation rather than a new one. It is minted by the client and arrives over the wire.
Fingerprint identifies what the operation was, and the two are distinct types on purpose. Do takes one of each, adjacent, and both are strings underneath: as bare strings a transposed pair compiles, runs, and silently disables mismatch detection — every request would fingerprint-match itself, so one key reused for two different requests would replay the first answer instead of being reported. That is a security control failing open with no signal, which makes it worth the conversions at the wire boundary.
func KeyFromContext ¶
KeyFromContext returns the key carried by ctx, if any.
func WithNewKey ¶
WithNewKey returns a context carrying a freshly minted key, and the key.
Call it once per logical operation, outside any retry loop. That placement is the whole contract: every attempt sharing this context sends the same key, which is what lets the server recognize a retry. Minting inside the loop produces a new key per attempt and no protection at all.
The generator is identifiers.New, which is fine for keys this process mints even though inbound keys are validated by shape rather than by xid — see ValidateKey.
Example ¶
ExampleWithNewKey shows where a client mints its key: once, outside the retry loop, so every attempt sends the same one.
package main
import (
"context"
"fmt"
"github.com/primandproper/platform-go/v8/idempotency"
)
func main() {
ctx := context.Background()
ctx, key := idempotency.WithNewKey(ctx)
for attempt := range 3 {
sent, _ := idempotency.KeyFromContext(ctx)
fmt.Println("attempt", attempt, "sends the minted key:", sent == key)
}
}
Output: attempt 0 sends the minted key: true attempt 1 sends the minted key: true attempt 2 sends the minted key: true
type Manager ¶
type Manager[T any] struct { // contains filtered or unexported fields }
Manager runs work at most once per key.
It is a concrete type rather than an interface: there is one implementation, and the seams worth swapping — the store and the locker — are already interfaces with their own mocks.
func NewManager ¶
func NewManager[T any]( store cache.Cache[Record[T]], locker distributedlock.ScopedLocker, opts ...Option, ) (*Manager[T], error)
NewManager builds a Manager over a record store and a locker.
The locker is required and has no default. An implicit noop would leave replay working while quietly removing mutual exclusion, which is the failure mode hardest to notice and most expensive to meet.
func (*Manager[T]) Do ¶
func (m *Manager[T]) Do( ctx context.Context, key Key, fingerprint Fingerprint, fn func(ctx context.Context) (*T, error), opts ...DoOption, ) (*Result[T], error)
Do runs fn at most once for key.
The fingerprint identifies the request the key is being used for. A stored record whose fingerprint differs yields ErrFingerprintMismatch rather than a replay, which is what stops one key from silently answering two different requests.
fn runs outside the lock. Only the claim is serialized, so the lock is held for two store round trips regardless of how long the work takes — see the package documentation on why that matters.
An error from fn is returned as-is and nothing is recorded, so the next attempt runs the work again. A panic does the same and keeps unwinding.
Example ¶
ExampleManager_Do shows the shape the whole package exists for: the same key twice, the work once.
package main
import (
"context"
"fmt"
"github.com/primandproper/platform-go/v8/cache/memory"
"github.com/primandproper/platform-go/v8/distributedlock"
dlmemory "github.com/primandproper/platform-go/v8/distributedlock/memory"
"github.com/primandproper/platform-go/v8/idempotency"
)
// charge is the recorded result: a concrete struct with exported fields, which
// is what the store can round-trip.
type charge struct {
ID string
}
func newManager() (*idempotency.Manager[charge], error) {
store, err := memory.NewInMemoryCache[idempotency.Record[charge]](0)
if err != nil {
return nil, err
}
locker, err := dlmemory.NewLocker()
if err != nil {
return nil, err
}
scoped, err := distributedlock.NewScopedLocker(locker)
if err != nil {
return nil, err
}
return idempotency.NewManager(store, scoped)
}
func main() {
ctx := context.Background()
manager, err := newManager()
if err != nil {
panic(err)
}
charges := 0
authorize := func(context.Context) (*charge, error) {
charges++
return &charge{ID: "ch_1"}, nil
}
// The key and a fingerprint of the request the key is being used for. The
// fingerprint is what stops one key from answering two different requests.
const (
key = "d3f1a0c4-5b6e-4a2f-9c8d-1e2f3a4b5c6d"
fingerprint = "sha256-of-the-request"
)
first, err := manager.Do(ctx, key, fingerprint, authorize)
if err != nil {
panic(err)
}
// The client never saw the response and retried with the same key.
second, err := manager.Do(ctx, key, fingerprint, authorize)
if err != nil {
panic(err)
}
fmt.Println("first:", first.Value.ID, "replayed:", first.Replayed)
fmt.Println("second:", second.Value.ID, "replayed:", second.Replayed)
fmt.Println("charges:", charges)
}
Output: first: ch_1 replayed: false second: ch_1 replayed: true charges: 1
Example (Mismatch) ¶
ExampleManager_Do_mismatch shows the same key used for a different request.
package main
import (
"context"
"fmt"
"github.com/primandproper/platform-go/v8/cache/memory"
"github.com/primandproper/platform-go/v8/distributedlock"
dlmemory "github.com/primandproper/platform-go/v8/distributedlock/memory"
"github.com/primandproper/platform-go/v8/idempotency"
)
// charge is the recorded result: a concrete struct with exported fields, which
// is what the store can round-trip.
type charge struct {
ID string
}
func newManager() (*idempotency.Manager[charge], error) {
store, err := memory.NewInMemoryCache[idempotency.Record[charge]](0)
if err != nil {
return nil, err
}
locker, err := dlmemory.NewLocker()
if err != nil {
return nil, err
}
scoped, err := distributedlock.NewScopedLocker(locker)
if err != nil {
return nil, err
}
return idempotency.NewManager(store, scoped)
}
func main() {
ctx := context.Background()
manager, err := newManager()
if err != nil {
panic(err)
}
authorize := func(context.Context) (*charge, error) { return &charge{ID: "ch_1"}, nil }
const key = "d3f1a0c4-5b6e-4a2f-9c8d-1e2f3a4b5c6d"
if _, err = manager.Do(ctx, key, "charge-10-dollars", authorize); err != nil {
panic(err)
}
// Same key, different request. Replaying the first result would hide the
// bug, so the reuse is reported instead.
_, err = manager.Do(ctx, key, "charge-1000-dollars", authorize)
fmt.Println(err)
}
Output: matching idempotency fingerprint: idempotency key reused with a different request
type Option ¶
type Option func(*managerOptions)
Option configures a Manager at construction.
It is deliberately not parameterized on the Manager's T. None of these settings depend on it, and Go cannot infer a type argument from a call's result type — so an Option would force every call site to spell the Manager's type out by hand — WithTTL[Receipt](time.Hour) — forever.
WithRecordable is the one setting that does depend on T. It stays generic but still needs no annotation, because T is inferable from the predicate it is handed; see its documentation for how a mismatch is reported.
func WithInFlightTTL ¶
WithInFlightTTL bounds how long a claim survives without completing.
It is the deadline for the guarded work, not a performance knob. Set it below the work's worst case and a slow execution loses its claim while still running, which is the one path that can still produce a duplicate effect — watch idempotency_claims_lost.
func WithKeyPrefix ¶
func WithKeyPrefix(prefix string) Option
WithKeyPrefix overrides the namespace applied to store and lock keys.
An empty prefix is honored rather than ignored, so a caller can deliberately opt out of namespacing; that is why this is the one setting held as a pointer.
func WithMaxKeyLength ¶
func WithMaxKeyLength(maxLength int) Option
WithMaxKeyLength overrides the longest accepted key.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider.
func WithRecordable ¶
WithRecordable sets the predicate deciding whether a result is worth recording. A result it rejects releases the claim instead, so the next attempt runs the work again.
This is how a caller expresses "that failure was ours, not theirs": a server-side error usually means the effect did not land, and pinning it for the whole TTL would strand a client that could have succeeded on retry.
T is inferred from the predicate, so this needs no type argument:
idempotency.WithRecordable(func(r *Receipt) bool { return r.Charged })
It must match the Manager it configures. Because Option carries no type parameter, a predicate for the wrong type cannot be rejected by the compiler; NewManager returns ErrRecordableTypeMismatch instead, at construction, before any work runs through it.
func WithStoreFailurePolicy ¶
func WithStoreFailurePolicy(policy StoreFailurePolicy) Option
WithStoreFailurePolicy chooses what happens when the store cannot be read.
func WithTracerProvider ¶
func WithTracerProvider(tracerProvider tracing.TracerProvider) Option
WithTracerProvider attaches a tracer provider.
type Record ¶
type Record[T any] struct { // CreatedAt is when this revision of the record was written. CreatedAt time.Time // Value is the recorded result, set only once State is // StateCompleted. Value *T // Fingerprint identifies the request this key was used for, so a // second, different request under the same key can be detected. Fingerprint Fingerprint // ClaimID identifies the execution that owns the claim. Only its owner // may complete or release it, which is what stops an execution that // outlived its claim from overwriting whoever re-claimed the key. ClaimID string // Version is the record shape this was written with. Version int // State is the lifecycle stage. State State }
Record is what the store holds for a key. It is written twice per execution: once to claim the key, once to record the outcome.
T must be a concrete struct with exported fields — see the package documentation on what the store can round-trip.
type Result ¶
type Result[T any] struct { // Value is the result of the work, whether it just ran or was // replayed. Value *T // Replayed reports whether Value came from a stored record rather // than from running the work. Replayed bool }
Result is the outcome of Do.
type StoreFailurePolicy ¶
type StoreFailurePolicy uint8
StoreFailurePolicy decides what happens when the record store cannot be read. It is the most consequential setting in this package, because the two answers fail in opposite directions.
const ( // FailClosed refuses the request when the store is unreachable. The // default, and the right answer whenever the guarded work costs money: a // brief outage becomes downtime rather than duplicate charges. FailClosed StoreFailurePolicy = iota // FailOpen runs the work anyway, trading the guarantee for availability. // Appropriate only where a duplicate effect is cheaper than a rejection. FailOpen )
Directories
¶
| Path | Synopsis |
|---|---|
|
Package grpc adapts idempotency to gRPC, on both sides of the wire.
|
Package grpc adapts idempotency to gRPC, on both sides of the wire. |
|
Package http adapts idempotency to HTTP, on both sides of the wire.
|
Package http adapts idempotency to HTTP, on both sides of the wire. |