idempotency

package
v3.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package idempotency provides canonicalization, hashing, typed errors, storage backends, and handler middleware for AdCP idempotency_key support.

Index

Constants

View Source
const (
	CodeIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
	CodeIdempotencyExpired  = "IDEMPOTENCY_EXPIRED"
	CodeInvalidRequest      = "INVALID_REQUEST"
)

Protocol error codes this package maps onto. Only IDEMPOTENCY_CONFLICT and IDEMPOTENCY_EXPIRED are idempotency-specific in the AdCP enum; missing or malformed keys are surfaced as the generic INVALID_REQUEST code and carry a Field value of "idempotency_key" so callers can handle them specifically without inventing codes that won't round-trip across SDKs.

View Source
const (
	MinTTL = 1 * time.Hour
	MaxTTL = 7 * 24 * time.Hour
	// DefaultClockSkew is the spec's ±60s tolerance around the TTL boundary.
	DefaultClockSkew = 60 * time.Second
)

Spec-mandated bounds on replay_ttl_seconds: 1 hour minimum, 7 days maximum.

View Source
const PostgresSchema = `` /* 418-byte string literal not displayed */

PostgresSchema is the table definition PgBackend expects. Create it once in your migration tooling before enabling the backend.

Variables

CanonicalJSONSHA256 is the default HashFn: RFC 8785 JCS canonicalization followed by SHA-256. The top-level exclude list is applied before hashing.

Integer precision beyond 2^53: JCS §3.2.2.3 mandates ECMAScript Number.prototype.toString form, which goes through IEEE-754 double precision. Integers above 2^53 (9,007,199,254,740,992) lose precision, so e.g. 9007199254740993 and 9007199254740992 canonicalize to the same bytes and therefore the same hash. This matches gowebpki/jcs and the RFC. OpenRTB and AdCP commonly encode IDs as strings for exactly this reason — verify upstream serializers don't emit bare 64-bit integers (nanosecond timestamps, SSP auction IDs) in hashed payloads.

View Source
var DefaultExcludePaths = []string{
	"idempotency_key",
	"context",
	"governance_context",
	"push_notification_config.authentication.credentials",
}

DefaultExcludePaths are top-level JSON fields removed before canonicalization. These are either the idempotency key itself (tautological) or transport-layer fields that legitimately vary between retries without altering request intent.

Functions

func Generate

func Generate() string

Generate returns a new UUID v4 formatted idempotency key (36 chars with dashes). The caller SHOULD cache this on the request struct so internal retries resend the same key.

func InjectReplayed

func InjectReplayed(envelope []byte, replayed bool) ([]byte, error)

InjectReplayed sets {"replayed": replayed} on an AdCP response envelope.

Store.Wrap caches and returns the inner handler response; envelope construction (adcp_version, message, status, replayed) happens one layer above, in the transport adapter. Adapters MUST set `replayed` on the envelope — the compliance storyboard validates this field for cached responses — and this helper is the spec-compliant way to do it.

If the envelope is not a JSON object, an error is returned.

func LogKey

func LogKey(key string) string

LogKey returns a prefix-truncated form of key safe for default logging. Full keys are retry-pattern oracles; callers that want full keys in logs must opt in explicitly.

func ParseCapability

func ParseCapability(caps map[string]any, agentID string) (time.Duration, error)

ParseCapability extracts adcp.idempotency.replay_ttl_seconds from a get_adcp_capabilities response body. Returns MissingCapabilityError if the field is absent — per spec, clients MUST NOT fall back to an assumed TTL.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) string

PrincipalFromContext returns the principal previously set via WithPrincipal, or "" if none.

func PrincipalScope

func PrincipalScope(ctx context.Context, _ []byte) (string, error)

PrincipalScope is the default ScopeFn. It requires a principal in context — unscoped keys would let one caller observe another caller's cached responses.

func ReadReplayed

func ReadReplayed(envelope []byte) (replayed bool, ok bool)

