Documentation
¶
Overview ¶
Package idempotency makes retried POST and PATCH requests safe (ADR-0060). A client sends an Idempotency-Key header; the first request with the key runs, and its response is stored in PostgreSQL for the retention period so a retry gets the same response instead of running the request again:
store, err := idempotency.NewStore(pool, idempotency.WithRetention(retention.Get)) handler = idempotency.Middleware(store, idempotency.WithSkip(isSignIn))(handler)
Keys belong to the caller that sent them: the signed-in user or service account in the context (gorbital.dev/actor). Requests without one ignore the header, so two callers never share a key or see each other's responses. A key sent again with another method, path, query or body is refused with 422 idempotency_key_reused, and a key whose first request is still running with 409 idempotency_in_progress.
Only final outcomes are stored. Server errors (5xx), panics, and 401, 403, 408 and 429 responses release the key so the client can retry, and so do responses that set cookies or say Cache-Control: no-store, bodies larger than the cap, and requests whose handler calls DontStore. A request that dies without releasing its key (a crashed instance) holds it for the lock TTL.
Stability: stable (ADR-0015, ADR-0054).
Index ¶
- Constants
- Variables
- func ActorScope(r *http.Request) (string, bool)
- func DontStore(ctx context.Context)
- func Fingerprint(method, target string, body []byte) []byte
- func Middleware(store *Store, opts ...MiddlewareOption) func(http.Handler) http.Handler
- type Lock
- type MiddlewareOption
- type Option
- type Response
- type Store
Constants ¶
const ( // Header is the request header carrying the client's key. Header = "Idempotency-Key" // ReplayedHeader is set to "true" on responses replayed from the store. ReplayedHeader = "Idempotent-Replayed" // MaxKeyLength is the longest key accepted, in bytes. MaxKeyLength = 255 // DefaultRetention is how long responses are kept without // [WithRetention]. DefaultRetention = 24 * time.Hour // DefaultLockTTL is how long a request holds its key without // [WithLockTTL]. It is well above the HTTP server's write timeout, so a // slow request isn't run twice, and short enough that a key held by a // crashed instance frees up within minutes. DefaultLockTTL = 5 * time.Minute )
const DefaultMaxResponseBytes = 1 << 20
DefaultMaxResponseBytes is the largest response body stored without WithMaxResponseBytes. Larger responses reach the client but release the key.
Variables ¶
var ( // ErrInProgress reports a key whose first request hasn't finished. The // client retries later. ErrInProgress = errors.New("idempotency: a request with this key is in progress") // ErrKeyReused reports a key sent again with a different method, path, // query or body. ErrKeyReused = errors.New("idempotency: the key was used for a different request") // ErrLockLost reports a [Lock] that no longer holds its key: it was held // longer than the lock TTL and another request took the key over, or // the key expired and was deleted. ErrLockLost = errors.New("idempotency: the lock on the key was lost") )
Errors returned by Store.Claim and Lock methods. Check them with errors.Is.
var Migrations fs.FS = mustSub(migrationFiles, "migrations")
Migrations holds the module's goose migrations: the idempotency_keys table. Apps copy them into db/migrations; tests can apply them directly with pgtest.
Functions ¶
func ActorScope ¶
ActorScope owns keys by the actor in the request's context: a user or a service account, by kind and ID. Anonymous and system actors have no scope, so their requests ignore the header.
func DontStore ¶
DontStore stops the response of the current request from being stored, for responses that must be shown only once, such as a new API key. The key is released, so a retry runs the request again. It does nothing outside a request with an idempotency key.
func Fingerprint ¶
Fingerprint identifies a request for comparing retries: its method, target (path and query, as in http.Request.RequestURI) and body.
func Middleware ¶
Middleware applies idempotency keys to POST and PATCH requests carrying the Header (other methods are idempotent already, or read-only). Put it after authentication, so the caller is known, and after any request body limit: the body is read into memory to fingerprint it.
- A key must be 1 to MaxKeyLength visible ASCII characters (400 invalid_idempotency_key), sent once.
- The first request runs; its response is stored unless it must be released (see the package documentation).
- A retry with the same method, target and body gets the stored status, Content-Type, Location, ETag and body, with ReplayedHeader "true".
- A retry with another method, target or body: 422 idempotency_key_reused. While the first request runs: 409 idempotency_in_progress with Retry-After.
- When the store can't be reached: 503 unavailable, without running the request.
Types ¶
type Lock ¶
type Lock struct {
// contains filtered or unexported fields
}
Lock is a claimed key. Exactly one of Lock.Complete and Lock.Release should be called.
func (*Lock) Complete ¶
Complete stores resp for the lock's key and gives the key up. It returns ErrLockLost when the lock no longer holds the key.
type MiddlewareOption ¶
type MiddlewareOption interface {
// contains filtered or unexported methods
}
A MiddlewareOption configures Middleware.
func WithMaxResponseBytes ¶
func WithMaxResponseBytes(n int) MiddlewareOption
WithMaxResponseBytes sets the largest response body stored. Default: DefaultMaxResponseBytes.
func WithScope ¶
func WithScope(scope func(*http.Request) (string, bool)) MiddlewareOption
WithScope sets who owns a request's keys. scope returns false for callers whose header is ignored. Default: ActorScope.
type Option ¶
type Option func(*Store)
An Option configures a Store.
func WithLockTTL ¶
WithLockTTL sets how long a request holds its key before another request with the same key may take it over. Default: DefaultLockTTL.
func WithLogger ¶
WithLogger sets the logger for keys that couldn't be stored or released. Default: discard.
func WithRetention ¶
WithRetention reads how long responses are kept, on every use, so a runtime setting applies at once, to existing keys too. Default: DefaultRetention.
type Response ¶
type Response struct {
Status int
// Header holds only the headers worth replaying: Content-Type,
// Location and ETag.
Header http.Header
Body []byte
}
Response is a stored response.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store keeps idempotency keys and their responses in the idempotency_keys table. It is safe for concurrent use.
func NewStore ¶
NewStore returns a store on pool, which must have the module's migrations applied. The middleware counts the OpenTelemetry metric idempotency.requests by outcome.
func (*Store) Claim ¶
func (s *Store) Claim(ctx context.Context, scope, key string, fingerprint []byte) (*Lock, *Response, error)
Claim takes key for the caller identified by scope. It returns a Lock when the request should run, or the stored Response when a request with the same fingerprint already completed. It returns ErrInProgress while another request holds the key, and ErrKeyReused when the key was used with another fingerprint. Keys held longer than the lock TTL, and keys older than the retention, can be claimed again.
func (*Store) DeleteExpired ¶
DeleteExpired deletes up to limit keys older than the retention, with their stored responses, and returns how many it deleted. Apps run it from a periodic job until it deletes fewer than limit.