Documentation
¶
Overview ¶
Package idempotency provides canonicalization, hashing, typed errors, storage backends, and handler middleware for AdCP idempotency_key support.
Index ¶
- Constants
- Variables
- func Generate() string
- func InjectReplayed(envelope []byte, replayed bool) ([]byte, error)
- func LogKey(key string) string
- func ParseCapability(caps map[string]any, agentID string) (time.Duration, error)
- func PrincipalFromContext(ctx context.Context) string
- func PrincipalScope(ctx context.Context, _ []byte) (string, error)
- func ReadReplayed(envelope []byte) (replayed bool, ok bool)
- func Validate(key string) error
- func WithPrincipal(ctx context.Context, principalID string) context.Context
- type Backend
- type ConflictError
- type Entry
- type ExpiredError
- type Handler
- type HashFn
- type InvalidKeyError
- type MemoryBackend
- type MissingCapabilityError
- type MissingKeyError
- type Options
- type PgBackend
- type RequestEnvelope
- type Result
- type ScopeFn
- type Store
Constants ¶
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.
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.
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 ¶
var CanonicalJSONSHA256 = NewCanonicalJSONSHA256(DefaultExcludePaths)
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.
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 ¶
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 ¶
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 ¶
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 ¶
PrincipalFromContext returns the principal previously set via WithPrincipal, or "" if none.
func PrincipalScope ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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) 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 ¶
NewPgBackend returns a PgBackend bound to db.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
New returns a Store. Panics on misconfiguration — the middleware must not start in a state where cache writes silently fail.
func (*Store) Capability ¶
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 ¶
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.