ReadReplayed returns the envelope's `replayed` flag. Clients use this to suppress side effects on replays (billing, analytics, webhooks). When the field is absent or non-boolean, ok=false.

func Validate

func Validate(key string) error

Validate checks a key against the AdCP schema pattern. Returns nil on OK, or *InvalidKeyError.

func WithPrincipal

func WithPrincipal(ctx context.Context, principalID string) context.Context

WithPrincipal attaches a principal identifier to ctx for PrincipalScope. Callers typically set this after authenticating the request and before invoking the wrapped handler.

Types

type Backend

type Backend interface {
	// Get returns the entry for (scope, key), or (nil, nil) on miss.
	// Expired entries MAY be returned; the middleware checks TTL.
	Get(ctx context.Context, scope, key string) (*Entry, error)

	// PutIfAbsent stores entry only if (scope, key) has no existing record.
	// On race, it returns the winning entry and stored=false so the caller can
	// hash-compare without an extra round trip.
	PutIfAbsent(ctx context.Context, scope, key string, entry *Entry) (existing *Entry, stored bool, err error)
}

Backend is the pluggable storage surface for the idempotency middleware. Implementations MUST be safe for concurrent use.

Scope is a namespace under which keys are unique. The middleware passes a per-principal (or principal+session) scope so keys from different principals cannot collide.

type ConflictError

type ConflictError struct {
	Key string
}

ConflictError is returned when an idempotency key is reused with a different canonicalized payload. Recovery is caller-driven: either resend the original payload or mint a fresh key.

func (*ConflictError) Code

func (*ConflictError) Code() string

Code returns the protocol error code.

func (*ConflictError) Error

func (e *ConflictError) Error() string

type Entry

type Entry struct {
	Hash      string
	Response  []byte
	CreatedAt time.Time
	ExpiresAt time.Time
}

Entry is the persisted record for one idempotency key. Response holds the encoded inner handler response. The envelope `replayed` flag is injected at response time, not stored.

type ExpiredError

type ExpiredError struct {
	Key string
}

ExpiredError is returned when an idempotency key was accepted previously but is now past the seller's replay window. Callers should natural-key-check before minting a fresh key to avoid double-create.

func (*ExpiredError) Code

func (*ExpiredError) Code() string

Code returns the protocol error code.

func (*ExpiredError) Error

func (e *ExpiredError) Error() string

type Handler

type Handler func(ctx context.Context, req []byte) (resp []byte, err error)

Handler is the business handler signature the middleware wraps. Req is the raw request JSON bytes; resp is the inner response payload (NOT the envelope — the caller wraps this with `replayed: …` at response time).

Contract: returning a nil error caches resp as-is. Task-level failures that should NOT be cached (so a retry can re-execute) MUST be returned as a Go error. The middleware cannot distinguish a "success" envelope from a "failed" envelope hidden inside resp.

type HashFn

type HashFn func(payload []byte) (string, error)

HashFn canonicalizes a JSON payload and returns a stable digest. Implementations MUST produce byte-identical output for semantically equal inputs so hash comparison correctly detects payload drift.

func NewCanonicalJSONSHA256

func NewCanonicalJSONSHA256(excludePaths []string) HashFn

NewCanonicalJSONSHA256 returns a HashFn that strips the given dotted JSON paths, canonicalizes the remainder with JCS (RFC 8785), and SHA-256 hashes the result. Dotted paths apply from the document root (e.g., "push_notification_config.authentication.credentials").

type InvalidKeyError

type InvalidKeyError struct {
	Reason string
}

InvalidKeyError is returned when a provided idempotency_key fails format validation. The malformed key is not embedded to avoid log exposure. Maps to INVALID_REQUEST with Field="idempotency_key".

func (*InvalidKeyError) Code

func (*InvalidKeyError) Code() string

Code returns the protocol error code.

func (*InvalidKeyError) Error

func (e *InvalidKeyError) Error() string

func (*InvalidKeyError) Field

func (*InvalidKeyError) Field() string

