idempotencykey

package
v1.49.7 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package idempotencykey is a reusable idempotency guard for money-moving HTTP requests (refunds, captures, payouts). It stores one record per (scope, key) whose STORAGE id is deterministic, so a duplicate submit collapses onto the same row via the backend ON CONFLICT(id, kind, namespace) upsert — independent of datastore.RunInTransaction, which is a no-op on this backend and provides no isolation.

Lifecycle of a guarded operation:

rec, replay, err := idempotencykey.Begin(db, scope, key)
  replay == true  ⇒ this exact request already ran; return rec.Response,
                    do NOT move money again.
  replay == false ⇒ we recorded the in-flight marker first; perform the
                    side effect, then idempotencykey.Complete(rec, response).

LIMITATION (documented, not hidden): Begin uses read-then-write, and this backend has no atomic compare-and-swap reachable from mixin.Model[T] (db.SQLiteDB.RunInTransaction is real but the datastore layer routes to the no-op datastore.RunInTransaction). So two callers racing on a FIRST-EVER key can both observe "not started" and both proceed. The deterministic id still guarantees a single STORED record (the second Create upserts), but the side effect could run twice in that narrow window. Callers whose side effect is itself non-idempotent (e.g. a raw gateway refund) MUST additionally pass the SAME key through to the gateway (Stripe/Square both honor an idempotency key) so the gateway de-dups the money move. This guard de-dups OUR ledger and replays OUR response; the gateway key closes the money-move window. See api/checkout refund for the wired example.

Index

Constants

View Source
const (
	StatusStarted   = "started"
	StatusCompleted = "completed"
)

Status values.

View Source
const StartedTTL = 5 * time.Minute

StartedTTL bounds how long a "started" guard is treated as a live in-flight operation. A money op (refund/capture) completes in seconds; a "started" guard older than this is presumed CRASHED (the process died between Begin and Complete) and is recoverable — Begin re-claims it and lets the caller retry. This is only safe because the guarded money move ALSO carries the same deterministic gateway idempotency key, so the gateway de-dupes the retry (no double charge). Without that gateway key a stale guard must stay fail-closed.

Variables

This section is empty.

Functions

func Complete

func Complete(rec *IdempotencyKey, response string) error

Complete records the successful response so future replays return it, and flips Status to completed. Called after the guarded side effect succeeds.

Re-pins the deterministic id and Puts so the write lands on the exact "started" row (the ON CONFLICT upsert transitions it in place). SetId+Put is the reliable in-place write for a string-key model.

func DeterministicID

func DeterministicID(scope, key string) string

DeterministicID derives the stable storage id for a (scope, key) pair. Same inputs → same id → the second concurrent Create upserts onto the first's row.

func Query

Query returns a query for this kind, scoped to db's namespace.

Types

type IdempotencyKey

type IdempotencyKey struct {
	mixin.Model[IdempotencyKey]

	Scope string `json:"scope"`
	// IdemKey is the caller idempotency key. Named IdemKey (not Key) because a
	// field named Key would shadow the embedded Model[T].Key() method and break
	// the mixin.Entity interface. JSON stays "key" for the API.
	IdemKey  string `json:"key"`
	Status   string `json:"status" orm:"default:started"`
	Response string `json:"response,omitempty" datastore:",noindex"`

	// RecoveryPoint lets a caller record how far a multi-step side effect got,
	// so a retry can resume rather than restart. Optional.
	RecoveryPoint string `json:"recoveryPoint,omitempty"`
}

IdempotencyKey records one guarded request. Scope namespaces the key to a resource kind + id (e.g. "refund:ord_123") so the same key under different scopes never collides. Response holds the JSON body returned on first success so a replay returns byte-identical output.

func Begin

func Begin(db *datastore.Datastore, scope, key string) (rec *IdempotencyKey, replay bool, err error)

Begin looks up (or records) the guard for (scope, key).

Returns replay=true with the stored record when the key was already seen (whether still in-flight or completed) — the caller must NOT repeat the side effect and should return rec.Response if Status==completed. Returns replay=false with a freshly-created "started" marker when this is the first sighting; the caller performs the side effect then calls Complete.

func New

New returns an initialized IdempotencyKey bound to db.

func (*IdempotencyKey) Load

func (k *IdempotencyKey) Load(ps []datastore.Property) error

func (*IdempotencyKey) Recoverable

func (k *IdempotencyKey) Recoverable() bool

Recoverable reports whether a "started" guard is stale enough to presume its originator crashed — i.e. safe to re-claim and retry. A completed guard is never recoverable (it's a replay); a fresh started guard is a live in-flight op (fail-closed, caller 409s).

func (*IdempotencyKey) Save

func (k *IdempotencyKey) Save() ([]datastore.Property, error)

Jump to

Keyboard shortcuts

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