Aller au contenu
English

Effets de bord et nouvelles tentatives

  • Leçon 12
  • 55 min
  • Module 4 : Approbation humaine et reprise

Ce que vous saurez faire

Faites chaque écriture approuvée exactement une fois, même si un appel expire ou si le processus plante pendant l’appel.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 55 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet après Point de contrôle et reprise. python -m pytest affiche 47 passed (47 tests réussis).
  • Une base de données d’exercice neuve : exécutez python -m resolver reset --all une fois avant de commencer.

Cette leçon ajoute deux fichiers de tests. Votre projet correspond ensuite à l’instantané end-of-m04. Les services simulés simulent chaque expiration de délai et chaque plantage. Aucun paiement réel n’est effectué.

Vous payez votre épicerie avec votre carte. Vous la présentez au terminal de paiement, qui se fige. Après dix secondes, il affiche « No response » (aucune réponse). La file est longue, et la caissière dit : « Présentez-la de nouveau. »

Le premier paiement a-t-il passé? La banque a peut-être pris l’argent, mais la réponse s’est perdue. La requête n’a peut-être jamais quitté le magasin. L’écran ne permet pas de le savoir. Si vous présentez la carte de nouveau et que le premier paiement a passé, vous payez deux fois.

Une caissière prudente fait autre chose d’abord. Elle vérifie le registre de la caisse ou votre application bancaire. Un paiement de ce montant est-il arrivé il y a une minute? Si oui, c’est terminé. Sinon, vous présentez la carte de nouveau.

Pensez maintenant à un bouton d’ascenseur. Vous appuyez, rien ne semble se passer, et vous appuyez cinq autres fois. Un seul ascenseur arrive. Vous pouvez appuyer de nouveau sans risque, car deux pressions ont le même effet qu’une seule. Ce n’est pas le cas du terminal de paiement.

Le problème : « Si l’appel de remboursement expire, refaites simplement l’appel »

Section intitulée « Le problème : « Si l’appel de remboursement expire, refaites simplement l’appel » »

Tomás examine l’exécuteur. « Les services de paiement dépassent parfois le délai prévu, dit-il. Si l’appel de remboursement expire, refaites simplement l’appel. Une nouvelle tentative ne coûte rien. »

Essayons avec T-90104 : un arrosoir, en retard de 12 jours ouvrables, avec des frais de livraison de 9,95 $. Le modèle chat-strong (gpt-6.1-sol sur Azure) propose le bon remboursement selon W2. Le projet peut simuler une expiration du délai après l’écriture : le service de paiement effectue le remboursement, puis la réponse ne revient jamais. L’option --naive-retry fait ce que Tomás suggère : refaire l’appel comme une nouvelle requête, sans vérifier.

Fenêtre de terminal
python -m resolver run T-90104 --variant agent_structured --model chat-strong
python -m resolver approve T-90104-agent_structured-chat-strong-1 --as agent --fault request_refund:timeout_after_write --naive-retry
request_refund LK-793996: done (operation 797fbb5bbb9f8a7e, 2 call(s))
Status: done

La sortie de run (exécuter) n’est pas montrée : c’est la demande d’approbation pour request_refund LK-793996 9.95 CAD RFD-LATE. « Done » (terminé) semble rassurant. Regardez maintenant dans la base de données d’exercice :

Fenêtre de terminal
python -m resolver changes
2026-10-09T17:00:53 T-90104 request_refund LK-793996 9.95 RFD-LATE (operation 797fbb5bbb9f8a7e)
2026-10-09T17:00:53 T-90104 request_refund LK-793996 9.95 RFD-LATE (operation 797fbb5bbb9f8a7e-retry1)
2 change(s) in the practice database

Le client a reçu deux fois le remboursement des frais. La politique de Grace dit : « Ne jamais rembourser deux fois pour la même raison sur la même commande. » Le flux de travail l’a enfreinte avec une seule approbation. La trace montre comment :