Field names the request field at fault, for envelope error.field.

type MemoryBackend

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

MemoryBackend is an in-process Backend suitable for tests and reference servers. A background sweeper removes expired entries; callers should invoke Close to stop it.

func NewMemoryBackend

func NewMemoryBackend(sweepInterval time.Duration) *MemoryBackend

NewMemoryBackend returns a MemoryBackend with a TTL sweeper running at sweepInterval. A zero interval disables the sweeper (entries still become unobservable past TTL because the middleware checks ExpiresAt).

func (*MemoryBackend) Close

func (b *MemoryBackend) Close()

Close stops the TTL sweeper.

func (*MemoryBackend) Get

func (b *MemoryBackend) Get(_ context.Context, scope, key string) (*Entry, error)

Get implements Backend.

func (*MemoryBackend) PutIfAbsent

func (b *MemoryBackend) PutIfAbsent(_ context.Context, scope, key string, entry *Entry) (*Entry, bool, error)

PutIfAbsent implements Backend.

type MissingCapabilityError

type MissingCapabilityError struct {
	AgentID string
}

MissingCapabilityError is a client-side condition: the seller's get_adcp_capabilities response did not declare adcp.idempotency.replay_ttl_seconds. Per spec, clients MUST NOT assume a default. This error is not transmitted over the wire, so it has no Code().

func (*MissingCapabilityError) Error

func (e *MissingCapabilityError) Error() string

type MissingKeyError

type MissingKeyError struct{}

MissingKeyError is returned when a required idempotency_key is absent from a mutating request. It maps to INVALID_REQUEST with Field="idempotency_key".

func (*MissingKeyError) Code

func (*MissingKeyError) Code() string

Code returns the protocol error code.

func (*MissingKeyError) Error

func (*MissingKeyError) Error() string

func (*MissingKeyError) Field

func (*MissingKeyError) Field() string

Field names the request field at fault, for envelope error.field.

type Options

type Options struct {
	// Backend stores idempotency records. Required.
	Backend Backend

	// TTL is the replay window surfaced via Capability() and enforced on
	// lookup. Required. Must be in [MinTTL, MaxTTL].
	TTL time.Duration

	// ClockSkew is the tolerance applied at the TTL boundary to absorb small
	// clock differences between client and server. A request that arrives
	// ClockSkew past TTL is still served from cache. Defaults to
	// DefaultClockSkew (60s). Set to 0 to disable.
	ClockSkew time.Duration

	// KeyRequired controls whether a missing idempotency_key triggers
	// MissingKeyError. Defaults to true. Set false for si_terminate_session
	// and other tools where the spec makes the key optional; when false and
	// no key is present, the handler runs uncached.
	KeyRequired *bool

	// Hash canonicalizes a request payload and returns a stable digest.
	// Defaults to CanonicalJSONSHA256 (JCS canonicalization with
	// DefaultExcludePaths stripped, SHA-256 applied). Provide a custom
	// NewCanonicalJSONSHA256(customPaths) if you need to override the
	// exclude list.
	Hash HashFn

	// Scope extracts the scope under which keys are unique for a given
	// request context. Defaults to PrincipalScope, which reads the principal
	// from context (set via WithPrincipal).
	Scope ScopeFn

	// Clock is injectable for tests. Defaults to time.Now.UTC.
	Clock func() time.Time
}

Options configures a Store.

type PgBackend

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

PgBackend is a Postgres-backed Backend. The PRIMARY KEY on (scope, key) provides the atomicity PutIfAbsent relies on. Uses database/sql so callers can wire any Postgres driver (pgx stdlib adapter, lib/pq, etc.).

func NewPgBackend

func NewPgBackend(db *sql.DB) *PgBackend

NewPgBackend returns a PgBackend bound to db.

func (*PgBackend) Get

func (b *PgBackend) Get(ctx context.Context, scope, key string) (*Entry, error)

Get implements Backend.

func (*PgBackend) PutIfAbsent

