Leçon 6 — Exploiter

Déployer et tester avec 10 documents

7 min de lecture

Les tests unitaires prouvent que le code prend les bonnes décisions face à un faux Pub/Sub et un faux Gemini. Ils ne prouvent pas que la notification part vraiment sur le bon sujet, que l'agent Pub/Sub a le droit de déplacer un message, ni que Gemini répond en moins de deux minutes depuis europe-west1. Ces défauts-là n'apparaissent qu'en vrai. Le test de bout en bout de ce projet tourne sur l'infrastructure réelle, avec des assertions, et produit un rapport lisible par le métier.

Le mécanisme

e2e.py exerce chaque branche du contrat sur Google Cloud :

sequenceDiagram
  participant E as "e2e.py"
  participant GCS as "Bucket entrant/"
  participant PS as "Pub/Sub"
  participant W as "Worker"
  participant BQ as "BigQuery"
  E->>GCS: 10 documents (dont 1 vide)
  E->>PS: 1 message empoisonné (sans eventType)
  GCS->>PS: 10 OBJECT_FINALIZE
  PS->>W: 10 + 1 livraisons
  W->>BQ: 9 ok + 1 rejected
  W-->>PS: 400 pour le message empoisonné (x10)
  E->>BQ: attend 10 lignes
  E->>W: rejeu direct d'un messageId déjà traité
  W-->>E: 200 duplicate
  PS->>PS: 10e échec : déplacement en lettres mortes
  E->>PS: lit la file de lettres mortes
  E->>E: 5 assertions + sorties/rapport.html

Chaque passage dépose ses documents sous un préfixe unique (entrant/<horodatage>-<aléa>/) : deux exécutions ne se mélangent pas, et les assertions ne comptent que les lignes du passage.

Le rejeu appelle le worker directement avec un jeton d'identité (gcloud auth print-identity-token). Il faut donc que votre compte utilisateur ait run.invoker sur le service ; un propriétaire du projet l'a par héritage.

La mise en œuvre

cd files
make setup                 # .venv + dépendances, .env créé depuis .env.example
# dans .env : GOOGLE_CLOUD_PROJECT=<votre projet>
make cloud-up              # ~2 min au premier passage (API, image, service)
make e2e                   # ~6 min

Les assertions finales du scénario :

files/e2e.py (extrait)
    checks = {
        "10 lignes, une par document": len(again) == len(docs),
        "9 analysés, 1 rejeté (vide)": (len(ok), len(rejected)) == (len(docs) - 1, 1),
        "aucun message_id en double": len({r["message_id"] for r in again}) == len(again),
        "rejeu acquitté comme doublon": rep == {"http": 200, "status": "duplicate"},
        "message empoisonné en lettres mortes": bool(dead is not None),
    }

Les requêtes BigQuery du test sont paramétrées et plafonnées à 100 Mo facturés : une erreur de filtre échoue au lieu de lire toute la table.

Résultat obtenu (2026-10-16)

15:17:33 10 documents déposés sous entrant/20261016-151732-1e59/
15:17:34 message empoisonné publié : 22188242576114171
15:17:35 BigQuery : 0/10 ligne(s)
15:17:45 BigQuery : 6/10 ligne(s)
15:17:56 BigQuery : 10/10 ligne(s)
15:17:56   01-facture-carburant.txt             ok       facture              normale   1133 ms
15:17:56   02-facture-pneumatiques.txt          ok       facture              normale    974 ms
15:17:56   03-reclamation-retard.txt            ok       reclamation          haute      969 ms
15:17:56   04-bon-de-livraison.txt              ok       bon_de_livraison     basse     1101 ms
15:17:56   05-contrat-sous-traitance.txt        ok       contrat              normale   1178 ms
15:17:56   06-relance-impaye.txt                ok       relance              haute     1190 ms
15:17:56   07-reclamation-colis-endommage.txt   ok       reclamation          haute      993 ms
15:17:56   08-avoir-transporteur.txt            ok       avoir                normale   1003 ms
15:17:56   09-note-interne.txt                  ok       autre                normale   1129 ms
15:17:56   10-document-vide.txt                 rejected document vide                       ms
15:17:58 rejeu du message 22201064101151603 : {'http': 200, 'status': 'duplicate'}
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 {... 'CloudPubSubDeadLetterSourceDeliveryCount': '10'}
15:23:20 OK   10 lignes, une par document
15:23:20 OK   9 analysés, 1 rejeté (vide)
15:23:20 OK   aucun message_id en double
15:23:20 OK   rejeu acquitté comme doublon
15:23:20 OK   message empoisonné en lettres mortes
15:23:20 rapport : sorties/rapport.html  |  tokens consommés : 4664