2 approval approved by agent (agent)
2 write request_refund op 797fbb5bbb9f8a7e -> timeout
2 write request_refund op 797fbb5bbb9f8a7e-retry1 -> done
2 stop done

Seule la fin de python -m resolver trace T-90104 est montrée. Le premier appel a expiré, mais le remboursement avait eu lieu. La nouvelle tentative était une nouvelle requête. Le service de paiement l’a donc traitée comme un nouveau remboursement. Le service vérifie seulement que le total ne dépasse pas le montant payé par le client. Or, 19,90 est inférieur à ce montant. Il ne peut pas savoir que les deux remboursements représentent la même chose.

L’idée : nommer chaque changement et vérifier avant de le répéter

Section intitulée « L’idée : nommer chaque changement et vérifier avant de le répéter »

Une expiration du délai donne un résultat incertain : le changement peut avoir eu lieu ou non. Deux idées rendent une écriture sûre malgré cette incertitude.

La première est un identifiant d’opération : un nom pour un changement prévu, identique à chaque tentative. Le service conserve un registre des identifiants qu’il a traités. Si le même identifiant revient, le service retourne le premier résultat et ne change rien. Une écriture qui donne le même résultat quand vous la répétez est idempotente, comme le bouton d’ascenseur. Le mot vient du latin : « la même puissance ». Beaucoup de services de paiement réels acceptent un tel identifiant. Ils l’appellent souvent une clé d’idempotence.

La deuxième est la réconciliation : après un résultat incertain, demandez au service ce qui s’est passé avant d’agir de nouveau. C’est la caissière qui vérifie le registre de la caisse. Dans ce projet, world.operation(op_id) retourne le résultat enregistré pour cette opération, ou rien.

D’où vient l’identifiant? Pas d’un nombre aléatoire, car un nouveau processus en créerait un autre. Le projet le calcule à partir du billet de soutien et de l’action exacte :

resolver/execute.py (part)
def operation_id(task_id: str, action) -> str:
text = json.dumps({"ticket": task_id, **action.model_dump()}, sort_keys=True)
return hashlib.sha256(text.encode()).hexdigest()[:16]

SHA-256 transforme tout texte en une empreinte de longueur fixe. Le même texte donne toujours la même empreinte. Le projet conserve les 16 premiers caractères. Essayez dans Python, dans le dossier du projet, après l’exécution ci-dessus :

from resolver.checkpoint import CheckpointStore
from resolver.execute import operation_id
state = CheckpointStore().load("T-90104-agent_structured-chat-strong-1")
action = state.proposal.actions[0]
print(action.model_dump())
print(operation_id("T-90104", action))
print(operation_id("T-90103", action))
print(operation_id("T-90104", action.model_copy(update={"amount": 19.90})))
{'tool': 'request_refund', 'order_id': 'LK-793996', 'amount': 9.95, 'reason_code': 'RFD-LATE', 'payment_id': None}
797fbb5bbb9f8a7e
1f9d2528fdce4b31
af291d28b738e056

Le même billet et la même action donnent toujours 797fbb5bbb9f8a7e. Cela vaut dans ce processus, après un plantage, lors d’une deuxième exécution ou sur un autre ordinateur. Un billet différent ou un montant différent représente un changement différent et reçoit donc un identifiant différent. C’est pourquoi les identifiants d’opération de Limites d’approbation étaient les mêmes sur votre ordinateur et dans la leçon.

Réinitialisez la base de données d’exercice et les exécutions enregistrées avec python -m resolver reset --all. Exécutez ensuite le même billet avec la même expiration simulée du délai, sans --naive-retry :

Fenêtre de terminal
python -m resolver run T-90104 --variant agent_structured --model chat-strong
python -m resolver approve T-90104-agent_structured-chat-strong-1 --as agent --fault request_refund:timeout_after_write
python -m resolver changes
request_refund LK-793996: done (operation 797fbb5bbb9f8a7e, 1 call(s))
Status: done
2026-10-09T17:00:54 T-90104 request_refund LK-793996 9.95 RFD-LATE (operation 797fbb5bbb9f8a7e)
1 change(s) in the practice database