func (b *PgBackend) PutIfAbsent(ctx context.Context, scope, key string, entry *Entry) (*Entry, bool, error)

PutIfAbsent implements Backend via ON CONFLICT DO NOTHING RETURNING: a RETURNING row indicates we inserted; no row means an existing entry won the race and we re-read it.

type RequestEnvelope

type RequestEnvelope struct {
	Key   string
	Bytes []byte
}

RequestEnvelope is the frozen form of a request, bound to a specific key. Callers that implement their own transport use *RequestEnvelope to satisfy the freeze-bytes-on-first-send rule: marshal once, resend byte-identical on every retry. Re-marshaling the struct is incorrect because Go's json.Marshal stability is not guaranteed across dependency changes.

func Freeze

func Freeze(req any, keyOverride string) (*RequestEnvelope, error)

Freeze marshals req through map[string]any, injects an idempotency_key (generating one if neither req nor keyOverride has one), and returns the bound bytes. Use this for untyped / map requests. For strongly-typed request structs where byte-for-byte preservation matters, marshal yourself and call FreezeBytes.

func FreezeBytes

func FreezeBytes(jsonReq []byte, keyOverride string) (*RequestEnvelope, error)

FreezeBytes takes already-marshaled JSON and returns it unchanged, bound to its existing idempotency_key. Use this when byte-exact retry semantics matter: the caller marshals their request struct once (preserving field order, number precision, custom encoders), and the SDK resends those same bytes on every retry.

The input MUST contain a top-level idempotency_key. Generating one here would require re-marshaling, which is what this function exists to avoid — callers without a key should use Freeze instead, then treat the returned bytes as the canonical form.

If keyOverride is non-empty it must match the key in the payload; mismatches return an error so a caller cannot silently overwrite a key already committed to bytes.

type Result

type Result struct {
	Response []byte
	Replayed bool
	Key      string
}

Result is the outcome of a wrapped call. Callers read Replayed to set the envelope flag. Key is empty when the caller opted out via KeyRequired=false and the request omitted idempotency_key.

type ScopeFn

type ScopeFn func(ctx context.Context, payload []byte) (string, error)

ScopeFn derives the storage scope for a request. Per-principal scope is a security requirement: keys from different principals MUST NOT collide. A handler may return a richer scope (e.g. "principal:sess") to narrow uniqueness further — si_send_message scopes to (principal, session_id).

func SessionScope

func SessionScope(sessionIDField string) ScopeFn

SessionScope scopes keys to (principal, session). Use for si_send_message, where the natural unit is a logical turn within a session.

SECURITY: the session id is read from the payload field identified by sessionIDField. Callers MUST validate that the session id matches the authenticated session (typically by rejecting requests where payload session_id disagrees with the transport-layer session) before invoking the wrapped handler. Without that check, a principal could cross-scope into another session they own by submitting its id in the payload.

type Store

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

Store holds idempotency configuration and wraps mutating handlers.

func New

func New(opts Options) *Store

New returns a Store. Panics on misconfiguration — the middleware must not start in a state where cache writes silently fail.

func (*Store) Capability

func (s *Store) Capability() map[string]any

Capability returns the capabilities fragment for this store. Sellers MUST merge this under capabilities.adcp.idempotency in get_adcp_capabilities. Use MergeCapability for a helper that wires it at the correct nesting.

func (*Store) MergeCapability

func (s *Store) MergeCapability(caps map[string]any)

MergeCapability inserts this store's capability fragment into a capabilities map at the correct nesting path (caps.adcp.idempotency). Safe to call on an empty map.

func (*Store) TTL

func (s *Store) TTL() time.Duration

TTL returns the configured replay window.

func (*Store) Wrap

func (s *Store) Wrap(h Handler) func(ctx context.Context, req []byte) (*Result, error)

Wrap composes a handler with the idempotency middleware. The returned function expects a raw JSON request containing an `idempotency_key` field.

Jump to

Keyboard shortcuts

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