Un mardi à 9 h, le quota Vertex AI partagé par trois équipes sature pendant quatre minutes. Avec la politique de réessai par défaut d'un abonnement push, Pub/Sub relivre immédiatement, le worker reçoit à nouveau un 429, et la boucle consomme les instances Cloud Run sans rien produire. Le même jour, un message mal formé publié par un script de test tourne en rond toute la journée. Il faut deux choses distinctes : des réessais qui laissent au système le temps de récupérer, et une sortie pour les messages qui ne passeront jamais.
Le mécanisme
Le projet empile trois niveaux, du plus rapide au plus lent :
| Niveau | Où | Réglage | Absorbe |
|---|---|---|---|
| 1. Dans la requête | client google-genai (HttpRetryOptions) | 3 tentatives, 2 s puis 4 s | un 429 ou un 503 de quelques secondes |
| 2. Par Pub/Sub | abonnement push | délai exponentiel de 10 s à 60 s, 10 livraisons | une indisponibilité de quelques minutes, un conflit BigQuery |
| 3. Lettres mortes | sujet lab-pipeline-evt-dlq + abonnement de lecture (7 jours) | après la 10e livraison | ce qui ne passera jamais sans intervention |
stateDiagram-v2 [*] --> Livraison Livraison --> Acquitté: 200 Livraison --> Attente: 400 / 500 / délai dépassé Attente --> Livraison: 10 s, 20 s, 40 s, 60 s, 60 s… Attente --> LettresMortes: 10e échec LettresMortes --> Rejeu: correction, puis republication Acquitté --> [*]
Le niveau 1 est court à dessein : chaque seconde d'attente dans la requête occupe une des 4 places de l'instance. Au-delà, mieux vaut rendre la main à Pub/Sub, qui attend sans rien consommer.
Pour router vers les lettres mortes, Pub/Sub agit sous son agent de service (service-<numéro>@gcp-sa-pubsub.iam.gserviceaccount.com). Il lui faut deux droits, faute de quoi le message n'est jamais déplacé et continue à être relivré, sans erreur visible :
roles/pubsub.publishersur le sujet de lettres mortes ;roles/pubsub.subscribersur l'abonnement d'origine, pour acquitter le message qu'il déplace.
Un message déplacé reçoit un nouvel ID ; il garde ses attributs et en gagne quatre : abonnement et projet d'origine, nombre de livraisons, heure de publication d'origine.
La mise en œuvre
PUSH_OPTS=(--push-endpoint="$URL/" --push-auth-service-account="$PUSH_SA" --ack-deadline=120
--dead-letter-topic="$P-dlq" --max-delivery-attempts=10 --min-retry-delay=10s --max-retry-delay=60s)
if $G pubsub subscriptions describe "$P-push" >/dev/null 2>&1; then
$G pubsub subscriptions update "$P-push" "${PUSH_OPTS[@]}"
else
$G pubsub subscriptions create "$P-push" --topic="$P-docs" --labels="$LABEL" "${PUSH_OPTS[@]}"
fi
# L'agent de service Pub/Sub doit pouvoir republier vers les lettres mortes et acquitter l'original.
$G pubsub topics add-iam-policy-binding "$P-dlq" --member="serviceAccount:$PUBSUB_AGENT" \
--role=roles/pubsub.publisher >/dev/null
$G pubsub subscriptions add-iam-policy-binding "$P-push" --member="serviceAccount:$PUBSUB_AGENT" \
--role=roles/pubsub.subscriber >/dev/nullLe sujet de lettres mortes a son propre abonnement, lab-pipeline-evt-dlq-sub, avec 7 jours de rétention : un message publié sur un sujet sans abonnement est perdu.
Le niveau 1, dans gcp.py :
RETRY = types.HttpOptions(retry_options=types.HttpRetryOptions(
attempts=3, initial_delay=2.0, max_delay=8.0, http_status_codes=[429, 500, 503]))Le test e2e publie un message empoisonné, sans attribut eventType, que le worker refuse en 400 à chaque livraison, puis attend qu'il apparaisse dans la file de lettres mortes. Il le reconnaît à son attribut run, puisque l'ID a changé.
Résultat obtenu (2026-10-16)
15:17:34 message empoisonné publié : 22188242576114171
15:18:18 lettres mortes : pas encore arrivé (10 tentatives, délai de 10 à 60 s)
...
15:23:19 lettres mortes : message 22188242576114171 reçu, attributs {'nouvel_id': '22035090386617531',
'CloudPubSubDeadLetterSourceSubscription': 'lab-pipeline-evt-push', 'origine': 'e2e',
'CloudPubSubDeadLetterSourceTopicPublishTime': '2026-10-16T13:17:34.17+00:00',
'CloudPubSubDeadLetterSourceSubscriptionProject': '<projet>', 'run': '20261016-151732-1e59',
'CloudPubSubDeadLetterSourceDeliveryCount': '10'}
15:23:20 OK message empoisonné en lettres mortesDix livraisons en 5 min 45 s, puis déplacement. Côté worker, les journaux comptent exactement dix lignes message_empoisonne pour ce passage : aucune livraison de plus, aucune de moins.
Ce qui casse en production
- Droits de l'agent Pub/Sub manquants. Symptôme :
CloudPubSubDeadLetterSourceDeliveryCountn'apparaît jamais, le message est relivré bien au-delà de 10 fois, la console signale un problème de permission sur la lettre morte. Parade : les deux liaisons ci-dessus, dans le script, pas à la main. - File de lettres mortes que personne ne lit. Elle se remplit en silence pendant des semaines, puis la rétention efface les messages. Parade : une alerte Cloud Monitoring sur
pubsub.googleapis.com/subscription/num_undelivered_messagesdelab-pipeline-evt-dlq-subsupérieur à 0, et une procédure de rejeu écrite. - Rejeu sans correction. Republier les messages de la file vers le sujet principal avant d'avoir corrigé la cause les renvoie dix fois en échec, puis en lettres mortes à nouveau. Parade : rejouer un message, vérifier sa ligne BigQuery, puis le reste.
- Réessai qui amplifie la panne. Pendant une saturation du quota, 300 documents en attente reviennent tous en même temps à chaque palier. Parade :
--max-instances=3borne la concurrence vers Gemini à 12 requêtes ; le délai exponentiel de Pub/Sub étale les retours. - Lettres mortes et redéploiement. Un worker déployé avec un bogue qui répond 500 à tout envoie toute la production du jour en lettres mortes en moins de six minutes. Parade : déploiement progressif (
--no-trafficpuis bascule), et une alerte sur le taux de 5xx.
La règle de décision
Réessai court dans la requête pour les erreurs de quelques secondes, réessai Pub/Sub à délai exponentiel pour les minutes, lettres mortes au-delà. Réglez max-delivery-attempts pour que la durée totale de réessai couvre la plus longue panne passagère acceptable (ici environ 6 minutes) ; une panne plus longue relève d'une intervention, pas d'un réessai. Toute file de lettres mortes a un abonnement, une alerte et une procédure de rejeu.
Pratique
Une panne Vertex AI dure 25 minutes. Avec la configuration du projet, que deviennent les documents déposés au début de la panne ?
10 livraisons avec un délai de 10 à 60 s couvrent environ 6 minutes, comme mesuré. Au-delà, les messages partent en lettres mortes, d'où ils sont rejoués après la panne : rien n'est perdu, mais il faut agir. Si ces pannes longues sont fréquentes, augmentez max-delivery-attempts (jusqu'à 100) plutôt que de transformer l'erreur en rejet définitif.
La file de lettres mortes contient 40 messages dont l'attribut CloudPubSubDeadLetterSourceDeliveryCount vaut 10. Tous datent de 14 h 02 à 14 h 07, juste après un déploiement. Par quoi commencez-vous ?
Une rafale juste après un déploiement désigne la nouvelle révision. Rejouer avant de corriger renverrait les 40 messages en échec. Augmenter les tentatives n'aurait fait que retarder le déplacement. Purger perdrait 40 documents : aucun n'a de ligne ok, sinon le worker aurait répondu duplicate, donc 200.
Exercice. Écrivez le script de rejeu : lire les messages de lab-pipeline-evt-dlq-sub, les republier sur lab-pipeline-evt-docs avec leurs attributs d'origine, et n'acquitter dans la file de lettres mortes qu'après la republication réussie.
Voir la solution
from google.cloud import pubsub_v1
PROJECT = "mon-projet"
sub, pub = pubsub_v1.SubscriberClient(), pubsub_v1.PublisherClient()
src = sub.subscription_path(PROJECT, "lab-pipeline-evt-dlq-sub")
dst = pub.topic_path(PROJECT, "lab-pipeline-evt-docs")
while True:
resp = sub.pull(request={"subscription": src, "max_messages": 10}, timeout=30)
if not resp.received_messages:
break
for m in resp.received_messages:
attrs = {k: v for k, v in m.message.attributes.items() if not k.startswith("CloudPubSubDeadLetter")}
pub.publish(dst, m.message.data, **attrs).result() # attend la confirmation
sub.acknowledge(request={"subscription": src, "ack_ids": [m.ack_id]})Le message republié reçoit un nouvel ID : si le document avait été traité entre-temps, la déduplication par messageId ne le verra pas. C'est acceptable ici parce qu'un message n'arrive en lettres mortes qu'après dix échecs, donc sans ligne ok. Avec une clé object_uri (exercice de la leçon 2), le rejeu deviendrait sûr dans tous les cas.
- Je règle les trois niveaux de réessai et je sais ce que chacun absorbe.
- J'accorde à l'agent de service Pub/Sub les deux droits de la file de lettres mortes.
- Je calcule la durée couverte par une politique de réessai.
- J'ai une alerte et une procédure de rejeu pour la file de lettres mortes.