idempotency

package
v1.12.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package idempotency implements Idempotency-Key handling for forge routes: a Store that remembers one response per key, an in-memory Store, and the middleware that replays a stored response instead of running a handler twice.

It lives under internal so that both the forge package (forge.WithIdempotency) and the public middleware package can build on it. middleware imports forge, so forge cannot import middleware; both can import this.

Index

Constants

View Source
const DefaultLease = time.Minute

DefaultLease is the lease a Store applies when Begin is called with a lease of zero or less. A claim is never retakeable the instant it is made.

View Source
const DefaultMaxEntries = 10000

DefaultMaxEntries caps how many completed responses a MemoryStore keeps.

View Source
const HeaderName = "Idempotency-Key"

HeaderName is the request header that carries the client's key.

View Source
const ReplayedHeader = "Idempotent-Replayed"

ReplayedHeader marks a response written from the store rather than by the handler.

View Source
const SkippedHeader = "Idempotency-Skipped"

SkippedHeader is set on the response to a request that carried an Idempotency-Key the middleware did not act on. Its value says why; "anonymous" means the request had no principal and AllowAnonymous is off.

View Source
const TruncatedHeader = "Idempotent-Truncated"

TruncatedHeader is set to "true" on a replay whose stored response was larger than MaxResponse: the status and headers are the handler's, and the body is empty.

Variables

View Source
var ErrNotHolder = errors.New("idempotency: caller does not hold the key")

ErrNotHolder is returned by Complete and Release when the caller's Token does not match the claim currently on the key. It means the caller's lease lapsed and the key moved on, so the caller's outcome must not be recorded. The store is left untouched.

Functions

func DefaultPrincipal

func DefaultPrincipal(ctx router.Context) string

DefaultPrincipal reads the authenticated subject: the "auth.subject" context value when it is a string, else the Subject field of whatever "auth_context" holds (the auth extension stores *auth.AuthContext there), prefixed with its ProviderName as "provider:subject" when that is set, else "" for an anonymous request. The prefix keeps one subject id issued by two providers from sharing keys. Every anonymous request shares the "" principal, so on routes open to anonymous callers (with AllowAnonymous) the key itself is the only thing keeping one caller's response from another.

func Middleware

func Middleware(store Store, opts ...Option) router.Middleware

Middleware returns middleware that runs a handler at most once per (principal, METHOD /path, Idempotency-Key) and replays the stored response to every repeat. A nil store means Default().

A response is stored unless it is one a client is expected to retry: 408, 429 and every 5xx give the key back, so the retry runs the handler again rather than replaying a transient failure for the whole TTL. Every other status, 409 and 422 included, is deterministic and is stored. A handler that returns nil without writing is stored as the empty 200 net/http sends for it. A handler that returns an error or panics also gives the key back, even when the error maps to a 4xx: the error handler writes that response outside this middleware, so only a 4xx the handler writes itself (ctx.JSON and the like) is stored. A response larger than MaxResponse is stored without its body and replayed with Idempotent-Truncated: true.

A request whose principal is "" runs as if it carried no key unless AllowAnonymous is set; its response carries Idempotency-Skipped: anonymous and the first such request on each route logs a warning (see Logger). While a request holds a key, a duplicate that cannot wait gets 409 with Retry-After: 1, so a client can tell the conflict is temporary.

A second layer inside the first (a group and a route that both opted in) steps aside, because the outer layer already holds the claim.

The handler runs on a fresh context that shares the outer context's values and session. Anything else a forge_http.Ctx keeps privately, such as a DI scope opened by outer middleware, is not carried across.

Types

type Begun

type Begun struct {
	// State is what Begin found.
	State State
	// Fingerprint is the fingerprint recorded by whoever began the key. Set
	// for Replay and InFlight, so a reused key can be compared.
	Fingerprint string
	// Response is set for Replay. It is a copy the caller may keep.
	Response *Response
	// Token is set for Acquired. Pass it to Complete or Release.
	Token Token
	// Done is closed when the holder completes or releases the key, or when
	// the store sweeps the holder's lapsed claim. Set for InFlight by stores
	// that can signal; nil means poll Begin again. A store is not required to
	// close Done at the instant a lease expires, only at its next sweep, so a
	// waiter must bound its wait by the lease and Begin again when that runs
	// out.
	Done <-chan struct{}
}

Begun is the result of Begin.

type ConflictMode

type ConflictMode int

ConflictMode decides what a request does when another request with the same key is still running.

const (
	// ConflictWait waits for the first request and then replays its response.
	ConflictWait ConflictMode = iota
	// ConflictReject answers 409 at once.
	ConflictReject
)

type Key

type Key struct {
	// Principal is who made the request. Two principals never share a stored
	// response, even when they send the same key value.
	Principal string
	// Scope is where the key applies. The HTTP middleware uses "METHOD /path";
	// the dashboard contract uses its own constant.
	Scope string
	// Value is the client-supplied Idempotency-Key.
	Value string
}

Key identifies one idempotent operation.

type MemoryOption

type MemoryOption func(*MemoryStore)

MemoryOption configures a MemoryStore.

func WithClock

func WithClock(now func() time.Time) MemoryOption

WithClock replaces time.Now, for tests.

func WithMaxEntries

func WithMaxEntries(n int) MemoryOption

WithMaxEntries caps the number of completed responses kept; the least recently used is evicted first. Values below 1 are ignored.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is a process-local Store. Claims expire with their lease, responses with their ExpiresAt, and completed responses beyond the cap are evicted least recently used first. Claims are not counted against the cap, so a burst of new keys never pushes out a stored response.

