Documentation
¶
Overview ¶
Package idempotency makes a repeated POST safe.
A client whose request times out does not know whether it happened. It retries. Without this, the retry provisions a second VM and bills for it — and the client still does not know, so it retries again.
The contract, which matches Stripe's and is what API clients already expect: send an Idempotency-Key header; the first request executes and its response is recorded against the key; a later request with the same key returns that recorded response without executing anything.
Two details that are easy to get wrong and expensive to get wrong:
- The key is scoped to the TENANT, the method and the path. A global key space lets one account's key collide with another's and serve them someone else's response body.
- The request fingerprint is stored and compared. Reusing a key with a different body is a client bug; returning the old response for it would hide that bug, so it is refused with 422.
Index ¶
- Constants
- Variables
- func New(opt Options) fiber.Handler
- type GormRecord
- type GormStore
- func (s *GormStore) Abandon(ctx context.Context, key string) error
- func (s *GormStore) Begin(ctx context.Context, key, fingerprint string, ttl time.Duration) (*Record, error)
- func (s *GormStore) Complete(ctx context.Context, key string, rec Record, ttl time.Duration) error
- func (s *GormStore) Sweep(ctx context.Context) (int64, error)
- type MemoryStore
- type Options
- type Record
- type Store
Constants ¶
const HeaderKey = "Idempotency-Key"
HeaderKey is the request header carrying the key.
const HeaderReplayed = "Idempotent-Replayed"
HeaderReplayed is set on a response served from the record, so a client can tell a replay from a fresh execution.
Variables ¶
var ErrInFlight = errors.New("idempotency: the original request is still running")
ErrInFlight is returned by a store when the first request with this key has not finished yet.
var ErrNotFound = errors.New("idempotency: no record")
ErrNotFound is returned by a store when the key is unknown.
Functions ¶
Types ¶
type GormRecord ¶
type GormRecord struct {
Key string `gorm:"primaryKey;column:key"`
Fingerprint string `gorm:"column:fingerprint"`
Status int `gorm:"column:status"`
Body []byte `gorm:"column:body"`
ContentType string `gorm:"column:content_type"`
// Completed separates a reservation from a recorded response. A row that
// exists but is not completed means "in flight".
Completed bool `gorm:"column:completed"`
CreatedAt time.Time `gorm:"column:created_at"`
ExpiresAt time.Time `gorm:"column:expires_at"`
}
GormRecord is the table behind GormStore.
Create it with a versioned migration, not AutoMigrate:
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
body BYTEA,
content_type TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idempotency_records_expires_at_idx ON idempotency_records (expires_at);
func (GormRecord) TableName ¶
func (GormRecord) TableName() string
TableName implements gorm's Tabler.
type GormStore ¶
type GormStore struct {
// contains filtered or unexported fields
}
GormStore keeps records in the database.
The database, not Redis, because the record must survive the restart the client is retrying through — a cache that was evicted is a duplicate charge. Redis is the right choice only when it is configured to persist.
func (*GormStore) Begin ¶
func (s *GormStore) Begin(ctx context.Context, key, fingerprint string, ttl time.Duration) (*Record, error)
Begin implements Store.
The reservation is a single INSERT ... ON CONFLICT DO NOTHING, so two concurrent first-requests cannot both win: exactly one inserts a row, and the other reads what that one wrote. Doing it as SELECT-then-INSERT would leave the race open in the window between them, which is precisely the window a retrying client occupies.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore keeps records in process memory.
For tests and single-instance development only. It is NOT a production store for two reasons, and both of them defeat the purpose of the package: a restart loses every record, and each replica has its own map, so the retry that lands on a different replica executes again.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an in-process store.
func (*MemoryStore) Abandon ¶
func (s *MemoryStore) Abandon(_ context.Context, key string) error
Abandon implements Store.
type Options ¶
type Options struct {
Store Store
// TTL is how long a record is replayable. Default 24h — long enough to
// cover a client's whole retry schedule, short enough that the table does
// not grow forever.
TTL time.Duration
// Methods that require a key. Default: POST, PATCH, DELETE.
//
// PUT is absent because a correct PUT is already idempotent. GET is
// absent because it changes nothing.
Methods []string
// Require makes a missing key a 400 on those methods. Off by default:
// turning it on breaks every existing client at once. Turn it on for a
// new API.
Require bool
// Skip exempts a request.
Skip func(c fiber.Ctx) bool
}
Options configures the middleware.
type Record ¶
type Record struct {
Key string
Fingerprint string
Status int
Body []byte
ContentType string
CreatedAt time.Time
}
Record is a completed response, kept so it can be replayed.
type Store ¶
type Store interface {
// Begin reserves key. It returns the existing record on a replay,
// ErrInFlight when the original is still running, or (nil, nil) when this
// caller now owns the key.
Begin(ctx context.Context, key, fingerprint string, ttl time.Duration) (*Record, error)
// Complete records the response.
Complete(ctx context.Context, key string, rec Record, ttl time.Duration) error
// Abandon releases a reservation whose request failed to produce a
// recordable response, so the client may retry.
Abandon(ctx context.Context, key string) error
}
Store persists records.
Begin MUST be atomic: it either reserves the key for this caller or reports that somebody already has it. A read-then-write implementation reintroduces exactly the race this package exists to close.