Un remboursement, un appel. La fin de la trace montre pourquoi :

2 write request_refund op 797fbb5bbb9f8a7e -> timeout
2 reconcile request_refund op 797fbb5bbb9f8a7e -> found: it happened
2 stop done

Après l’expiration du délai, l’exécuteur a interrogé le service au sujet de 797fbb5bbb9f8a7e. Le service l’a trouvé. L’exécuteur a donc enregistré le résultat sans refaire l’appel. Le billet T-90603 contient la même défaillance simulée, pour un montant de 45,95 $ facturé deux fois. Chaque conception qui utilise un modèle Azure l’a traitée avec un seul remboursement, dans toutes les exécutions enregistrées.

Le délai peut aussi expirer avant que l’écriture atteigne le service. T-90604, un escabeau à retourner, contient cette défaillance simulée. Exécutez-le et approuvez-le :

Fenêtre de terminal
python -m resolver run T-90604
python -m resolver approve T-90604-agent-chat-small-1 --as agent
create_return_label LK-645334: done (operation 2ed3b2a93ab0f494, 2 call(s))
Status: done
2 write create_return_label op 2ed3b2a93ab0f494 -> timeout
2 reconcile create_return_label op 2ed3b2a93ab0f494 -> not found
2 write create_return_label op 2ed3b2a93ab0f494 -> done

Cette fois, le service n’avait aucune trace de l’identifiant. L’exécuteur a donc refait l’appel avec le même identifiant. Deux appels, une étiquette. Si le premier appel était finalement arrivé en retard, le service aurait répondu au deuxième appel avec le premier résultat.

L’exécuteur suit la même règle pour chaque action approuvée. Lisez à partir du haut :

Le déroulement d'une action approuvée. Son identifiant d'opération est le SHA-256 du billet et de l'action. Est-elle déjà dans le point de contrôle? Si elle est terminée, passer à terminé. Sinon, enregistrer l'opération comme en attente (écriture préalable). Si elle est en attente avec un appel enregistré, demander au service si l'opération a eu lieu : oui mène à terminé, non mène à l'enregistrement comme en attente. Appeler ensuite le service avec le même identifiant, au plus 3 fois. La réponse : terminé mène à terminé; une erreur 503 entraîne un nouvel appel; un refus mène à une personne, sans changement effectué; une expiration du délai signifie que le résultat est inconnu. Demander alors au service si l'opération a eu lieu : oui mène à terminé, non entraîne un nouvel appel. Après 3 appels sans résultat terminé, une personne prend le relais.
Ce que fait execute() pour chaque action approuvée : enregistrer d'abord, appeler avec le même identifiant et, après une réponse incertaine, interroger le service avant de refaire l'appel.
Description textuelle du diagramme
  1. Calculez l’identifiant d’opération à partir du billet et de l’action.
  2. Si le point de contrôle indique que l’opération est terminée, sautez-la. Si elle est en attente avec un appel enregistré, demandez au service si l’opération a eu lieu. Si oui, enregistrez-la comme terminée.
  3. Sinon, enregistrez l’opération comme en attente avant l’appel. C’est un enregistrement préalable.
  4. Appelez le service avec le même identifiant, au plus 3 fois.
  5. Si la réponse indique que l’opération est terminée, enregistrez-la. Si le service est indisponible (503), refaites l’appel. S’il refuse, arrêtez : une personne prend le relais, et rien n’a changé.
  6. Si le délai de l’appel expire, demandez au service si l’opération a eu lieu. Si oui, enregistrez-la comme terminée. Sinon, refaites l’appel avec le même identifiant.

L’étape 3 est l’enregistrement préalable. L’exécuteur enregistre « en attente, tentative 1 » dans le point de contrôle avant l’appel. Un plantage pendant l’appel laisse donc une trace de la tentative. La reprise sait alors qu’elle doit interroger le service d’abord. Sans cet enregistrement, la reprise penserait qu’aucune tentative n’a eu lieu.

