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
| 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
-
RabbitMQ,
Dead Letter Exchanges
et
Quorum Queues
: politiques,
x-death,delivery-limit. - Symfony, Messenger, Retries & Failures : retry, failure transport, commandes.
- Symfony, Handling Decode Failures : changement de 8.1.
- Symfony 7.4, code de Worker et d'AmqpReceiver : rejets après échec et décodage.