<?php
namespace App\Message;
final class SyncCustomerMessage
{
public function __construct(
public readonly int $customerId,
public readonly string $idempotencyKey,
) {
}
}
namespace App\MessageHandler;
use App\Message\SyncCustomerMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlerException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final class SyncCustomerHandler
{
public function __construct(
private readonly HttpClientInterface $crmClient,
) {
}
public function __invoke(SyncCustomerMessage $message): void
{
try {
$response = $this->crmClient->request('POST', '/v2/customers/sync', [
'headers' => ['Idempotency-Key' => $message->idempotencyKey],
'json' => ['id' => $message->customerId],
]);
// Force the request to complete so 5xx/429 surface as exceptions.
$response->getContent();
} catch (ClientExceptionInterface $e) {
$status = $e->getResponse()->getStatusCode();
// Rate limiting is transient; other 4xx are permanent.
if (429 !== $status) {
throw new UnrecoverableMessageHandlerException(
sprintf('CRM rejected customer %d: HTTP %d', $message->customerId, $status),
previous: $e,
);
}
throw $e;
}
}
}
<?php
namespace App\Messenger\Retry;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\RecoverableExceptionInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableExceptionInterface;
use Symfony\Component\Messenger\Retry\RetryStrategyInterface;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
final class ExternalCallRetryStrategy implements RetryStrategyInterface
{
public function __construct(
private readonly int $maxRetries = 5,
private readonly int $delayMs = 1000,
private readonly float $multiplier = 2.0,
private readonly int $maxDelayMs = 60000,
) {
}
public function isRetryable(Envelope $message, ?\Throwable $throwable = null): bool
{
if ($throwable instanceof UnrecoverableExceptionInterface) {
return false;
}
if ($throwable instanceof RecoverableExceptionInterface) {
return true;
}
$retries = $this->retryCount($message);
return $retries < $this->maxRetries;
}
public function getWaitingTime(Envelope $message, ?\Throwable $throwable = null): int
{
$retries = $this->retryCount($message);
$delay = $this->delayMs * $this->multiplier ** $retries;
$delay = (int) min($delay, $this->maxDelayMs);
// Full jitter to avoid synchronized retry storms.
return random_int((int) ($delay / 2), $delay);
}
private function retryCount(Envelope $message): int
{
$stamp = $message->last(RedeliveryStamp::class);
return $stamp instanceof RedeliveryStamp ? $stamp->getRetryCount() : 0;
}
}
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
# Delegate backoff logic to the custom service below.
service: App\Messenger\Retry\ExternalCallRetryStrategy
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
App\Message\SyncCustomerMessage: async
services:
App\Messenger\Retry\ExternalCallRetryStrategy:
arguments:
$maxRetries: 5
$delayMs: 1000
$multiplier: 2.0
$maxDelayMs: 60000
App\MessageHandler\SyncCustomerHandler:
arguments:
$crmClient: '@crm.http_client'
This snippet shows how Symfony Messenger turns a flaky external HTTP call into a durable, self-healing operation using three collaborating pieces: a message and its handler, a custom retry strategy, and transport wiring that routes exhausted messages to a failure queue. The core idea is that transient failures (timeouts, 5xx, rate limits) should not fail permanently — they should be retried with backoff, and only genuinely unrecoverable failures should land in the dead-letter transport for inspection.
In SyncCustomerMessage, the message is a plain immutable DTO carrying just enough context — a customerId and an idempotencyKey. The key exists because retries mean the handler may run more than once for the same logical event; downstream systems must be able to deduplicate. SyncCustomerHandler is a #[AsMessageHandler] that performs the actual HttpClientInterface request. It distinguishes error classes deliberately: a 4xx client error other than 429 is a permanent problem, so it throws UnrecoverableMessageHandlerException, which tells Messenger to skip retries entirely and go straight to failure. Everything else (transport exceptions, 5xx, 429) is allowed to bubble up as an ordinary exception so the retry machinery can take over.
ExternalCallRetryStrategy implements RetryStrategyInterface. Its isRetryable() inspects the throwable to veto retries for unrecoverable errors, and getWaitingTime() computes an exponential backoff with jitter — delay * multiplier^retries capped at maxDelayMs, plus a random component to avoid a thundering herd when many messages fail simultaneously. Honoring Retry-After on 429 responses would be a natural extension here.
messenger.yaml binds it all together: the async transport sets max_retries and names the custom strategy service, while failed is a separate transport acting as the dead-letter queue. When retries are exhausted or an UnrecoverableMessageHandlerException is thrown, the message is serialized to failed where it can be listed, retried, or removed with messenger:failed:* console commands.
The trade-off is added latency and at-least-once delivery semantics, which is exactly why the idempotency key matters. This pattern fits any integration with an unreliable dependency where losing the work is unacceptable but momentary failure is expected.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
CREATE MATERIALIZED VIEW top_sellers AS
SELECT p.id AS product_id,
p.name AS product_name,
Cache-Friendly “Top N” with Materialized View Refresh
Share this code
Here's the card — post it anywhere.