Retry policies
Retry settings can be defined per message. Two contracts are involved:
RetryPolicy(SomeWork\CqrsBundle\Contract\RetryPolicy) is resolved for every dispatched message. ItsgetStamps()result is added to the envelope in the stamp pipeline.RetryConfiguration(SomeWork\CqrsBundle\Contract\RetryConfiguration) is an optional second interface for a policy. It exposes the numbers Messenger needs to retry a failed message.CqrsRetryStrategyreads them on the transports you list underretry_strategy.transports.
Retries are a worker concern. Messenger retries messages that failed while a worker consumed them from a transport. When a message is handled synchronously and fails, the exception propagates to the caller, and no retry happens.
Contracts
<?php
declare(strict_types=1);
namespace SomeWork\CqrsBundle\Contract;
use SomeWork\CqrsBundle\Bus\DispatchMode;
use Symfony\Component\Messenger\Stamp\StampInterface;
interface RetryPolicy
{
/**
* @return list<StampInterface>
*/
public function getStamps(object $message, DispatchMode $mode): array;
}
interface RetryConfiguration
{
/** Maximum number of retry attempts before the message is rejected. */
public function getMaxRetries(): int;
/** Initial delay in milliseconds before the first retry attempt. */
public function getInitialDelay(): int;
/** Multiplier applied to the delay for each subsequent retry attempt. */
public function getMultiplier(): float;
}
The bundle calls getStamps() in the stamp pipeline (RetryPolicyStampDecider, priority
200) for commands, queries and events, in every dispatch mode. Return an empty array when
the policy only configures transport retries.
Built-in policies
| Class | Stamps | RetryConfiguration |
Notes |
|---|---|---|---|
SomeWork\CqrsBundle\Policy\NullRetryPolicy |
none | no | The default policy of every message type. Transports keep their own Messenger retry strategy. |
SomeWork\CqrsBundle\Policy\ExponentialBackoffRetryPolicy |
none | yes | Constructor (int $maxRetries = 3, int $initialDelay = 1000, float $multiplier = 2.0). Rejects $maxRetries < 0, $initialDelay < 1 and $multiplier <= 0. |
Both are registered as services under their class names. The ExponentialBackoffRetryPolicy
service uses the constructor defaults, and somework_cqrs.exponential_backoff_retry_policy
is an alias of it. For other values, register your own service:
# config/services.yaml
services:
app.retry.payment:
class: SomeWork\CqrsBundle\Policy\ExponentialBackoffRetryPolicy
arguments:
$maxRetries: 5
$initialDelay: 1000 # milliseconds
$multiplier: 2.0
Mapping policies to messages
retry_policies has a global default and one section per message type. Each section has
an optional default service id and a map from message class or interface names to
service ids:
# config/packages/somework_cqrs.yaml
somework_cqrs:
retry_policies:
default: SomeWork\CqrsBundle\Policy\NullRetryPolicy
command:
default: ~
map:
App\Application\Command\ProcessPayment: app.retry.payment
App\Application\Command\TalksToPaymentGateway: SomeWork\CqrsBundle\Policy\ExponentialBackoffRetryPolicy
event:
default: ~
map: {}
query:
default: ~
map: {}
A message's policy is looked up in this order: exact class, parent classes, interfaces,
the section's default, then the global default. Map keys must be existing class or interface names (a
leading \ is allowed), and a typo fails container compilation. Each value must be a
service that implements RetryPolicy.
Transport retry strategy
A RetryConfiguration only takes effect on transports that you hand over to the bundle:
# config/packages/somework_cqrs.yaml
somework_cqrs:
retry_strategy:
transports:
async_commands: command
async-events: event
jitter: 0.1
max_delay: 60000
| Option | Default | Description |
|---|---|---|
transports |
{} |
Messenger transport names, as a list or mapped to command, query or event. Commands, queries and events received from the transport use the retry_policies section of their own type; the mapped type (command for a list) is used for any other message. |
jitter |
0.0 |
Random variation of each computed delay, between 0.0 and 1.0. 0.1 means ±10 %. |
max_delay |
0 |
Upper bound for each delay, in milliseconds. 0 means no cap. |
For each listed transport, CqrsRetryStrategyPass replaces the transport's entry in
Messenger's messenger.retry_strategy_locator with a CqrsRetryStrategy. The transport's
original strategy is kept as the fallback. Keep the following in mind:
- Keys are Messenger transport names, written exactly as they appear under
framework.messenger.transports. They are not normalised: a transport namedasync-eventsmust be listed asasync-events, notasync_events. - Unknown transports are rejected. Compilation fails with
Transport "<name>" configured under "somework_cqrs.retry_strategy.transports" is not a Messenger transport. Known transports: "...". - Each message uses its own type. A command received from the transport is resolved
against
retry_policies.command, an event againstretry_policies.event, whatever type the transport is mapped to; commands and events can share a transport. The mapped type only applies to messages that are neither commands, queries nor events.
How CqrsRetryStrategy decides
When a worker fails to handle a message from a mapped transport, Messenger asks the
strategy two questions: whether to retry, and how long to wait. CqrsRetryStrategy
resolves the message's policy and continues as follows.
The policy implements RetryConfiguration:
- Retry or not. The message is retried while
retryCount < getMaxRetries(). The retry count comes from Messenger'sRedeliveryStampand is0on the first failure, somaxRetries: 3allows three retries (four attempts in total). - Delay. It is computed in three steps:
initialDelay × multiplier^retryCount;- capped at
max_delay(when it is greater than 0); - multiplied by a random factor between
1 - jitterand1 + jitter, then capped atmax_delayagain.
Because the cap comes before the jitter, jitter still spreads retries that hit the cap,
and a delay never exceeds max_delay.
The policy does not implement RetryConfiguration (for example NullRetryPolicy): both
questions go to the transport's original strategy. That strategy is whatever
framework.messenger.transports.<name>.retry_strategy configures. By default, that is
Messenger's MultiplierRetryStrategy with 3 retries, a 1000 ms initial delay and a
multiplier of 2, plus Messenger's own jitter (default 0.1). The bundle's jitter and
max_delay options do not apply to it.
With ExponentialBackoffRetryPolicy(5, 1000, 2.0), max_delay: 5000 and jitter: 0, the
delays are:
| Failure | Retry count | Retried? | Delay |
|---|---|---|---|
| 1st | 0 | yes | 1000 ms |
| 2nd | 1 | yes | 2000 ms |
| 3rd | 2 | yes | 4000 ms |
| 4th | 3 | yes | 5000 ms (capped) |
| 5th | 4 | yes | 5000 ms (capped) |
| 6th | 5 | no, sent to the failure transport (if one is configured) | none |
Messenger applies its own rules before it asks the strategy:
- An exception that implements
UnrecoverableExceptionInterface(for exampleUnrecoverableMessageHandlingException) is never retried. - An exception that implements
RecoverableExceptionInterfaceis always retried, and a delay it provides is used instead of the strategy's.
Writing a policy with RetryConfiguration
<?php
declare(strict_types=1);
namespace App\Infrastructure\Retry;
use SomeWork\CqrsBundle\Bus\DispatchMode;
use SomeWork\CqrsBundle\Contract\RetryConfiguration;
use SomeWork\CqrsBundle\Contract\RetryPolicy;
use Symfony\Component\Messenger\Stamp\StampInterface;
final class FixedIntervalRetryPolicy implements RetryPolicy, RetryConfiguration
{
public function __construct(
private readonly int $maxRetries = 5,
private readonly int $delay = 2000,
) {
}
/**
* @return list<StampInterface>
*/
public function getStamps(object $message, DispatchMode $mode): array
{
return [];
}
public function getMaxRetries(): int
{
return $this->maxRetries;
}
public function getInitialDelay(): int
{
return $this->delay;
}
public function getMultiplier(): float
{
return 1.0; // same delay before every retry
}
}
Register the class as a service (autowiring does this for classes under src/), then map it
in retry_policies.<type>.map. List the transports that carry those messages under
retry_strategy.transports.