Le problème

Le service stock consomme la queue stock.events. Un événement order.order.placed.v1 cite un produit que stock ne connaît pas : son handler échoue à chaque tentative.

Sans limite, ce message tourne en boucle. Il occupe le worker et retarde les messages placés derrière lui. Avec une limite mais sans quarantaine, il disparaît après la dernière tentative.

Il faut un nombre fini de tentatives, puis une quarantaine où le message reste lisible et rejouable. C'est le rôle d'une dead letter queue (DLQ).

La solution

Retry à délai croissant, puis failure transport

Pour un consommateur Symfony Messenger, nous configurons le retry du transport et un failure transport (Symfony 7.4) :

# config/packages/messenger.yaml (service stock)
framework:
  messenger:
    failure_transport: failed
    transports:
      events:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        # options : exchange app.events, queue stock.events
        retry_strategy:
          max_retries: 3
          delay: 1000
          multiplier: 2
      failed: 'doctrine://default?queue_name=failed'

Messenger retente le message après environ 1 s, 2 s puis 4 s. Chaque nouvelle tentative passe par une queue de délai qui le renvoie à stock.events seulement, comme décrit dans notre article sur la topologie RabbitMQ. Après la dernière, le message part dans failed, stocké en base.

La clé failure_transport se règle aussi par transport. Face à une erreur définitive, le handler peut lever UnrecoverableMessageHandlingException : le message part alors en quarantaine sans retry.

Ce que garde la quarantaine

Le failure transport garde le message et ses stamps. ErrorDetailsStamp porte la classe, le code et le message de la dernière exception. SentToFailureTransportStamp nomme le transport d'origine, et les RedeliveryStamp datent les tentatives que le sérialiseur a conservées.

Un sérialiseur personnalisé doit au moins conserver le compteur de tentatives, dans un en-tête relu en RedeliveryStamp. Sans lui, le compteur repartirait de zéro et le message ne sortirait jamais de la boucle.

php bin/console messenger:failed:show           # liste la quarantaine
php bin/console messenger:failed:show 42 -vv    # détail, exception comprise
php bin/console messenger:failed:retry 42       # retraite le message
php bin/console messenger:failed:remove 42      # l'écarte définitivement

Un message rejoué qui échoue encore revient dans failed, trois fois au plus par défaut. Ensuite, Messenger le supprime définitivement.

Rejouer suppose un handler idempotent, sujet de notre article sur le consommateur idempotent.

Les autres langages : la DLX

Supposons que shipping soit écrit dans un autre langage. La quarantaine se règle alors dans RabbitMQ : une dead letter exchange (DLX) reçoit les messages que la queue écarte.

Nous la déclarons par une politique plutôt que par l'argument x-dead-letter-exchange. Une politique se modifie sans redéployer ni recréer la queue.

rabbitmqctl set_policy shipping-dlx '^shipping\.events$' \
  '{"dead-letter-exchange":"app.dead-letters","dead-letter-routing-key":"shipping.events"}' \
  --apply-to queues

L'exchange app.dead-letters et la queue shipping.events.dlq, liée par la clé shipping.events, doivent exister. Sinon, RabbitMQ supprime le message sans bruit. Après ses propres tentatives, le consommateur rejette le message avec requeue=false.

RabbitMQ ajoute alors l'en-tête x-death : queue d'origine, raison (rejected, expired, maxlen, delivery_limit), compteur et date. Les en-têtes x-first-death-queue et x-first-death-reason gardent le premier passage.

Depuis RabbitMQ 4.0, une queue quorum fixe delivery-limit à 20 par défaut. Un message relivré plus souvent, par exemple après des plantages du worker, est supprimé, ou envoyé à la DLX si elle existe.

Le piège

Deux niveaux de quarantaine : Messenger dans l'application, la DLX dans le broker Niveau application : service stock, consommateur Symfony Messenger stock.events queue worker Messenger le handler échoue tentatives restantes queue de délai 1 s, 2 s puis 4 s nouvelle tentative après max_retries failed failure transport message, exception et dates d'échec messenger:failed:* Niveau broker : service shipping, consommateur écrit dans un autre langage shipping.events queue consommateur rejette, requeue=false politique dead-letter-exchange app.dead-letters dead letter exchange shipping.events.dlq message + en-tête x-death L'exception n'y figure pas : le consommateur la journalise. À éviter : une DLX sur stock.events en plus du failure transport Messenger rejette l'original après chaque échec : la DLQ recevrait une copie par tentative.

DLX RabbitMQ Failure transport Messenger
Niveau broker application
Déclencheur rejet, TTL, delivery-limit échec après max_retries
Contenu message et en-tête x-death message, exception, dates d'échec
Rejeu à outiller messenger:failed:retry
Consommateurs tous langages Symfony Messenger

Empiler les deux niveaux sur une même queue produit des doublons. Après chaque échec, Messenger republie une copie, puis rejette l'original sans remise en file. Une DLX sur stock.events recevrait une copie par tentative : quatre avec max_retries: 3.

À l'inverse, en Symfony 7.4, un message que le sérialiseur ne sait pas décoder est rejeté avant tout retry. Il n'atteint jamais failed : sans DLX, RabbitMQ le supprime. Depuis Symfony 8.1, il suit le chemin normal, retry puis failure transport.

Notre recommandation : un seul niveau de quarantaine par queue. Le failure transport pour les consommateurs Messenger, la DLX pour les autres langages.

En Symfony 7.4, ce choix perd les messages illisibles, que la validation par schéma chez nos producteurs réduit. Pour les garder : Symfony 8.1 (même sérialiseur sur failed) ou une DLX d'archive sur stock.events, jamais rejouée en bloc.

Sources