Le rapport HTML, ouvert dans un navigateur :

sorties/rapport.html : les 10 documents, leur classement par Gemini, la latence de chaque appel (2026-10-16)

Ce qu'on lit dans ces chiffres : la latence Gemini est stable autour de 1 s par document (sans raisonnement), le délai médian du dépôt à la ligne BigQuery est de 6,3 s, dont environ 1 s de Gemini ; le reste se partage entre la notification, la livraison Pub/Sub, les deux requêtes BigQuery et, pour les premiers documents, le démarrage de l'instance. Les trois documents haute sont ceux que la comptable attendait : la réclamation avec pénalité, la relance avant mise en demeure, le colis endommagé. L'avoir est extrait avec un montant négatif (-768 €), ce que le prompt ne demandait pas : à décider avec le métier, et à figer dans la description du champ.

Ce qui casse en production

  • Test qui passe sur des données d'hier. Sans préfixe unique par passage, un test peut compter les lignes d'une exécution précédente et réussir alors que le pipeline est cassé. Parade : un identifiant de passage dans le chemin et dans les requêtes.
  • Démarrage à froid sous rafale. 300 documents déposés d'un coup : Cloud Run démarre jusqu'à 3 instances, les autres livraisons attendent, certaines dépassent le délai d'acquittement. Symptôme : des duplicate et des delivery_attempt > 1 le matin. Parade : --min-instances=1 aux heures de bureau (facturé en continu), ou accepter quelques secondes de latence.
  • Test e2e qui coûte. Un test qui envoie 500 documents à chaque commit coûte vite plus que la production. Parade : 10 documents choisis pour couvrir chaque branche (type, vide, empoisonné), lancés à la demande ou chaque nuit, pas à chaque commit ; la CI ne lance que make test.
  • Quota partagé. Le quota Gemini du projet est commun à toutes les équipes : un e2e lancé pendant le traitement d'une autre équipe prend des 429. Les réessais l'absorbent ; regardez delivery_attempt avant de conclure à un défaut.

La règle de décision

Un e2e par chemin du contrat (succès, rejet, doublon, lettres mortes), sur l'infrastructure réelle, avec des assertions qui échouent, pas une simple démonstration. Isolez chaque passage par un préfixe, plafonnez ses coûts, et sortez un artefact que le métier peut relire : c'est lui qui valide la qualité des réponses, pas le code.

Pratique

L'e2e échoue sur « 10 lignes attendues, 9 reçues » après 300 s. Les journaux du worker ne montrent que 9 document_traite et aucune erreur. Où regardez-vous ?

Un collègue propose d'ajouter make e2e à la CI sur chaque pull request, avec le projet Google Cloud de l'équipe. Que répondez-vous ?

Exercice. Ajoutez au scénario un 11e document : un fichier .png de 20 Ko. Quelle ligne doit-il produire, et quelles assertions changent ?

Voir la solution

Il doit produire une ligne status=rejected, reason="type non pris en charge : image/png", sans appel Gemini. Dans e2e.py :

  • upload() doit garder le type du fichier (content_type=mimetypes.guess_type(p)[0]) au lieu de forcer text/plain ;
  • docs inclut data/*.png ;
  • l'assertion devient (len(ok), len(rejected)) == (len(docs) - 2, 2).

Vérifiez aussi dans BigQuery que input_tokens est NULL pour cette ligne : c'est la preuve que le rejet a eu lieu avant l'appel.

  • Je lance make cloud-up puis make e2e et je lis chaque assertion.
  • J'isole chaque passage d'un test réel par un préfixe unique.
  • Je lis le rapport avec le métier, pas seulement le code de sortie.
  • Je décide où et quand un e2e coûteux s'exécute.
Suivant