Toutes les erreurs ne justifient pas une nouvelle tentative. Une expiration du délai et une erreur 503 concernent la connexion. Le même appel peut donc fonctionner plus tard. Un refus est la réponse du monde extérieur : l’entrepôt refuse la réexpédition de T-90605 (simulation). Refaire l’appel ne peut pas changer cela. L’exécution passe à une personne sans effectuer de changement. Après 3 appels sans succès, une personne prend aussi le relais.

Combinez maintenant cela avec le point de contrôle de la dernière leçon. Un plantage peut survenir avant l’écriture, pendant l’appel ou après l’écriture. L’explorateur rejoue les trois cas sur T-90104, avec une reprise qui conserve l’identifiant d’opération et une qui ne le conserve pas.

Explorer

Arrêter le processus autour d'un remboursement, puis le reprendre

Remboursements dans la base
2
Le client reçoit
19,90 $ CA
Appels au service
2

2 remboursements de 9,95 $ CA : le client est payé 2 fois. Le remboursement a eu lieu, puis le processus s’est arrêté. Le dossier dit encore « not done », et sans ID il n’y a rien à demander. Donc la reprise l’envoie de nouveau.

  • un appel qui a écrit dans la base de données d’exercice
  • une panne ou un délai dépassé simulé
  • rapprochement : interroger le service par ID d’opération
Arrêt : après l’écriture. Reprise : sans ID (une nouvelle requête).
#PartieCe qui se passe
1écriturerequest_refund 9.95, request request-1: the payment service makes the refund (RF-19F1064B)
2arrêtthe process stops after the write, before it was recorded (simulated crash)
3point de reprisethe record says: refund not done (no ID to ask about)
4repriseresume: the refund is not done, so send it
5écriturerequest_refund 9.95, request request-2: the payment service makes the refund (RF-6FC63565)
6finstatus: done

Remboursements pour LK-793996 dans la base de données d’exercice à la fin :

  • RF-19F1064B 9,95 $ CA RFD-LATE, requête request-1
  • RF-6FC63565 9,95 $ CA RFD-LATE, requête request-2

Billet T-90104 : la proposition est la décision enregistrée de chat-strong (gpt-6.1-sol on Azure), agent_structured, repeat 1, approuvée par Amira (agent). Chaque panne et chaque délai dépassé est simulé sur la base de données d’exercice. « Sans ID » est un client construit pour l’exemple; pour un délai dépassé, c’est le --naive-retry du projet lui-même.

T-90104 : un remboursement approuvé de 9,95, un plantage ou une expiration du délai simulés, puis la reprise. Chaque ligne provient de la fonction execute() du projet sur une base de données d'exercice neuve; le client sans identifiant est construit pour l'exemple.
Description textuelle de l’explorateur

L’explorateur a deux commandes. La première choisit où le processus s’arrête. Les choix sont avant l’écriture, pendant l’appel (expiration du délai après le remboursement), et après l’écriture, avant son enregistrement. La deuxième choisit comment la reprise appelle le service de paiement : avec l’identifiant d’opération ou sans identifiant, comme une nouvelle requête. L’explorateur affiche le nombre de remboursements dans la base de données et le montant reçu par le client. Il affiche aussi le nombre d’appels et un tableau des événements.

Avant l’écriture : avec ou sans identifiant, un remboursement de 9,95, car rien n’a été écrit avant le plantage.

Pendant l’appel : avec l’identifiant d’opération, l’exécuteur interroge le service, trouve le remboursement et l’enregistre : un remboursement, un appel. Sans identifiant, la nouvelle tentative est une nouvelle requête : deux remboursements, 19,90.

Après l’écriture : avec l’identifiant d’opération, le point de contrôle conserve l’opération comme en attente avec 1 appel. La reprise interroge donc le service d’abord et trouve le remboursement : un remboursement. Sans identifiant, l’enregistrement indique « non terminé », et il n’y a rien à vérifier auprès du service. La reprise envoie donc la requête de nouveau : deux remboursements, 19,90.