Lapsed claims are swept on every Begin, soonest lease first, so a client that abandons unique keys cannot grow the store without bound. Each claim is removed once, so the sweep costs O(log n) amortised per claim.

func Default

func Default() *MemoryStore

Default returns the process-wide store used when a route opts into idempotency without naming one.

func NewMemoryStore

func NewMemoryStore(opts ...MemoryOption) *MemoryStore

NewMemoryStore returns an empty MemoryStore.

func (*MemoryStore) Begin

func (s *MemoryStore) Begin(_ context.Context, key Key, fingerprint string, lease time.Duration) (Begun, error)

Begin implements Store.

func (*MemoryStore) Complete

func (s *MemoryStore) Complete(_ context.Context, key Key, token Token, resp Response) error

Complete implements Store.

func (*MemoryStore) Release

func (s *MemoryStore) Release(_ context.Context, key Key, token Token) error

Release implements Store.

type Option

type Option func(*config)

Option configures Middleware.

func AllowAnonymous

func AllowAnonymous() Option

AllowAnonymous deduplicates requests that have no principal too. By default a request whose PrincipalFunc returns "" runs as if it carried no key: every anonymous caller would share the "" principal, so one could be handed another's stored response by sending the same key. Skipping also fails safe when auth middleware is registered after this one and has not yet run.

A skipped request is not refused, because public routes see third-party clients that send Idempotency-Key routinely. Its response carries Idempotency-Skipped: anonymous, and the first one on each route is logged as a warning, so a misordered auth middleware shows up.

func Clock

func Clock(now func() time.Time) Option

Clock replaces time.Now for StoredAt and ExpiresAt, for tests.

func Lease

func Lease(d time.Duration) Option

Lease is how long a claim survives a request that never finishes, such as one whose process died. Default DefaultLease (1m).

func Logger

func Logger(l logger.Logger) Option

Logger is where the middleware warns about a misconfiguration. Without it the middleware uses the application logger from the request's container (forge registers it as "forge.logger"), and logs nothing if there is none.

func MaxBody

func MaxBody(n int64) Option

MaxBody caps the request body read for fingerprinting. Larger bodies get 413. Default 1 MiB.

func MaxResponse

func MaxResponse(n int) Option

MaxResponse caps the response body stored for replay. A larger response is still sent in full, and its status and headers are stored with an empty body and Idempotent-Truncated: true, so a repeat gets those instead of running the handler again. Default 1 MiB.

func OnConflict

func OnConflict(m ConflictMode) Option

OnConflict selects waiting (the default) or an immediate 409 for a concurrent duplicate.

func Principal

func Principal(fn PrincipalFunc) Option

Principal replaces DefaultPrincipal.

func RequireKey

func RequireKey() Option

RequireKey answers 400 to a write that carries no Idempotency-Key. A keyed write with no principal still passes through undeduplicated, as it does without RequireKey, and its response carries Idempotency-Skipped: anonymous.

func TTL

func TTL(d time.Duration) Option

TTL is how long a stored response is replayed. Default 24h.

func WaitTimeout

func WaitTimeout(d time.Duration) Option

WaitTimeout bounds how long a concurrent duplicate waits before it gets 409. Default 10s.

func WithStore

func WithStore(s Store) Option

WithStore selects the store, overriding Middleware's store argument.

type PrincipalFunc

type PrincipalFunc func(ctx router.Context) string

PrincipalFunc names who made a request. Keys are scoped by it.

type Response

type Response struct {
	// Status is the HTTP status the handler wrote.
	Status int
	// Header holds the headers the handler set, not the ones outer middleware
	// set before it ran.
	Header http.Header
	// Body is the response body.
	Body []byte
	// Fingerprint identifies the request that produced this response, so a
	// reused key with a different request can be refused.
	Fingerprint string
	// StoredAt is when the response was stored.
	StoredAt time.Time
	// ExpiresAt is when the store may forget the response. Zero never expires.
	ExpiresAt time.Time
}

Response is one stored outcome, written back verbatim on a replay.

type State

type State int

State is what Begin found for a key.

const (
	// Acquired means nobody held the key. The caller now does and must
	// Complete or Release it.
	Acquired State = iota + 1
	// Replay means a completed response is stored for the key.
	Replay
	// InFlight means another request holds the key and has not finished.
	InFlight
)

type Store

type Store interface {
	// Begin claims key for a new request, or reports why it cannot. lease
	// bounds how long a claim survives a holder that never finishes; a lease of
	// zero or less means DefaultLease. An Acquired result carries the Token the
	// holder must present when it finishes.
	Begin(ctx context.Context, key Key, fingerprint string, lease time.Duration) (Begun, error)
	// Complete stores resp for key and ends the claim held under token. When
	// resp has no Fingerprint it takes the one the claim was begun with. A
	// token that does not match the current claim returns ErrNotHolder and
	// stores nothing. The zero token completes a key that nobody has claimed,
	// storing resp without a prior Begin; it cannot finish a live claim.
	Complete(ctx context.Context, key Key, token Token, resp Response) error
	// Release ends the claim held under token without storing anything, so the
	// key can be used again. A token that does not match the current claim
	// returns ErrNotHolder and changes nothing. Releasing a key that is gone is
	// a no-op.
	Release(ctx context.Context, key Key, token Token) error
}

Store remembers responses by Key. Implementations must be safe for concurrent use.

type Token

type Token uint64

Token proves which Begin holds a key. A Store hands one out when it returns Acquired and checks it again on Complete and Release, so a holder whose lease lapsed cannot finish a claim that has since passed to somebody else. Treat it as opaque. The zero Token means "no claim".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL