idempotency

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Nov 7, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

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

View Source
const HeaderKey = "Idempotency-Key"

HeaderKey is the request header carrying the key.

View Source
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

View Source
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.

View Source
var ErrNotFound = errors.New("idempotency: no record")

ErrNotFound is returned by a store when the key is unknown.

Functions

func New

func New(opt Options) fiber.Handler

New returns the middleware.

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 NewGormStore

func NewGormStore(db *gorm.DB) *GormStore

NewGormStore builds a store.

func (*GormStore) Abandon

func (s *GormStore) Abandon(ctx context.Context, key string) error

Abandon implements Store.

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.

func (*GormStore) Complete

func (s *GormStore) Complete(ctx context.Context, key string, rec Record, ttl time.Duration) error

Complete implements Store.

func (*GormStore) Sweep

func (s *GormStore) Sweep(ctx context.Context) (int64, error)

Sweep deletes expired records. Run it from a scheduled job; without it the table grows by one row per write request, forever.

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.

func (*MemoryStore) Begin

func (s *MemoryStore) Begin(_ context.Context, key, fingerprint string, ttl time.Duration) (*Record, error)

Begin implements Store.

func (*MemoryStore) Complete

func (s *MemoryStore) Complete(_ context.Context, key string, rec Record, ttl time.Duration) error

Complete 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.

Jump to

Keyboard shortcuts

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