Deux des six cas paient deux fois. Les deux sont « sans identifiant », après que le changement a eu lieu. Avant l’écriture, un plantage est sans conséquence dans les deux cas. Le danger n’est pas le plantage lui-même. C’est de ne pas savoir si le changement a eu lieu.

Essayez le cas « après l’écriture » dans votre propre projet. L’option --interrupt after-write simule un plantage juste après que le service a effectué le remboursement :

Fenêtre de terminal
python -m resolver reset --all
python -m resolver run T-90104 --variant agent_structured --model chat-strong
python -m resolver approve T-90104-agent_structured-chat-strong-1 --as agent --interrupt after-write
python -m resolver resume T-90104-agent_structured-chat-strong-1
python -m resolver changes
The process stopped: simulated crash after the write of 797fbb5bbb9f8a7e. Resume with: python -m resolver resume T-90104-agent_structured-chat-strong-1
Resuming T-90104-agent_structured-chat-strong-1 (status executing)
request_refund LK-793996: done (operation 797fbb5bbb9f8a7e, 1 call(s))
Status: done
2026-10-09T17:00:55 T-90104 request_refund LK-793996 9.95 RFD-LATE (operation 797fbb5bbb9f8a7e)
1 change(s) in the practice database

La sortie de run n’est pas montrée. La trace se termine par note interrupted (note : interrompu), puis reconcile … found: it happened (réconciliation… trouvé : l’opération a eu lieu). La reprise n’a pas appelé le service de nouveau. Elle n’a pas non plus demandé une nouvelle approbation, car l’approbation est dans le point de contrôle avec son empreinte.

Erreur courante

« Si l'appel de remboursement expire, refaites simplement l'appel »

Symptôme : après une approbation du remboursement de frais de 9,95 pour T-90104, changes (changements) montre deux remboursements de 9,95. Ce sont les opérations 797fbb5bbb9f8a7e et 797fbb5bbb9f8a7e-retry1. L’exécution indique done (… 2 call(s)) (terminé, … 2 appels). L’expiration du délai est simulée (--fault request_refund:timeout_after_write), et la nouvelle tentative est le raccourci de Tomás (--naive-retry).

Cause : le premier appel a effectué le remboursement, et seule la réponse s’est perdue. La nouvelle tentative n’a pas vérifié cela. C’était une nouvelle requête avec un nouvel identifiant, donc le service a effectué un deuxième remboursement.

Diagnostic : dans la trace, cherchez un write … -> timeout (écriture… délai expiré) suivi d’un deuxième write (écriture), sans reconcile (réconciliation) entre les deux. Dans la base de données, cherchez deux changements issus d’une seule approbation, avec la même commande, le même montant et la même raison.

Correction : donnez à chaque écriture un identifiant d’opération qui reste identique à chaque tentative. Faites conserver au service un registre des identifiants. Après une expiration du délai, faites une réconciliation avant de refaire l’appel. tests/test_execute.py conserve le raccourci de Tomás comme test, test_just_calling_it_again_refunds_twice, pour que personne ne le réintroduise par accident.

Les cadres logiciels offrent les idées que vous avez construites en Python simple. LangGraph, une bibliothèque connue pour l’orchestration d’agents, propose un graphe d’état et un gestionnaire de points de contrôle SQLite. Il propose aussi interrupt() pour attendre une personne. Nous avons exécuté les nœuds du cours dans un graphe LangGraph 1.2.14, avec les décisions enregistrées de chat-small. Il a proposé la même chose que la boucle simple pour les 70 billets sur 70. Sur T-90103, il s’est arrêté à l’approbation, et les deux écritures ont eu lieu seulement après la reprise.

La documentation de LangGraph énonce une règle importante ici : à la reprise, un nœud interrompu s’exécute de nouveau depuis le début. Une écriture placée avant interrupt() dans le même nœud s’exécute donc deux fois. Nous avons testé un nœud qui effectue un remboursement de 9,95, puis appelle interrupt() :

L’écriture dans le nœud Exécutions du nœud Remboursements
un nouvel identifiant à chaque appel 2 2
l’identifiant d’opération du cours (billet et action) 2 1

