spiral / idempotency
Idempotency guarantees (AtLeastOnce lease / ExactlyOnce inbox) for Spiral Framework operations.
Requires
- php: >=8.4
- psr/clock: ^1.0
- psr/log: ^2.0 || ^3.0
- spiral/boot: ^3.15
- spiral/serializer: ^3.15
- yiisoft/friendly-exception: ^1.1
Requires (Dev)
- cycle/database: ^2.21
- cycle/schema-builder: ^2.12
- cycle/skills: 1.x-dev
- cycle/transaction: ^1.1
- google/common-protos: ^3.1 || ^4.0
- google/protobuf: ^3.7 || ^4.0 || ^5.0
- infection/infection: ^0.33.2
- llm/skills: dev-master
- nyholm/psr7: ^1.8
- predis/predis: ^2.2
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- spiral/cycle-bridge: ^2.13
- spiral/dumper: ^3.3
- spiral/events: ^3.15
- spiral/interceptors: ^3.15
- spiral/queue: ^3.15
- spiral/roadrunner-bridge: ^4.0
- spiral/roadrunner-grpc: ^3.6
- testo/testo: ^0.10.20
- vimeo/psalm: ^7
Suggests
- cycle/database: Use the bundled Cycle driver (lease/inbox storage over a Cycle DBAL connection) — required by everything under Spiral\Idempotency\Driver\Cycle.
- cycle/transaction: Required by the bundled Cycle driver's inbox storage (single-transaction ExactlyOnce commit) — everything under Spiral\Idempotency\Driver\Cycle.
- predis/predis: Default client for the Redis/Valkey lease backend (RedisLeaseConfig) — AtLeastOnce storage over a Redis-compatible server. Not needed when RedisCommands (Driver/Redis) is bound to an adapter over your own Redis client.
- spiral/cycle-bridge: Integrate the idempotency tables into the Cycle ORM schema (cycle:migrate / cycle:sync) via CycleSchemaBootloader — also pulls cycle/schema-builder.
- spiral/events: Make PSR-14 event listeners idempotent declaratively with #[Idempotent] via IdempotentListenerFactory, bound by EventsIdempotencyBootloader — a per-listener key, so one failing listener of a fan-out re-runs alone on the next dispatch.
- spiral/interceptors: Make HTTP controller actions idempotent declaratively with the #[Idempotent] attribute via IdempotencyInterceptor (also needs a PSR-17 factory).
- spiral/queue: Make queue/job handlers idempotent declaratively with #[Idempotent] via IdempotencyInterceptor bound in the 'queue' scope by QueueIdempotencyBootloader (Locked -> native job retry).
- spiral/roadrunner-bridge: Runs the gRPC (and queue) dispatcher in a Spiral app: provides config/grpc.php with its `interceptors` list, the 'grpc' dispatcher scope and the invoker that IdempotencyInterceptor plugs into. Required to actually serve gRPC; no type of it is imported here.
- spiral/roadrunner-grpc: Make gRPC service methods idempotent declaratively with #[Idempotent] via IdempotencyInterceptor bound in the 'grpc' scope by GrpcIdempotencyBootloader (Locked -> ABORTED status with a google.rpc.RetryInfo detail, key from metadata). Pulls google/protobuf + google/common-protos, which the gRPC middleware use directly.
Provides
None
Conflicts
None
Replaces
None
- 1.x-dev
- 0.4.1
- 0.4.0
- 0.3.0
- 0.2.1
- 0.2.0
- dev-release-please--branches--1.x--components--idempotency
- dev-feat/default-storage-alias
- dev-feature/optional-transports-section
- dev-feature/memory-lease-config
- dev-feature/classifier-retryable-queue-exception
- dev-feat/events-transport
- dev-feature/failure-policy
- dev-feat/queue-job-id-key
- dev-feat/argument-key-resolver
- dev-feat/class-level-idempotent
- dev-test/increase-coverage
This package is auto-updated.
Last update: 2026-09-16 12:48:30 UTC
README
The package protects operations from duplicated side-effects on retries in Spiral Framework
applications. It ships two mechanisms with different guarantees — an AtLeastOnce lease
(lock + fencing token + response cache) and an ExactlyOnce-effect transactional inbox —
behind one declarative #[Idempotent] attribute and one programmatic API.
Installation
composer require spiral/idempotency
Optional packages:
cycle/database— the bundled storage driver (lease and inbox tables over a Cycle DBAL connection);spiral/interceptors— the declarative#[Idempotent]attribute viaIdempotencyInterceptor(also needs a PSR-17 factory for the HTTP middleware);spiral/queue— the queue/jobs transport (QueueIdempotencyBootloader,QueueKeyMiddleware,QueueRetryMiddleware), making consumed jobs idempotent withLocked → native job retry;spiral/events— the PSR-14 events transport (EventsIdempotencyBootloader,IdempotentListenerFactory), making individual#[Listener]methods idempotent with a per-listener key;predis/predis— the default client for the Redis/Valkey lease backend (RedisLeaseConfig): AtLeastOnce storage over a Redis-compatible server, with server-side TTL (no GC needed) and atomic Lua CAS. Not needed whenDriver\Redis\RedisCommandsis bound to an adapter over the app's own Redis client;spiral/cycle-bridge— integrates the idempotency tables into the ORM schema (cycle:sync/cycle:migrate).
Documentation
Two guarantees
Exactly-once delivery is impossible: between committing a side-effect and recording its completion there is always a crash window, so any retry may re-run the effect. What is achievable:
| Guarantee | Mechanism | When |
|---|---|---|
| AtLeastOnce | Lease: atomic conditional insert + fencing-token CAS; the result is cached and replayed | Always. The side-effect may repeat inside the crash window |
| AtMostOnce | Dedup-guard: the marker commits before the effect and is never removed, so a duplicate is refused (not re-run) | When losing the effect is safer than repeating it; a repeat is not safe to retry |
| ExactlyOnce (effect) | Inbox: INSERT ... ON CONFLICT DO NOTHING + the side-effect commit in one DB transaction |
Only when the side-effect writes to the same database as the inbox record |
Use the lease for non-transactional effects (calling a payment gateway, sending an email) and the
inbox when the whole effect lives in your database (creating an order). The dedup-guard is for
fire-and-forget jobs/events where the effect must run at most once and a crash between marker and
effect may lose it — a duplicate gets null (or, with cacheResult: true, a best-effort cached
result: the marker and the result are not committed atomically, so replay is not guaranteed).
Quick start
Register the bootloaders:
// app/src/Application/Kernel.php public function defineBootloaders(): array { return [ // ... \Spiral\Idempotency\Bootloader\IdempotencyBootloader::class, // opt-in: HTTP wiring for the #[Idempotent] attribute \Spiral\Idempotency\Bootloader\HttpIdempotencyBootloader::class, // opt-in: idempotency tables in the ORM schema (cycle:sync / cycle:migrate) \Spiral\Idempotency\Bootloader\CycleSchemaBootloader::class, ]; }
Describe the storages and the HTTP middleware stack:
// app/config/idempotency.php use Spiral\Idempotency\Driver\Cycle\CycleInboxConfig; use Spiral\Idempotency\Driver\Cycle\CycleLeaseConfig; use Spiral\Idempotency\Http\HttpKeyMiddleware; use Spiral\Idempotency\Http\HttpOutcomeMiddleware; return [ 'default' => 'payments', // Resolution middleware, outer → inner. The outcome middleware sits OUTERMOST: it marshals // the response (replay headers, Locked → 409, missing key → 400); the key middleware inside // it extracts and normalizes the key. 'transports' => [ 'http' => [ HttpOutcomeMiddleware::class, HttpKeyMiddleware::class, ], ], // Semantic aliases: the handler code names an alias, the driver and the declared guarantee // live here. The declared guarantee is verified against the driver capability at bootstrap. 'storages' => [ 'payments' => new CycleLeaseConfig( table: 'idempotency_lease', lockTtl: 30, // seconds the PROCESSING lock is held retentionTtl: 3600, // seconds the cached result is kept ), 'orders' => new CycleInboxConfig( table: 'idempotency_inbox', ), ], ];
default names the alias an #[Idempotent] without a storage: argument falls back to, so a
single-storage application never repeats the alias in business code. It is optional: omit it and
storage: becomes mandatory — an attribute that then omits it throws MisconfigurationException
before the action runs (never as a 4xx/5xx response: a misconfiguration is not an outcome of the
call), and for an event listener already when the listener is registered.
A default pointing at an alias absent from storages fails at bootstrap, not on the first call.
transports is optional as a whole — omit it when no transport bootloader is registered (the
IdempotencyRegistry::execute() usage below). Once one is registered, its own entry becomes
mandatory: a missing transports.<name> throws MisconfigurationException on the first
#[Idempotent] call instead of running an empty pipeline that deduplicates on nothing. An explicit
'http' => [] is valid — the key then comes only from the attribute.
Storage drivers available under storages:
| Config | Guarantee | Backend |
|---|---|---|
Driver\Cycle\CycleLeaseConfig |
AtLeastOnce | Lease table over a Cycle DBAL connection |
Driver\Cycle\CycleInboxConfig |
ExactlyOnce | Inbox table; the record and the side-effect commit in one transaction |
Driver\Cycle\CycleAtMostOnceConfig |
AtMostOnce | Dedup-guard table |
Driver\Redis\RedisLeaseConfig |
AtLeastOnce | Redis/Valkey hash with server-side TTL — no GC needed |
Driver\Memory\MemoryLeaseConfig |
AtLeastOnce | Per-process PHP array. Dedup holds only inside the worker that acquired the lease and dies with it — for tests and local development, not for production traffic |
Add one line to your existing domain-core interceptor list — reference the
IdempotencyInterceptor alias, not a concrete class:
use Spiral\Idempotency\Interceptor\IdempotencyInterceptor; final class AppBootloader extends DomainBootloader { protected const INTERCEPTORS = [ // ...your existing interceptors (Cycle, Guard, ...) — innermost after auth: IdempotencyInterceptor::class, ]; }
Mark an action:
use Spiral\Idempotency\Attribute\Idempotent; final class PaymentController { #[Route(route: '/payments/charge', methods: 'POST')] #[Idempotent(storage: 'payments')] public function charge(): ResponseInterface { // charge the gateway, return the response — it will be cached and replayed } }
Create the tables with the project's normal workflow: php app.php cycle:sync (or generate a
migration with cycle:migrate).
Note
Reference the IdempotencyInterceptor alias, with no scope. The DomainBootloader
interceptor list is resolved in the root container, where the alias is a forwarding proxy: on every
call it resolves the real, transport-flavored interceptor from the active dispatcher scope (the
http scope, where HttpIdempotencyBootloader bound it; the queue scope for
QueueIdempotencyBootloader). So the same domain core works in any scope, and every transport
reuses the same alias. Referencing the concrete
PipelineIdempotencyInterceptor class instead would fail — it is deliberately unbound in root. Invoking the
proxy outside a transport scope fails fast with a friendly MisconfigurationException.
HTTP behaviour
The client generates a key and sends it with every retry of the same operation:
curl -X POST /payments/charge -H 'Idempotency-Key: pay-42' -d 'amount=500'
| Situation | Response |
|---|---|
| First call | The action runs; the whole response is snapshotted. Headers: Idempotency-Key, Idempotency-Replay: false |
| Retry after completion | The cached response is replayed byte-identically, Idempotency-Replay: true; the action does not run |
| Retry while the first call is still in flight | 409 Conflict + Retry-After (lease storages) |
| No key supplied | 400 Bad Request with a JSON body |
The action responded 5xx |
Not cached: the key is released and a retry re-runs the operation (configurable predicate of HttpOutcomeMiddleware) |
By default HttpKeyMiddleware reads the Idempotency-Key header, then the key body/query field
(both names are constructor-configurable).
Queue / Jobs behaviour
The same interceptor makes queue/job handlers idempotent — a broker delivers at-least-once, so a
redelivered job must not double-run its side-effect. Register the queue bootloader and list the queue
middleware under transports.queue:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\QueueIdempotencyBootloader::class,
// app/config/idempotency.php use Spiral\Idempotency\Queue\QueueKeyMiddleware; use Spiral\Idempotency\Queue\QueueRetryMiddleware; 'transports' => [ 'queue' => [ QueueRetryMiddleware::class, // outer: maps failure modes back to the transport QueueKeyMiddleware::class, // inner: extracts the key from the job header ], ],
Register the interceptor on the consume side (same alias, no concrete class) — either in
app/config/queue.php under interceptors.consume, or via the bootloader:
use Spiral\Idempotency\Interceptor\IdempotencyInterceptor; $queue->addConsumeInterceptor(IdempotencyInterceptor::class);
Important
Keep Spiral's default RetryPolicyInterceptor outer of the idempotency interceptor in the
consume list: QueueRetryMiddleware re-throws a Locked collision as a RetryableLockException,
and it is RetryPolicyInterceptor that catches it and re-enqueues the job with the carried delay.
We reuse the framework's retry engine rather than reimplementing backoff.
A failing job releases its key (FailurePolicy::Release is the queue default), so the redelivery
the retry policy schedules actually re-runs the handler instead of replaying the cached failure. Pass
failurePolicy: FailurePolicy::Cache on the attribute for a job whose failure is a final answer.
Mark a job handler — the key comes from the payload via the attribute's key path, or from a
job header the producer set (Options::withHeader('Idempotency-Key', ...)):
#[Idempotent(storage: 'orders', key: 'orderId')] // dot-path over the job payload public function invoke(string $orderId, array $payload): void { // runs once per orderId; a redelivered job replays / is skipped }
When the producer is outside the application — a CDC pipeline publishing from a transactional outbox
table, say — the job carries no headers at all. Enable the job-id fallback: the broker job id
(the id argument of the consume call context) becomes the key material. Mind what it identifies: one
broker message, not the logical operation. The id is the same when the broker redelivers the message
(consumer crash, missing ack); a job re-published by a retry policy or pushed a second time by the
producer carries a new one, and whether a driver keeps the id across its own requeue is driver-specific.
To deduplicate the operation itself, keep a key from the payload (key: 'payload.<field>') or the
header. The fallback is off by default so a producer that merely forgot the header does not get silent,
weaker deduplication:
// a bootloader of the app — the config stack lists class names, so bind the configured instance QueueKeyMiddleware::class => static fn(KeyResolver $resolver): QueueKeyMiddleware => new QueueKeyMiddleware($resolver, fallbackToJobId: true),
The header still wins when present, and the key scope (operation identity by default) still namespaces the result, so two job types redelivered with the same broker id do not collide.
| Situation | Outcome |
|---|---|
| First delivery | The handler runs; the guarantee records the effect (inbox commit / lease + cache) |
| Redelivery after completion | Deduplicated — the handler does not re-run (ExactlyOnce/AtLeastOnce), the job is ACKed |
| Delivery while another worker holds the key | LockedException → RetryableLockException → the job is re-enqueued after the lock TTL (not dead-lettered) |
| No key resolvable (bad payload/header) | MissingKeyException propagates — not retryable, so the job dead-letters instead of retrying forever |
AtMostOnce (dedup-guard) duplicate |
The handler returns null → the job is ACKed (fire-once); this is the natural home for AtMostOnce |
For transient failure retries (infra errors), compose Spiral's own #[RetryPolicy] attribute on
the handler alongside #[Idempotent] — the two are orthogonal: idempotency dedups the effect, the
retry policy governs re-delivery.
A job whose entry point lives on an abstract base — the common "handle() implemented once, subclasses
implement invoke()" shape — annotates the class instead; there is no method of its own to mark:
#[Idempotent(storage: 'orders')] // key from the job header final class DispatchEvent extends JobHandler {} // handle() is inherited from JobHandler
gRPC behaviour
The same interceptor makes gRPC service methods idempotent. Register the gRPC bootloader, list the
gRPC middleware under transports.grpc, and add the interceptor to config/grpc.php:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\GrpcIdempotencyBootloader::class,
// app/config/idempotency.php use Spiral\Idempotency\Grpc\GrpcKeyMiddleware; use Spiral\Idempotency\Grpc\GrpcOutcomeMiddleware; 'transports' => [ 'grpc' => [ GrpcOutcomeMiddleware::class, // outer: response/status snapshot, Locked → ABORTED GrpcKeyMiddleware::class, // inner: key from the `idempotency-key` metadata entry ], ],
The client sends the key as metadata; the server-side key middleware reads idempotency-key
(case-insensitively, configurable).
| Situation | Outcome |
|---|---|
| First call | The method runs; the protobuf response message is snapshotted. Response metadata: idempotency-key, idempotency-replay: false |
| Retry after completion | The cached message is rebuilt and returned with idempotency-replay: true; the method does not run |
| Retry while the first call is in flight | ABORTED — the status gRPC recommends for "retry at a higher level" (the analog of HTTP 409) — with a google.rpc.RetryInfo detail carrying the suggested delay (the analog of Retry-After) |
| No key in the metadata | INVALID_ARGUMENT (the analog of HTTP 400) |
The method threw a GRPCException |
The status is snapshotted (code + message + details) and replayed identically, exact subclass included |
... with a transient status (UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL, ...) |
Not cached: the key is released and a retry re-runs (configurable predicate of GrpcOutcomeMiddleware) |
A dedup hit with no cached message (AtMostOnce duplicate, void operation) |
The method's declared response type is returned empty — gRPC has no "empty ACK", and the bridge's invoker requires a Message |
Unlike HTTP, no configuration is needed to keep a failure replay faithful: over gRPC a negative
outcome is a status, and code + message + details is a complete, deterministic snapshot of what
the client sees. Bind a DomainFailureMapper only if the service throws plain domain
exceptions instead of GRPCException — that mapping otherwise happens above the interceptor, too late
to be cached, and the replay would answer with a different status.
Infrastructure and Bug failures (including a GRPCException marked Retryable) are never
snapshotted: they stay exceptions so the key is released and a retry re-runs.
Events behaviour
The same attribute makes PSR-14 event listeners idempotent, one listener method at a time. The
target is a durable fan-out: an event re-published by an outbox (or dispatched again by a redelivered
job) reaches several #[Listener] methods, and a failure in one of them must not re-run the others.
Register the events bootloader — there is no transports.events config section and no interceptor to
wire, because events are dispatched through PSR-14 rather than through a domain core:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\EventsIdempotencyBootloader::class,
It replaces the framework's ListenerFactoryInterface with IdempotentListenerFactory, which wraps
each marked listener method in its storage. The factory is the integration point on purpose: the
dispatcher sees only an opaque list of closures, while the factory still knows which listener a
closure belongs to — the identity a per-listener key needs.
Give the event a stable identity by implementing HasIdempotencyKey, then mark the listeners:
use Spiral\Idempotency\Events\HasIdempotencyKey; final class OrderPlaced implements HasIdempotencyKey { public function __construct(public readonly string $id) {} // outbox message_id, not a fresh uuid public function idempotencyKey(): string { return $this->id; } } final class PaymentService { #[Listener] #[Idempotent(storage: 'events')] public function charge(OrderPlaced $event): void {} #[Listener] #[Idempotent(storage: 'events')] public function notify(OrderPlaced $event): void {} }
An application whose events already share a base contract adopts the interface once, rather than per event class:
interface DomainEvent extends HasIdempotencyKey {} trait WithEventId { public readonly Uid $id; // the outbox message_id, preserved across re-publications public function idempotencyKey(): string { return $this->id->rawValue(); } }
Every event using the trait is then a valid target for a marked listener without a key path; keep the identity the producer preserves, not one regenerated per dispatch.
The key can also come from the attribute's key arg-path, resolved over the single argument event
(key: 'event.id') — use it for an event you do not own. An event with neither source throws
MissingKeyException: a listener marked idempotent must not deduplicate on nothing.
| Situation | Outcome |
|---|---|
| First dispatch | Every marked listener runs, each under its own key (scope = ListenerClass::method) |
| Re-dispatch of the same event | Each marked listener that completed is skipped; an unmarked one runs again |
| One listener of the fan-out threw | The dispatcher stops at it (PSR-14 semantics); the re-dispatch skips the listeners that completed and re-runs only the failed one |
| Concurrent dispatch of the same event | LockedException propagates untouched — the durable producer that dispatched the event owns the retry |
Event with neither key path nor HasIdempotencyKey |
MissingKeyException |
A failing listener releases its key (FailurePolicy::Release is the events default), which is what
makes the partial re-run work. Pass failurePolicy: FailurePolicy::Cache on the attribute for a
listener whose failure is a final answer.
Important
The event identity must be the one the producer preserves across re-publications of the same logical
event — an outbox message_id, not a value regenerated per dispatch. A fresh id makes every
redelivery look like a new event and silently disables deduplication.
The #[Idempotent] attribute
#[Idempotent(storage: 'payments', key: 'command.orderId', lockTtl: 60, ttl: 86400, scope: null)]
| Parameter | Meaning |
|---|---|
storage |
Semantic alias from the config — the only infrastructure reference in business code. Omit it to take the config's default alias |
key |
Dot-notation path over the call arguments, resolved by ArgumentKeyResolver: the leaf may be a non-boolean scalar, a Stringable (domain id) or a backed enum. null lets a transport middleware supply the key (HTTP header/field). A path that resolves to nothing fails fast |
lockTtl |
Override of the PROCESSING lock TTL, seconds (lease driver only) |
ttl |
Override of the completed-record retention TTL, seconds |
scope |
Key namespace, see below |
failurePolicy |
What a thrown failure does to the key: Cache (replay it) or Release (free the key, the next call re-runs). null = the transport default, see below |
The attribute goes on a method or on a class. On a class it covers every method the
transport dispatches to on that class, including ones inherited from an abstract base — the only way
to annotate a handler that never redeclares its entry point. On a job handler that is the single
handle(); on a controller it makes every action idempotent under the one storage alias, so reach
for a class attribute there only when that is what you mean. Resolution order, first hit wins:
- the dispatched method;
- the concrete class of the target;
- its parents, nearest first.
So a method attribute beats a class one, and a subclass beats the base it inherits from.
Keys are namespaced by operation identity so that the same client key sent to two different endpoints never replays a foreign response:
scope |
Key space |
|---|---|
null (default) |
Controller::method — safe per-operation isolation |
'payment-flow' |
Explicit name — intentionally shared by several endpoints |
Idempotent::SCOPE_GLOBAL |
No namespacing — the client is responsible for global uniqueness |
The default scope names the concrete class, not the one that declares the method, so sibling subclasses sharing one inherited entry point get one key space each.
ExactlyOnce: write through the transaction
The inbox driver opens a database transaction that carries both the dedup record and your
side-effect. Inside the operation, narrow the context to CycleContext and write through it —
this is the contract that makes the effect exactly-once:
use Spiral\Idempotency\Driver\Cycle\CycleContext; use Spiral\Idempotency\IdempotencyContext; #[Idempotent(storage: 'orders', key: 'command.orderId')] public function place(PlaceOrder $command, IdempotencyContext $ctx): array { \assert($ctx instanceof CycleContext); // ORM entities: a scoped Unit of Work flushed inside the transaction $ctx->entityManager()->persist(new Order(...)); // ...or raw DBAL through the transactional connection $ctx->database()->insert('order_events')->values([...])->run(); return ['status' => 'placed']; }
Crash before COMMIT → everything rolls back → a retry is clean. Crash after COMMIT → the retry
sees the dedup conflict, skips the operation and replays the stored result. No window.
Important
The guarantee only holds for effects written through the transactional connection. Anything external — an HTTP call, a message queue, another database — degrades the operation to AtLeastOnce no matter which driver runs it.
Programmatic usage
When the key is already known, skip the attribute and call the driver directly:
use Spiral\Idempotency\ExecuteOptions; use Spiral\Idempotency\FailurePolicy; use Spiral\Idempotency\IdempotencyContext; use Spiral\Idempotency\IdempotencyRegistry; public function __construct( private readonly IdempotencyRegistry $registry, ) {} public function handle(string $transactionId): Receipt { return $this->registry->get('payments')->execute( $transactionId, static fn(IdempotencyContext $ctx): Receipt => /* the operation */, new ExecuteOptions(lockTtl: 60, ttl: 86400, failurePolicy: FailurePolicy::Cache), ); }
The programmatic path applies no automatic namespacing — compose the final key yourself
(KeyResolver is available as a service and supports parentKey hierarchies for
composing multi-step chains).
Failure classification
An exception thrown by the operation is classified into one of three kinds — deterministic outcome and "will a retry help" are independent axes:
| Kind | Default mapping | Lease reaction |
|---|---|---|
| Domain | any other \Exception, or a RetryableExceptionInterface that is not retryable |
Cached as a valid negative outcome, replayed on retry |
| Infrastructure | \Error, \Exception implementing Retryable, or a retryable RetryableExceptionInterface |
The key is released; the transport/client retries |
| Bug | Only by explicit configuration (DefaultFailureClassifier(bugExceptions: [...])) |
The key is released; no re-enqueue — report and fix |
RetryableExceptionInterface is spiral/queue's own retry contract: a job exception that already
states its retry intent for the broker is read the same way here, so it needs no second marker. The
rule is skipped when spiral/queue is not installed. It is checked before the \Error rule, so an
\Error implementing the contract follows isRetryable() rather than its type — the one combination
whose classification changed in 0.4.
A cached domain failure is replayed as CachedDomainFailureException carrying the original class
name and message. For an exact-type replay, implement ReplayableFailure on the
domain exception:
final class PaymentDeclined extends \DomainException implements ReplayableFailure { public function toReplayPayload(): array { return ['code' => $this->code, 'reason' => $this->reason]; } public static function fromReplayPayload(array $payload): static { return new self($payload['code'], $payload['reason']); } }
Per-operation failure policy
Classification decides what kind of failure happened; FailurePolicy decides whether the operation
may run again under the same key at all:
| Policy | Effect on a thrown failure |
|---|---|
Cache |
The table above applies: a Domain failure becomes a cached negative outcome and is replayed |
Release |
Any failure frees the key and the original throwable is rethrown unchanged — the next call re-runs the operation |
The default comes from the transport, and follows who owns the retry: queue → Release (the broker
redelivers the job), HTTP and gRPC → Cache (the client repeats the call itself and must see the
same answer). A direct execute() without options also caches. Override per operation on the attribute
or in ExecuteOptions:
use Spiral\Idempotency\FailurePolicy; #[Idempotent(storage: 'jobs', key: 'event.id', failurePolicy: FailurePolicy::Release)]
This is a lease/AtLeastOnce contract. The inbox (ExactlyOnce) and at-most-once drivers ignore it: an inbox failure already rolls the dedup row back with the side-effect, and a committed record of either driver is terminal — releasing it would let the effect run twice.
Consistent HTTP status for a thrown domain failure
Warning
A thrown domain failure bypasses the response snapshot, so the two attempts are rendered by different code: the first by your exception handler, the replay from the cached snapshot. Over HTTP that means a different status code — unless you do one of the three things below.
Three ways to keep the replay identical to the first attempt, best first:
-
Return an error response instead of throwing — it is snapshotted and replayed byte-identically, status included. Nothing to configure.
-
Bind a
DomainFailureRenderer— keeps the throwing style:HttpOutcomeMiddlewarerenders Domain-classified throwables into a response inside the operation, so the outcome is cached as a response snapshot and the replay carries the same status and theIdempotency-Replayheader:final class DeclineRenderer implements DomainFailureRenderer { public function __construct(private ResponseFactoryInterface $responses) {} public function render(\Throwable $failure): ?ResponseInterface { // Return null for failures this renderer does not own — they are rethrown untouched. return $failure instanceof PaymentDeclined ? $this->responses->createResponse(402) : null; } }
Bind it in a bootloader (
DomainFailureRenderer::class => DeclineRenderer::class) — the middleware picks it up by autowiring. OnlyFailureKind::Domainfailures reach the renderer: Infrastructure and Bug ones stay exceptions, so the key is still released and a retry re-runs. Thecacheablepredicate still applies, so a rendered 5xx is not cached either. Rows written before the renderer was bound keep replaying as a throw. -
Map
CachedDomainFailureException::$originalClassin the application exception handler.
Customization
- Middleware — both pipelines are open: implement
ResolutionMiddleware(transport phase, key extraction / outcome mapping) and list it undertransports.<name>, orExecutionMiddleware(domain phase around the operation). - Serializer — cached results are serialized with
spiral/serializer(PhpSerializerby default); bind your ownSerializerInterfaceto switch, e.g. to JSON. - Classifier — bind
FailureClassifierto replace the default failure mapping. - Domain failure rendering — bind
DomainFailureRendererto turn thrown domain failures into cached HTTP responses, so a replay reproduces the same status (see above). - Key policy — bind
KeyResolverto change normalization, hashing and hierarchy composition, orArgumentKeyResolverto change how the attribute'skeypath is walked and which leaf types have a string form. - Schema — role names of the generated ORM tables are customizable via
SchemaNaming; table names live in the storage configs.
Important
The default PhpSerializer unserializes blobs read from the idempotency tables. The trust
boundary is the table itself: if several services or roles can write to that database, bind a
JSON serializer and keep the operation results JSON-safe.