Vortos
Messaging

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 false

The 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:

AttemptDelay (no jitter)
1500ms
21000ms
32000ms

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
): RetryPolicy

maxReplayLimit

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 entry

The 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 maxDelayMs and 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.events

Options:

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-run

Replayed 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:

EventLog Level
Handler executed successfullyDEBUG
Handler threw exceptionERROR
Retry attemptERROR (via LoggingMiddleware)
Message dead-letteredCRITICAL

Monitor your logs for CRITICAL entries containing dead-lettered — these require attention.

On this page