Le cadre logiciel vous fournit des points de contrôle et des pauses. Il ne rend pas une écriture sûre à répéter; c’est l’identifiant d’opération qui le fait. Le cadre logiciel a aussi un coût : 43 paquets et 44 Mo dans un environnement neuf. Le projet demande 19 paquets et 31 Mo (mesurés avec uv; pip seul augmente les deux tailles). Le script de comparaison, graph.py, se trouve dans le dépôt d’exercices, dans C10/optional/langgraph. Il est facultatif et ne fait pas partie du projet. Il s’exécute dans son propre environnement virtuel, sur l’instantané end-of-m04, sans clé. Le README des exemples facultatifs montre comment le configurer. Les cadres logiciels pour agents changent vite. Vérifiez donc la documentation actuelle de celui que vous choisissez.

Quand utiliser les identifiants d’opération, et leur coût

Section intitulée « Quand utiliser les identifiants d’opération, et leur coût »

Utilisez-les pour chaque écriture qui produit un effet de bord : argent, marchandises, messages, enregistrements dans un autre système. Une lecture n’en a pas besoin : lire deux fois ne change rien.

Ils ont des coûts :

  • Le service doit aider. Il lui faut un registre d’identifiants et un moyen d’interroger une opération. Beaucoup de services de paiement offrent une clé d’idempotence. Si un service n’offre ni l’un ni l’autre, placez le registre dans votre propre base de données. Faites ensuite la réconciliation avec la liste des changements récents du service.
  • L’identifiant doit nommer la bonne chose. C’est le billet plus l’action exacte. Si deux remboursements différents sur un même billet sont tous deux valides, leurs actions doivent différer, par exemple par le code de raison. Si le montant change, l’identifiant et l’empreinte d’approbation changent aussi. C’est un nouveau changement qui exige une nouvelle approbation.
  • La réconciliation prend du temps. Chaque résultat incertain coûte un appel supplémentaire au service. Ici, c’est une recherche dans SQLite.
  • Le registre grossit. Conservez les identifiants aussi longtemps qu’une nouvelle tentative ou une reprise peut survenir. Dans ce projet, ils restent dans la base de données d’exercice.
  1. Vérifiez votre point de départ :

    Fenêtre de terminal
    python -m pytest

    La commande affiche 47 passed.

  2. Copiez les deux derniers fichiers de tests de l’instantané end-of-m04 :

    Fenêtre de terminal
    cp ../nextia-learning-labs/C10/snapshots/end-of-m04/tests/{test_systems,test_execute}.py tests/

    Sur Windows, dans PowerShell : foreach ($f in 'test_systems','test_execute') { Copy-Item "..\nextia-learning-labs\C10\snapshots\end-of-m04\tests\$f.py" tests\ }.

    tests/test_systems.py vérifie les services simulés. Le même identifiant d’opération produit une seule écriture. Une expiration du délai après l’écriture cache un changement, et operation() le trouve. Les services refusent un remboursement supérieur au montant payé.

    tests/test_execute.py vérifie que vous avez terminé ce module. Voici les noms de ses tests :

    tests/test_execute.py (the test names)
    def test_interrupt_before_the_write_then_resume(world): ...
    def test_interrupt_after_the_write_then_resume_reconciles(world): ...
    def test_a_timeout_after_the_write_is_reconciled_not_repeated(world): ...
    def test_just_calling_it_again_refunds_twice(world): ...
    def test_a_timeout_before_the_write_is_retried_with_the_same_id(world): ...
    def test_a_service_that_is_down_once_is_retried(world): ...
    def test_a_refused_write_goes_to_a_person_with_no_change(world): ...
    def test_running_the_same_approved_proposal_twice_changes_nothing_more(world): ...
  3. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La commande affiche 60 passed (60 tests réussis). Votre projet correspond maintenant à l’instantané end-of-m04.

  4. Exécutez de nouveau les exemples de cette leçon avec et sans --naive-retry. Comparez changes chaque fois.

