Retry & Dead Letter
Automatic retry with exponential backoff and dead letter storage — Vortos handles failure recovery so your handlers don't have to.
Retry & Dead Letter
When a handler throws an exception, Vortos does not immediately discard the message. It retries the handler with a configurable backoff policy. Only after all retry attempts are exhausted does the message get written to the dead letter store — where it can be inspected and replayed manually.
How Retry Works
The retry logic lives in ConsumerRunner::processHandler(). On the first failure, the consumer's retry policy is read once from ConsumerRegistry, then a retry loop begins:
Handler throws exception
│
▼
Read RetryPolicy from ConsumerRegistry (once — not per retry iteration)
│
▼
attempt = 1
│
▼
RetryDecider::shouldRetry(policy, attempt)?
├── YES → sleep(getDelayMs(policy, attempt)) → retry handler
│ │
│ SUCCESS → set idempotency cache key, return true
│ FAILURE → attempt++, lastException = e, loop again
│
└── NO → DeadLetterWriter::write(...) → return falseThe shouldRetry() method returns true as long as attemptNumber <= policy.maxAttempts. The delay between attempts is calculated by RetryDelayCalculator based on the backoff strategy.
Configuring Retry Policy
Retry is configured per consumer in your #[MessagingConfig] class:
use Vortos\Messaging\Retry\RetryPolicy;
#[RegisterConsumer]
public function userConsumer(): KafkaConsumerDefinition
{
return KafkaConsumerDefinition::create('user.events')
->groupId('user-service')
->retry(RetryPolicy::exponential(
attempts: 3, // max retry attempts
initialDelayMs: 500, // first retry waits 500ms
maxDelayMs: 30000, // cap delay at 30 seconds
jitter: true, // add ±20% randomness to prevent thundering herd
));
}Delays with initialDelayMs: 500, multiplier: 2.0:
| Attempt | Delay (no jitter) |
|---|---|
| 1 | 500ms |
| 2 | 1000ms |
| 3 | 2000ms |
With jitter: true, each delay is randomized by ±20% to prevent all consumers retrying simultaneously after a shared failure.
->retry(RetryPolicy::fixed(
attempts: 5, // max retry attempts
delayMs: 2000, // wait exactly 2 seconds between each retry
))All retries wait the same duration. Use for predictable, low-frequency events where timing consistency matters more than throughput.
RetryPolicy Reference
RetryPolicy::exponential(
attempts: int, // maximum number of retry attempts
initialDelayMs: int, // delay before first retry (ms)
maxDelayMs: int = 30000, // maximum delay cap (ms)
maxReplayLimit: int = 10, // hard discard after this many DLQ replays
jitter: bool = true, // randomize delay to prevent thundering herd
): RetryPolicy
RetryPolicy::fixed(
attempts: int, // maximum number of retry attempts
delayMs: int, // fixed delay between each retry (ms)
maxReplayLimit: int = 10, // hard discard after this many DLQ replays
): RetryPolicymaxReplayLimit
Every time a message is replayed via vortos:dlq:replay, the x-vortos-global-replays header is incremented. Before invoking the handler, ConsumerRunner compares this counter against maxReplayLimit. If the counter exceeds the limit, the message is hard-discarded — logged at ERROR and silently acknowledged without running any handler or writing another dead letter entry.
This prevents a permanently broken message from cycling through the dead letter store indefinitely:
First processing attempt → fails
Retries exhausted → dead-lettered (x-vortos-global-replays: 0)
vortos:dlq:replay run 1 → re-produced with x-vortos-global-replays: 1
Handler fails again → dead-lettered (replays: 1)
... (repeated up to maxReplayLimit) ...
vortos:dlq:replay run 11 → x-vortos-global-replays: 11 > limit (10)
Hard discard — logged, offset committed, no new DLQ entryThe default limit is 10. Set it higher for messages that are likely to require many manual replay cycles (e.g., during prolonged outages), or lower for events where repeated failure indicates a structural problem that needs code-level intervention.
Kafka Offset and Retry
It is important to understand that Vortos' retry is in-process — it retries the handler directly without re-consuming from Kafka. The Kafka offset is not committed during retries. This means:
- If the worker process crashes mid-retry, Kafka redelivers the message on restart (at-least-once guarantee)
- The retry delay is a PHP
usleep()— the consumer process is blocked during this time - For very long retry delays with many consumers, consider reducing
maxDelayMsand accepting more retries instead
At-Least-Once Delivery
Kafka only knows about the message being processed after the offset is committed. Vortos commits the offset only on success (acknowledge) or after dead-lettering (reject). During retries, the offset remains uncommitted — Kafka will redeliver if the process restarts.
Dead Letter
After all retry attempts are exhausted, DeadLetterWriter::write() is called with the full context of the failure:
$this->deadLetterWriter->write(
transportName: $message->transportName,
eventClass: $message->headers['event_class'],
payload: $message->payload,
headers: $message->headers,
failureReason: $lastException->getMessage(),
exceptionClass: get_class($lastException),
attemptCount: 1 + $retryPolicy->maxAttempts,
);This writes a CRITICAL log entry:
app.CRITICAL: Message dead-lettered {
"transport": "user.events",
"event_class": "App\\User\\Domain\\Event\\UserRegisteredEvent",
"reason": "Connection refused",
"attempts": 4
}After dead-lettering, the message is rejected — $consumer->reject($message, false) — which commits the Kafka offset. The message will not be redelivered by Kafka automatically.
Configuring a DLQ Transport
To route dead-lettered messages to a Kafka DLQ topic, configure a DLQ transport name in your consumer definition:
#[RegisterConsumer]
public function userConsumer(): KafkaConsumerDefinition
{
return KafkaConsumerDefinition::create('user.events')
->groupId('user-service')
->retry(RetryPolicy::exponential(attempts: 3, initialDelayMs: 500))
->dlq('user.events.dlq'); // must be a registered transport name
}And register the DLQ transport:
#[RegisterTransport]
public function userDlqTransport(): KafkaTransportDefinition
{
return KafkaTransportDefinition::create('user.events.dlq')
->dsn('kafka://kafka:9092')
->topic('user.events.dlq')
->partitions(1)
->replicationFactor(1);
}Replaying Dead-Lettered Messages
Once the root cause of failures is fixed, use the CLI command to replay:
php bin/console vortos:dlq:replay --transport=user.eventsOptions:
php bin/console vortos:dlq:replay --limit=100
php bin/console vortos:dlq:replay --transport=user.events
php bin/console vortos:dlq:replay --event-class="App\User\Domain\Event\UserRegisteredEvent"
php bin/console vortos:dlq:replay --id=019d4784-7b26-78a6-ba36-3b74ce5ad751
php bin/console vortos:dlq:replay --failed-from="2026-05-11T10:00:00Z" --failed-to="2026-05-11T10:30:00Z"
php bin/console vortos:dlq:replay --latest --limit=10 --transport=user.events --failed-from="2026-05-11T10:00:00Z" --dry-runReplayed messages are re-dispatched through the same consumer pipeline — they go through deserialization, handler execution, middleware, and retry again as if they were fresh messages.
Observing Failures
All retry and dead letter events are logged at appropriate levels:
| Event | Log Level |
|---|---|
| Handler executed successfully | DEBUG |
| Handler threw exception | ERROR |
| Retry attempt | ERROR (via LoggingMiddleware) |
| Message dead-lettered | CRITICAL |
Monitor your logs for CRITICAL entries containing dead-lettered — these require attention.