php yaml 136 lines · 3 tabs

Retrying Flaky HTTP Calls with a Symfony Messenger Retry Strategy and Failure Handler

Shared by codesnips Aug 2026
3 tabs
<?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;
        }
    }
}
3 files · php, yaml Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Retrying Flaky HTTP Calls with a Symfony Messenger Retry Strategy and Failure Handler — share card
Link copied