Exercice du module : interrompre une écriture, puis la reprendre

Section intitulée « Exercice du module : interrompre une écriture, puis la reprendre »

Écrivez tests/test_module4_practice.py pour T-90604, le retour de l’escabeau. L’action approuvée crée une seule étiquette de retour : create_return_label LK-645334 LADDER-3 change_of_mind. Écrivez trois vérifications :

  1. Interrompez l’exécution approuvée avant l’écriture, puis reprenez-la deux fois. Il doit y avoir exactement une étiquette.
  2. Faites la même chose avec une interruption après l’écriture.
  3. Essayez d’exécuter la proposition sans approbation, puis après un refus. Les deux tentatives doivent être refusées, sans aucun changement.

Utilisez proposed() de tests/test_approval.py, la fixture (données de test préparées) world, decide() et execute() avec interrupt=.

Afficher un exemple de réponse
tests/test_module4_practice.py
"""Module 4 practice: interrupt a simulated task before and after a mock write, then resume.
The crashes are SIMULATED (`interrupt=`); every write goes to a fresh practice database."""
import pytest
from resolver.approval import decide
from resolver.execute import ExecutionRefused, Interrupted, execute
from .test_approval import proposed
LABEL = {"tool": "create_return_label", "order_id": "LK-645334", "sku": "LADDER-3", "reason": "change_of_mind"}
@pytest.mark.parametrize("where", ["before-write", "after-write"])
def test_resume_after_a_crash_writes_the_label_once(world, where):
s, w = proposed("T-90604", [LABEL]), world("T-90604")
decide(s, True, "Amira", "agent")
with pytest.raises(Interrupted):
execute(s, w, interrupt=where)
assert len(w.changes()) == (0 if where == "before-write" else 1)
execute(s, w) # the resume
execute(s, w) # a second resume changes nothing
assert len(w.changes()) == 1 and s.status == "done"
def test_a_resume_cannot_skip_the_approval(world):
s, w = proposed("T-90604", [LABEL]), world("T-90604") # waiting_approval: nobody approved it
with pytest.raises(ExecutionRefused):
execute(s, w)
decide(s, False, "Grace", "grace", "the customer already sent it back")
with pytest.raises(ExecutionRefused):
execute(s, w)
assert w.changes() == []

python -m pytest tests/test_module4_practice.py affiche 3 passed (3 tests réussis), et python -m pytest affiche 63 passed (63 tests réussis). La fixture world n’ajoute pas le délai d’attente dépassé que la tâche simule elle-même. Ces tests vérifient donc uniquement le plantage. Supprimez l’identifiant d’opération, par exemple en faisant retourner une nouvelle valeur aléatoire à operation_id() à chaque appel. Le cas « après l’écriture » trouve alors deux étiquettes.

Vous avez terminé le module quand vous pouvez montrer ces éléments dans votre propre projet :

  • python -m pytest affiche 60 passed (60 tests réussis), ou plus avec vos tests d’exercice. tests/test_execute.py réussit : une reprise après un plantage avant ou après l’écriture effectue le changement une seule fois. Rien ne s’exécute sans approbation.
  • Vous pouvez exécuter un billet de soutien et vous arrêter à la demande d’approbation. Vous pouvez en expliquer chaque partie à Grace : l’action exacte, qui peut l’approuver et les preuves.
  • Vous pouvez expliquer pourquoi une reprise utilise l’exécution sauvegardée, jamais une nouvelle exécution du billet.
  • Vous pouvez expliquer, à l’aide de la trace, pourquoi --naive-retry a produit deux remboursements. Vous pouvez aussi expliquer pourquoi le même délai d’attente dépassé a produit un seul remboursement sans cette option.

Réinitialisation. Si votre projet ne fonctionne plus, renommez-le et copiez l’instantané end-of-m04 :

Fenêtre de terminal
mv resolution-workflow resolution-workflow-old
cp -R nextia-learning-labs/C10/snapshots/end-of-m04 resolution-workflow

Sous Windows, dans PowerShell : Rename-Item resolution-workflow resolution-workflow-old, puis Copy-Item -Recurse nextia-learning-labs\C10\snapshots\end-of-m04 resolution-workflow. Recréez l’environnement virtuel, comme l’indique le README des instantanés. python -m pytest affiche alors 60 passed. Ces étapes ont été testées sur macOS; les commandes Windows n’ont pas été testées.

Vérifiez votre compréhension

Un appel de remboursement dépasse le délai d'attente. Nommez les deux situations possibles au service de paiement. Dites ensuite ce que fait l'exécutant dans chaque cas.

Afficher la réponse

Soit le service a effectué le remboursement et seule la réponse a été perdue (délai d’attente dépassé après l’écriture). Soit la requête n’a jamais effectué le changement (délai d’attente dépassé avant l’écriture). L’exécutant ne peut pas le savoir à partir du délai dépassé. Il procède donc à une réconciliation : il interroge le service sur l’identifiant d’opération. Si le service l’a trouvé (T-90104 avec timeout_after_write), l’exécutant note que l’opération est terminée et ne rappelle pas le service. Sinon (T-90604), il rappelle le service avec le même identifiant : deux appels, une étiquette.

Vérifiez votre compréhension

Pourquoi le projet calcule-t-il l'identifiant d'opération à partir du billet et de l'action, plutôt qu'un nombre aléatoire par appel?

Afficher la réponse

Le même changement prévu doit avoir le même identifiant à chaque tentative. Cela comprend une nouvelle tentative, une reprise dans un nouveau processus après un plantage et une deuxième exécution de la même proposition. C’est la seule façon pour le service de reconnaître une répétition et de retourner le premier résultat. Un identifiant aléatoire change à chaque fois : chaque tentative ressemble donc à un nouveau remboursement. C’est exactement ce que --naive-retry a fait avec 797fbb5bbb9f8a7e-retry1.

Vérifiez votre compréhension

Dans l'explorateur, un plantage avant l'écriture produit un seul remboursement, même sans identifiant d'opération. L'identifiant sert-il donc seulement quand le délai d'attente est dépassé? Expliquez à l'aide des autres cas.

Afficher la réponse

Non. Avant l’écriture, rien ne s’est passé, donc toute reprise est sûre. Mais l’exécutant ne sait pas à quel moment le plantage s’est produit. Après l’écriture, l’enregistrement indique « non terminé ». Sans identifiant, la reprise renvoie la demande : deux remboursements. La même chose se produit pendant l’appel. L’identifiant et la réconciliation sont nécessaires dès que le changement a pu avoir lieu sans que l’enregistrement l’indique.

Récapitulatif

  • Un délai d’attente dépassé représente un résultat incertain : le changement a peut-être eu lieu. « Il suffit de rappeler le service » a transformé un remboursement de frais approuvé en deux (délai dépassé simulé, --naive-retry).
  • Un identifiant d’opération désigne un changement prévu. Le projet le calcule à partir du billet et de l’action exacte. Chaque tentative et chaque reprise utilisent donc le même identifiant. Le service tient un registre, ce qui rend l’écriture idempotente.
  • La réconciliation demande au service ce qui s’est passé avant d’agir de nouveau. L’exécutant sauvegarde chaque opération comme étant en attente avant l’appel (écriture anticipée). Une reprise après un plantage sait donc qu’elle doit interroger le service.
  • Réessayez seulement en cas d’erreur de connexion (délai d’attente dépassé, 503), avec un maximum de 3 appels. Transmettez les refus à une personne.
  • Les cadres logiciels comme LangGraph offrent des points de contrôle et des pauses. Mais un nœud repris recommence son exécution depuis le début : l’identifiant d’opération reste votre responsabilité.
  • tests/test_execute.py et tests/test_systems.py : 60 passed. Le projet correspond à end-of-m04.

Vous avez terminé le module 4. Ensuite, le flux de travail effectue plusieurs lectures à la fois et fusionne leurs résultats selon des règles claires.

Leçon suivante : Travail en parallèle