Aller au contenu
English

Gérer les limites du fournisseur

  • Leçon 17
  • 50 min
  • Module 6 : Contrôles de fiabilité et de coût

Ce que vous saurez faire

Gardez l’assistant en marche quand le fournisseur dit « trop de requêtes » ou ne répond pas à temps. Réessayez seulement ce qui peut être répété sans risque. Attendez le temps nécessaire entre les tentatives. Arrêtez avant que l’agent attende trop longtemps.

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

Ce que vous devez savoir

  • Les erreurs de l’adaptateur (ProviderError, ProviderTimeout, RateLimited) et le délai maximal, présentés dans La frontière de l’application.
  • Le résultat failed (échec), et ce que l’agent voit alors, présentés dans Communiquer un échec.
  • La clé d’idempotence dans la conception de l’approbation, présentée dans Limiter la boucle.

Ce qu’il vous faut sur votre ordinateur

  • Le projet à la fin du module 5. python -m pytest affiche 65 passed, 1 deselected (65 tests réussis, 1 désélectionné). L’environnement virtuel est actif.
  • Si votre projet n’est pas dans cet état, copiez l’instantané end-of-m05 en suivant Réinitialiser le projet.

Les modèles et les API changent vite. Les réponses, les en-têtes et les prix de ce module sont un instantané. La méthode, elle, reste valable.

Vous appelez un magasin. Un message enregistré dit : « Toutes les lignes sont occupées. Veuillez rappeler plus tard. » Vous attendez une minute et rappelez. Si la ligne est encore occupée, vous attendez plus longtemps avant la prochaine tentative. Après quelques tentatives, vous arrêtez et envoyez plutôt un courriel.

Imaginez maintenant cent personnes qui ont entendu le même message au même moment. Si chacune attend exactement une minute, elles rappellent toutes ensemble, et les lignes sont encore occupées. Les personnes qui réussissent à joindre le magasin ont attendu un temps légèrement différent.

Le problème : le fournisseur dit « pas maintenant »

Section intitulée « Le problème : le fournisseur dit « pas maintenant » »

Un fournisseur partage ses serveurs entre plusieurs clients. Il limite donc la quantité que chacun peut envoyer. Une limite de débit fixe cette règle : au plus un certain nombre de requêtes ou de jetons par minute. Quand vous la dépassez, le fournisseur répond HTTP 429, Too Many Requests (trop de requêtes) et ne fait aucun travail.

C’est réellement arrivé pendant l’enregistrement du cours. Le programme d’enregistrement envoyait des billets de soutien à Azure avec quatre processus en parallèle. Il ne cherchait pas à atteindre la limite. Pourtant, en environ huit minutes, il a reçu 42 vraies réponses HTTP 429. En voici une, avec ses en-têtes les plus utiles :

HTTP 429
{"error": {"message": "Your requests to gpt-6-luna for chat-small in eastus2 have exceeded token rate limit.",
"type": "too_many_requests", "code": "rate_limit_exceeded"}}
retry-after: 30
retry-after-ms: 511
x-ratelimit-limit-tokens: 100000
x-ratelimit-remaining-tokens: -853
x-ratelimit-limit-requests: 100
x-ratelimit-remaining-requests: 65

Le déploiement permet 100 000 jetons et 100 requêtes par minute. Les requêtes n’étaient pas le problème : il en restait 65 sur 100 disponibles. Les jetons l’étaient : les quatre processus avaient dépassé la limite de 853 jetons. Chaque billet envoie environ 2 000 jetons. Quatre processus atteignent donc vite la limite de jetons.

L’image montre quand les 42 réponses sont arrivées. Elles ne se répartissent pas uniformément. Elles arrivent en groupes, espacés d’environ 30 secondes.

Les 42 vraies réponses HTTP 429, par groupes de 10 secondes, à partir de 22 h 17 min 10 s, heure de Toronto (quatre processus en parallèle, sans provoquer les erreurs). Le programme d'enregistrement attendait 30 secondes après chacune.
Description textuelle du graphique

Un histogramme de 42 réponses HTTP 429 sur environ 480 secondes, avec des classes de 10 secondes. Les réponses arrivent en groupes. Il y en a 3 au début, puis des groupes vers 30, 40, 70, 100 et 140 secondes. Après une pause, un grand groupe de 19 réponses arrive entre 240 et 310 secondes. Des groupes plus petits suivent vers 400, 435 et 470 secondes. Entre les groupes, il n’y a aucune réponse 429. Plusieurs groupes sont espacés d’environ 30 secondes, soit le temps d’attente du programme d’enregistrement.

Le programme d’enregistrement suivait une règle simple. Après une réponse 429, il attendait les 30 secondes demandées par retry-after (réessayer après), puis renvoyait la même requête. Les requêtes qui échouaient ensemble attendaient ensemble, puis revenaient ensemble. Cinq de ces nouvelles tentatives ont reçu une autre réponse 429. Une requête, la boucle d’outils de T-65070, en a reçu trois de suite, espacées de 30 secondes. Les 42 réponses indiquaient retry-after: 30, peu importe la situation réelle.

Un deuxième type d’échec semble différent, mais demande la même prudence. Un délai maximal est le temps maximal pendant lequel le code attend une réponse; le projet attend 30 secondes. Une fois ce délai dépassé, vous ne savez pas ce qui s’est passé. Le fournisseur n’a peut-être jamais reçu la requête. Ou il a fait le travail et l’a facturé, mais la réponse n’est pas arrivée.

Une nouvelle tentative renvoie la même requête après un échec. Une bonne politique répond à trois questions : quels échecs réessayer, combien de temps attendre et quand arrêter. L’image les montre dans cet ordre.

Un arbre de décision. L'appel au fournisseur a échoué. Quel type d'échec? HTTP 400, 401, 403 ou 404, où la requête ou la clé est incorrecte : ne réessayez pas, la même requête échouera encore, et arrêtez. HTTP 429 : la réponse contient-elle un en-tête Retry-After? Non : attendez avec un délai d'attente croissant et de la gigue, 0,5, 1, 2 secondes, et ainsi de suite, jusqu'à 8 secondes. Oui, 8 secondes ou moins : attendez au moins le temps demandé. Oui, plus de 8 secondes : arrêtez. Délai dépassé ou HTTP 5xx : peut-on répéter la requête sans risque? Une lecture, oui; une écriture, seulement avec une clé d'idempotence. Sinon : ne réessayez pas. Si oui : délai d'attente croissant avec gigue. Après l'attente : reste-t-il des tentatives, sur 4, et les attentes totalisent-elles moins de 20 secondes? Oui : renvoyez la même requête. Non : arrêtez. Arrêter signifie un résultat d'échec, et une personne prend le billet en charge.
Quels échecs réessayer, combien de temps attendre et quand arrêter. Les nombres sont les valeurs par défaut du projet dans assistant/retry.py.
Description textuelle du diagramme

L’appel au fournisseur a échoué. D’abord, de quel type d’échec s’agit-il?

  1. HTTP 400, 401, 403 ou 404 : la requête ou la clé est incorrecte. Ne réessayez pas, car la même requête échouera encore. Arrêtez.
  2. HTTP 429, une limite de débit : la réponse contient-elle un en-tête Retry-After? Sinon, attendez avec un délai d’attente croissant et de la gigue. S’il demande 8 secondes ou moins, attendez au moins ce temps. S’il demande plus de 8 secondes, arrêtez.
  3. Un délai dépassé ou une erreur serveur HTTP 5xx : peut-on répéter la requête sans risque? Une lecture est sans risque. Une écriture est sans risque seulement avec une clé d’idempotence. Si ce n’est pas sans risque, ne réessayez pas. Sinon, attendez avec un délai d’attente croissant et de la gigue.
  4. Après l’attente : reste-t-il des tentatives (au plus 4) et les attentes totalisent-elles encore moins de 20 secondes? Si oui, renvoyez la même requête. Sinon, arrêtez.

Arrêter signifie que le résultat est un échec et qu’une personne prend le billet en charge.

Réessayez seulement si la prochaine tentative peut réussir. Une réponse 429 dit « pas maintenant », et une erreur 5xx dit « quelque chose a brisé de notre côté ». Les deux peuvent disparaître en une seconde. Une réponse 400 dit « cette requête est incorrecte ». La vraie erreur 400 de Décrire un outil, où Termechat-strongLe nom que le code de Larkfield donne à un modèle hébergé plus grand : gpt-6.1-sol sur Azure. Il est souvent plus exact que chat-small et coûte environ 14 à 18 fois plus par appel.En savoir plus Les éléments d'une requêteCréer des applications fiables avec les API de LLM refusait les outils, revient chaque fois de la même façon. Réessayer ne fait qu’ajouter de l’attente.

Décidez selon le type de l’erreur et son code d’état, jamais selon ses mots. Dans Communiquer un échec, l’écran devinait « temporaire ou non » à partir du texte de l’erreur. C’est parce que Outcome.error conserve seulement du texte. Un fournisseur peut changer sa formulation ou répondre dans une autre langue. La comparaison de texte échoue alors sans avertissement. Le code de nouvelle tentative s’exécute plus tôt, quand l’objet d’erreur existe encore. is_retryable pose deux questions qui ne dépendent pas des mots. Est-ce une erreur RateLimited ou ProviderTimeout? Son status (code d’état) vaut-il 500 ou plus? Vous verrez la fonction dans les étapes ci-dessous.

Demandez-vous ensuite si la requête peut être répétée sans risque. Une opération est idempotente quand l’exécuter deux fois a le même effet que l’exécuter une fois. Lire une commande est idempotent. Analyser un billet l’est aussi. L’assistant prépare seulement un brouillon et n’enregistre rien avant que les deux barrières soient franchies. Un remboursement ne l’est pas : deux remboursements coûtent deux fois plus. C’est pourquoi la conception de l’approbation dans Limiter la boucle envoie une clé d’idempotence. Ainsi, une requête répétée fait le travail une seule fois.

Le cas du délai dépassé demande une précision de plus. Le fournisseur a peut-être fait le travail. Réessayer après un délai dépassé peut donc coûter deux fois plus. Pour un appel qui coûte moins d’un dixième de cent, c’est acceptable. Pour un appel coûteux, comptez les nouvelles tentatives dans votre budget, comme le fait la prochaine leçon.

Combien de temps attendre : délai d’attente croissant et gigue

Section intitulée « Combien de temps attendre : délai d’attente croissant et gigue »

Un délai d’attente croissant exponentiel double chaque attente : 0,5 seconde, puis 1 seconde, puis 2 secondes. Le client espace de plus en plus ses appels à un serveur occupé. Un plafond empêche les attentes de croître sans fin : celui du projet est de 8 secondes.

Voici le délai d’attente croissant du projet, avec une base de 0,5 seconde, pour cinq tentatives échouées. Les nombres sont la vraie sortie de backoff_s, sans gigue :

[0.5, 1.0, 2.0, 4.0, 8.0]

Le délai d’attente croissant seul ne résout pas le problème de la ligne téléphonique. Quatre processus qui échouent ensemble attendent encore 0,5 seconde ensemble et reviennent ensemble. La gigue ajoute du hasard : chaque processus attend un temps aléatoire entre zéro et le délai d’attente croissant. Le projet utilise cette « gigue complète ». La deuxième attente se situe donc entre zéro et 1 seconde. Les processus reviennent maintenant à des moments différents, et moins d’entre eux arrivent en même temps.

Toute politique de nouvelle tentative doit avoir une fin. Le projet arrête après 4 tentatives (le premier appel et 3 nouvelles tentatives). Il arrête aussi si les attentes dépasseraient 20 secondes au total. L’appel échoue alors, le résultat est failed, et l’agent traite le billet manuellement, comme le montre Communiquer un échec. Un agent qui attend 20 secondes peut encore travailler. Un agent qui attend deux minutes a déjà ouvert le billet lui-même.

Explorer : quatre processus et un fournisseur occupé

Section intitulée « Explorer : quatre processus et un fournisseur occupé »

L’explorateur utilise un fournisseur simulé, pas un enregistrement. Quatre processus envoient un billet au même moment, comme le faisait le programme d’enregistrement. Le fournisseur démarre au plus une requête toutes les 0,25 seconde. Il répond aux autres avec HTTP 429, sans en-tête Retry-After. Chaque processus suit la politique du projet.

Explorer

Nouvelles tentatives avec délai d'attente croissant et gigue auprès d'un fournisseur à débit limité (simulation)

Réponses HTTP 429
6
Billets traités
4 sur 4
Fin du dernier processus à
3,5 s

Base 0,5 s, gigue désactivée, au plus 4 tentatives. 10 requêtes, dont 6 reçoivent la réponse HTTP 429 (simulée). 4 billets sur 4 reçoivent une réponse; le dernier processus finit à 3,5 s. Sans gigue, les processus en attente attendent les mêmes durées. Ils reviennent donc ensemble, et un seul d’entre eux peut passer chaque fois.

  • Réponse (HTTP 200)
  • HTTP 429 (simulé)
  • Attente avant la tentative suivante
01234secondes depuis l’envoi des quatre requêtesProc. 1Proc. 1, tentative 1 à 0 s : réponseProc. 2Proc. 2 attend 0,5 sProc. 2, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 2, tentative 2 à 0,5 s : réponseProc. 3Proc. 3 attend 0,5 sProc. 3 attend 1 sProc. 3, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 3, tentative 2 à 0,5 s : HTTP 429 (simulé)Proc. 3, tentative 3 à 1,5 s : réponseProc. 4Proc. 4 attend 0,5 sProc. 4 attend 1 sProc. 4 attend 2 sProc. 4, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 4, tentative 2 à 0,5 s : HTTP 429 (simulé)Proc. 4, tentative 3 à 1,5 s : HTTP 429 (simulé)Proc. 4, tentative 4 à 3,5 s : réponse
01234secondes depuis l’envoi des quatre requêtesP1Proc. 1, tentative 1 à 0 s : réponseP2Proc. 2 attend 0,5 sProc. 2, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 2, tentative 2 à 0,5 s : réponseP3Proc. 3 attend 0,5 sProc. 3 attend 1 sProc. 3, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 3, tentative 2 à 0,5 s : HTTP 429 (simulé)Proc. 3, tentative 3 à 1,5 s : réponseP4Proc. 4 attend 0,5 sProc. 4 attend 1 sProc. 4 attend 2 sProc. 4, tentative 1 à 0 s : HTTP 429 (simulé)Proc. 4, tentative 2 à 0,5 s : HTTP 429 (simulé)Proc. 4, tentative 3 à 1,5 s : HTTP 429 (simulé)Proc. 4, tentative 4 à 3,5 s : réponse
Chaque processus : tentatives (✓ réponse, ✗ HTTP 429), attentes en secondes, résultat
ProcessusTentatives à (s)Attentes (s)Résultat
Proc. 10 ✓-réponse à 0 s
Proc. 20 ✗, 0,5 ✓0,5réponse à 0,5 s
Proc. 30 ✗, 0,5 ✗, 1,5 ✓0,5 + 1 = 1,5réponse à 1,5 s
Proc. 40 ✗, 0,5 ✗, 1,5 ✗, 3,5 ✓0,5 + 1 + 2 = 3,5réponse à 3,5 s
Un fournisseur simulé : il démarre une requête toutes les 0,25 seconde. Les attentes suivent assistant/retry.py : la base double à chaque tentative, jusqu'à 8 secondes, avec au plus 20 secondes d'attente.
Description textuelle de l’explorateur

L’explorateur montre quatre processus sur une ligne du temps. Il offre trois commandes. Le délai de base est de 0,25, 0,5, 1 ou 2 secondes. La gigue est activée ou désactivée. Un bouton permet de tirer de nouvelles attentes aléatoires. Le nombre maximal de tentatives va de 1 à 6. L’affichage indique le nombre de réponses HTTP 429, les billets ayant reçu une réponse et l’heure où le dernier processus termine. Un tableau donne les tentatives, les attentes et le résultat de chaque processus.

L’état initial : base de 0,5 seconde, gigue désactivée, 4 tentatives. Le processus 1 reçoit une réponse à 0 seconde. Les processus 2, 3 et 4 reçoivent HTTP 429 à 0 seconde et attendent tous 0,5 seconde. À 0,5 seconde, le processus 2 passe. Les processus 3 et 4 arrivent au même moment et reçoivent encore une réponse 429. Ils attendent tous deux 1 seconde. À 1,5 seconde, le processus 3 passe, et le processus 4 reçoit encore une réponse 429. Le processus 4 attend 2 secondes. Il reçoit une réponse à 3,5 secondes, à sa quatrième et dernière tentative. Au total, il y a 10 requêtes et 6 réponses HTTP 429. Les 4 billets reçoivent tous une réponse.

Avec la gigue activée, chaque processus tire sa propre attente. Sur 50 tirages, le dernier processus termine à 1,61 seconde en moyenne, plutôt qu’à 3,5 secondes. Mais dans quelques tirages malchanceux, un processus utilise ses 4 tentatives et abandonne.

Sans gigue, l’image reproduit à petite échelle l’erreur du programme d’enregistrement. Les processus qui échouent ensemble reviennent ensemble, et un seul peut passer chaque fois. La gigue sépare les groupes. Elle est aléatoire, donc un tirage peut être malchanceux. Faites quelques nouveaux tirages avant de la juger. Un délai de base plus grand réduit les arrivées simultanées, mais chaque billet attend alors plus longtemps.

Quand le fournisseur indique combien de temps attendre, son nombre vaut mieux qu’une estimation. L’en-tête standard est Retry-After (réessayer après), en secondes entières ou sous forme de date. Regardez encore la vraie réponse 429 : elle contient deux réponses qui ne concordent pas.

  • retry-after: 30 indique 30 secondes. Cette valeur était de 30 dans les 42 réponses, quoi qu’il arrive. Elle semble être une valeur fixe et prudente.
  • retry-after-ms: 511 indique 0,511 seconde, et vous pouvez le vérifier. La limite est de 100 000 jetons par 60 secondes. Elle se reconstitue donc d’environ 1 667 jetons chaque seconde. Les processus avaient dépassé la limite de 853 jetons, et 853 ÷ 1 667 = 0,51 seconde.

Fiez-vous donc à l’en-tête précis quand il est présent. Il a été calculé pour ce moment; l’autre ne l’a pas été. Mais traitez-le comme l’attente minimale, pas comme une promesse. Après 0,511 seconde, la réserve revient à zéro, et le prochain billet demande 2 000 jetons de plus. C’est pourquoi le projet attend au moins le temps demandé par le fournisseur, ou plus si le délai d’attente croissant l’exige.

Votre code doit traiter les deux en-têtes. retry-after-ms n’est pas une norme. Azure l’a envoyé ici, et le SDK OpenAI le lit, mais plusieurs fournisseurs ne l’envoient jamais. Certains fournisseurs envoient seulement Retry-After, certains l’envoient sous forme de date, et d’autres n’envoient rien. L’adaptateur les lit par ordre de précision. Une règle protège l’agent : si le fournisseur demande plus que le plafond de 8 secondes, l’assistant ne revient pas plus tôt. Il arrête, et une personne prend le billet en charge.

  1. Ouvrez assistant/providers.py. Ajoutez deux importations, en ordre alphabétique avec les autres :

    import email.utils
    from datetime import datetime, timezone
  2. Dans le même fichier, ajoutez cette fonction juste avant translate_errors :

    assistant/providers.py (new function)
    def retry_after_s(headers) -> float | None:
    """The wait that a 429 response asks for, in seconds, or None.
    Some providers send `retry-after-ms` (milliseconds, precise) as well as the standard `retry-after`
    (whole seconds, or an HTTP date). Use the precise one when it is there.
    """
    for name, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
    value = headers.get(name)
    if value is None:
    continue
    try:
    return max(0.0, float(value) * scale)
    except ValueError:
    pass
    try: # retry-after can also be a date: "Fri, 09 Oct 2026 02:21:39 GMT"
    when = email.utils.parsedate_to_datetime(value)
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
    except (TypeError, ValueError):
    pass
    return None
  3. Dans translate_errors, remplacez les deux lignes de la branche RateLimitError par une seule ligne :

    raise RateLimited("HTTP 429: rate limited.", retry_after_s(e.response.headers)) from e
  4. Créez assistant/retry.py :

    assistant/retry.py
    """Retry what is safe to repeat, with bounded exponential backoff and jitter.
    Analysing a ticket only reads: repeating the request changes nothing, so it is safe to retry.
    A request with side effects (a refund, a message to a customer) is not retried without an idempotency key.
    """
    import random
    import time
    from dataclasses import dataclass
    from typing import Callable, TypeVar
    from .providers import ProviderError, ProviderTimeout, RateLimited
    T = TypeVar("T")
    @dataclass
    class RetryPolicy:
    max_attempts: int = 4 # the first call and up to 3 retries
    base_s: float = 0.5 # the first wait before jitter
    cap_s: float = 8.0 # no single wait is longer than this
    jitter: bool = True # a random wait between 0 and the backoff, so that clients do not retry together
    max_total_s: float = 20.0 # stop when the waits would add up to more than this
    def is_retryable(error: Exception) -> bool:
    if isinstance(error, (RateLimited, ProviderTimeout)):
    return True
    return isinstance(error, ProviderError) and error.status is not None and error.status >= 500
    def backoff_s(attempt: int, policy: RetryPolicy, rng: random.Random) -> float:
    """The wait after failed attempt number `attempt` (1, 2, 3 ...): base x 2^(attempt-1), capped, then jitter."""
    wait = min(policy.cap_s, policy.base_s * 2 ** (attempt - 1))
    return rng.uniform(0, wait) if policy.jitter else wait
    def call_with_retry(call: Callable[[], T], policy: RetryPolicy = RetryPolicy(),
    sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None,
    on_retry: Callable[[int, Exception, float], None] | None = None) -> T:
    rng = rng or random.Random()
    waited = 0.0
    for attempt in range(1, policy.max_attempts + 1):
    try:
    return call()
    except ProviderError as error:
    if not is_retryable(error) or attempt == policy.max_attempts:
    raise
    wait = backoff_s(attempt, policy, rng)
    if isinstance(error, RateLimited) and error.retry_after is not None:
    if error.retry_after > policy.cap_s:
    raise # the provider asks for a longer wait than we accept: stop, do not come back early
    wait = max(wait, error.retry_after) # never earlier than the provider asked
    if waited + wait > policy.max_total_s:
    raise
    if on_retry:
    on_retry(attempt, error, wait)
    sleep(wait)
    waited += wait
    raise AssertionError("unreachable")
  5. Créez assistant/simulate.py. Il échoue volontairement pour vous permettre de tester la politique sans panne réelle :

    assistant/simulate.py
    """A SIMULATED provider for tests and demonstrations: it fails on purpose, in a fixed order.
    Real rate limits and outages are not provoked for the course. This wrapper raises the same errors that
    OpenAICompatibleProvider raises for a real HTTP 429, timeout or 5xx, then lets the next call through.
    """
    from .providers import Completion, ProviderError, ProviderTimeout, RateLimited
    class SimulatedProvider:
    def __init__(self, inner, script: list[str]):
    """script: one item per call, for example ["rate_limit:2", "timeout", "ok"].
    "rate_limit:N" = HTTP 429 with Retry-After N seconds ("rate_limit" alone has no Retry-After),
    "timeout", "server_error" (HTTP 503), "ok" = ask the inner provider. After the script: always "ok"."""
    self.inner = inner
    self.script = list(script)
    self.calls = 0
    def complete(self, request: dict) -> Completion:
    self.calls += 1
    step = self.script.pop(0) if self.script else "ok"
    if step.startswith("rate_limit"):
    wait = step.partition(":")[2]
    raise RateLimited("HTTP 429: rate limited (simulated).", float(wait) if wait else None)
    if step == "timeout":
    raise ProviderTimeout("The provider did not answer within the timeout (simulated).")
    if step == "server_error":
    raise ProviderError("HTTP 503: service unavailable (simulated).", status=503)
    return self.inner.complete(request)
  6. Ouvrez assistant/analyse.py. Ajoutez l’importation sous la ligne qui importe depuis .providers :

    from .retry import RetryPolicy, call_with_retry
  7. Dans analyse_ticket, ajoutez le paramètre retry avant reasoning_effort, et une petite fonction après request = … :

    assistant/analyse.py (analyse_ticket, changed lines)
    tools: bool = True, book: OrderBook | None = None, limits: Limits = Limits(),
    retry: RetryPolicy = RetryPolicy(), reasoning_effort: str = "") -> Outcome:
    book = book or OrderBook()
    request = build_request(ticket, model, prompt, response_format() if structured else None,
    TOOLS if tools else None, reasoning_effort)
    def complete(req: dict) -> Completion:
    return call_with_retry(lambda: provider.complete(req), retry)
  8. Dans la même fonction, utilisez complete aux deux endroits qui appelaient provider.complete :

    result = run_tool_loop(complete, request, ticket.customer_id, book, limits)
    completion = complete(request)
  9. Copiez tests/test_retry.py depuis l’instantané end-of-m06. Exécutez ensuite les tests :

    Fenêtre de terminal
    python -m pytest

L’étape 9 affiche 75 passed, 1 deselected (75 tests réussis, 1 désélectionné). Les dix nouveaux tests utilisent une fausse fonction sleep qui enregistre seulement les attentes. Ils s’exécutent donc en quelques millisecondes. L’un d’eux utilise les vrais en-têtes de la réponse 429 ci-dessus et attend 0,511 seconde.

Chaque nouvelle tentative englobe un appel au modèle, pas toute la boucle d’outils. Dans une boucle de deux appels, une réponse 429 au deuxième appel répète seulement cet appel. La recherche de commande qui le précédait ne s’exécute pas de nouveau.

Créez un petit script dans le dossier du projet, try_retry.py. Son fournisseur échoue volontairement trois fois (simulation), puis rejoue le véritable enregistrement de T-80008.

try_retry.py
"""Watch the retry policy work. The provider is SIMULATED: it fails on purpose, then replays the recording."""
import random
import time
from assistant.context import build_request
from assistant.data import load_ticket
from assistant.providers import MockProvider
from assistant.retry import RetryPolicy, call_with_retry
from assistant.schema import response_format
from assistant.simulate import SimulatedProvider
provider = SimulatedProvider(MockProvider(), ["rate_limit", "timeout", "server_error"])
request = build_request(load_ticket("T-80008"), "chat-small", "v2", response_format())
def show(attempt, error, wait):
print(f"attempt {attempt}: {error} Wait {wait:.2f} s.")
start = time.monotonic()
completion = call_with_retry(lambda: provider.complete(request), RetryPolicy(), rng=random.Random(8),
on_retry=show)
print(f"attempt {provider.calls}: finish_reason {completion.finish_reason}, "
f"{time.monotonic() - start:.1f} s in total")

random.Random(8) fixe les tirages aléatoires. Vous voyez donc les mêmes attentes que ci-dessous. Exécutez-le avec python try_retry.py. La sortie :

attempt 1: HTTP 429: rate limited (simulated). Wait 0.11 s.
attempt 2: The provider did not answer within the timeout (simulated). Wait 0.96 s.
attempt 3: HTTP 503: service unavailable (simulated). Wait 0.25 s.
attempt 4: finish_reason stop, 1.3 s in total

Le total est mesuré avec une horloge. Il peut donc varier d’un dixième de seconde sur votre ordinateur. Chaque attente est aléatoire, mais ne dépasse jamais le délai d’attente croissant : au plus 0,5 seconde, puis 1 seconde, puis 2 secondes. La quatrième tentative est la dernière permise, et elle obtient la réponse enregistrée.

Changez une seule chose à la fois dans try_retry.py, prédisez la sortie, puis exécutez-le.

  1. Désactivez la gigue : RetryPolicy(jitter=False).
  2. Réactivez la gigue et utilisez le script ["rate_limit:0.511"] : une réponse 429 avec le vrai retry-after-ms.
  3. Utilisez le script ["rate_limit:30"] : un fournisseur qui demande 30 secondes.
Afficher les sorties
  1. Sans gigue, les attentes correspondent exactement au délai d’attente croissant :

    attempt 1: HTTP 429: rate limited (simulated). Wait 0.50 s.
    attempt 2: The provider did not answer within the timeout (simulated). Wait 1.00 s.
    attempt 3: HTTP 503: service unavailable (simulated). Wait 2.00 s.
    attempt 4: finish_reason stop, 3.5 s in total
  2. Le fournisseur a demandé 0,511 seconde, et le délai d’attente croissant aléatoire était plus court. La politique attend donc le temps demandé :

    attempt 1: HTTP 429: rate limited (simulated). Wait 0.51 s.
    attempt 2: finish_reason stop, 0.5 s in total
  3. 30 secondes dépassent le plafond de 8 secondes. La politique ne réessaie pas plus tôt : elle lève immédiatement l’erreur. Le script se termine avec une trace d’erreur, dont la dernière ligne est :

    assistant.providers.RateLimited: HTTP 429: rate limited (simulated).

    Dans analyse_ticket, la même erreur devient le résultat failed, et une personne prend le billet en charge.

Supprimez try_retry.py quand vous avez terminé. Il ne fait pas partie du projet.

Prévoir une solution de repli quand les tentatives sont épuisées

Section intitulée « Prévoir une solution de repli quand les tentatives sont épuisées »

Une solution de repli est ce que fait l’application quand les nouvelles tentatives sont épuisées. Celle de TermeLarkfieldUne boutique en ligne d’articles pour la maison. Ce n’est pas une vraie entreprise. Tous les cours Nextia utilisent son service d’assistance et ses données comme exemple.En savoir plus L'IA dans les produits de tous les joursBases d'une carrière en IA : rôles, compétences et feuilles de route est la plus simple. Le résultat est failed (échec), et l’application de soutien indique que le brouillon n’est pas disponible. L’agent travaille comme avant l’arrivée de l’assistant. Rien n’est perdu, puisque l’assistant ne fait que rédiger des brouillons.

Deux autres solutions de repli sont courantes, et chacune a un coût. Une file d’attente relance le traitement du billet quelques minutes plus tard, quand la limite est réinitialisée. L’agent voit alors le brouillon en retard. Un deuxième modèle peut prendre le relais, mais vérifiez d’abord qu’il peut faire le même travail. chat-strong semble un remplaçant évident pour Termechat-smallLe nom que le code de Larkfield donne à un petit modèle hébergé peu coûteux : gpt-6-luna sur Azure. Le code utilise ce nom, donc le modèle peut changer sans modifier le code.En savoir plus Ce qu'est une API de LLMCréer des applications fiables avec les API de LLM. Pourtant, il ne peut pas utiliser d’outils dans TermeChat CompletionsLe format de requête et de réponse de l’API de clavardage d’OpenAI : un nom de modèle, une liste de messages avec des rôles, et des paramètres. Azure, Ollama et beaucoup d’autres fournisseurs acceptent le même format (« compatible avec OpenAI »), donc un seul SDK fonctionne avec tous.En savoir plus Les éléments d'une requêteCréer des applications fiables avec les API de LLM : voyez la véritable erreur HTTP 400 dans Décrire un outil. Une solution de repli jamais testée échoue au pire moment.

Les nouvelles tentatives ont aussi un coût, même quand elles réussissent. Une erreur 429 est gratuite, mais l’attente ne l’est pas : avec 4 tentatives, l’agent peut attendre jusqu’à 20 secondes. Si les erreurs 429 sont fréquentes, les nouvelles tentatives masquent un problème de capacité. Envoyez moins de requêtes à la fois, demandez une limite plus élevée au fournisseur ou utilisez moins de jetons par billet.

Erreur courante

Tout le monde attend 30 secondes

Symptôme : pendant l’enregistrement du cours, le programme d’enregistrement a reçu 42 véritables réponses HTTP 429 en environ huit minutes. Cinq de ses nouvelles tentatives ont reçu une autre erreur 429. T-65070 en a reçu trois de suite, à exactement 30 secondes d’TermeIntervalle de confianceUne plage autour d’une valeur mesurée, obtenue par une méthode qui contient la vraie valeur 95 fois sur 100. Si l’intervalle d’une différence ne contient pas zéro, la différence n’est probablement pas due au hasard.En savoir plus Estimer l'incertitudeMathématiques et statistiques essentielles pour l'IA.

Cause : deux facteurs combinés. Le programme attendait exactement le délai indiqué par retry-after, soit 30 secondes, sans gigue. Les requêtes qui échouaient ensemble revenaient ensemble et dépassaient encore la limite. De plus, il lisait l’en-tête approximatif plutôt que le précis : retry-after-ms indiquait 511 millisecondes.

Correction : lisez l’en-tête le plus précis envoyé par le fournisseur et utilisez-le comme délai minimal. Ajoutez de la gigue à chaque délai d’attente croissant pour que les clients ne réessaient pas en même temps. Limitez le nombre de tentatives et l’attente totale. Les fonctions retry_after_s et call_with_retry du projet font ces trois choses.

Ce que cela signifie pour un vrai service d’IA : une politique de nouvelles tentatives crée un comportement commun. Un client qui réessaie mal perd son propre temps. Mille clients qui réessaient de la même façon peuvent maintenir la surcharge du fournisseur après la résolution du problème initial.

Vérifiez votre compréhension

L'assistant reçoit une erreur HTTP 400 « Unsupported value : temperature » (valeur non prise en charge : temperature). Est-ce que call_with_retry doit réessayer? Que voit l'agent?

Afficher la réponse

Non. Une erreur 400 indique que la requête elle-même est incorrecte; la même requête échouera donc encore. is_retryable renvoie faux pour un code inférieur à 500 autre que 429. Le code lève l’erreur immédiatement, dès la première tentative. Dans analyse_ticket, elle devient le résultat failed, et l’agent traite le billet manuellement. Il faut corriger la requête, pas réessayer.

Vérifiez votre compréhension

Prédisez le résultat : la gigue est désactivée, le délai de base est de 1 seconde et le plafond de 8 secondes. Un fournisseur répond toujours 429, sans Retry-After. Combien de temps un appel attend-il au total avant d'abandonner, et combien de tentatives fait-il?

Afficher la réponse

Les délais sont de 1 seconde, 2 secondes et 4 secondes, soit 7 secondes au total. Ensuite, la quatrième tentative échoue. C’est la dernière tentative permise, donc call_with_retry lève l’erreur. Quatre tentatives, trois attentes. La limite de 20 secondes n’est pas atteinte. Avec un délai de base de 4 secondes, les délais seraient de 4, 8 et 8 secondes. Après 4 + 8 = 12 secondes, les 8 secondes suivantes porteraient l’attente totale à plus de 20 secondes. La politique arrête donc les tentatives après la troisième.

Vérifiez votre compréhension

Pourquoi peut-on relancer sans risque l'analyse d'un billet, mais pas un appel qui envoie un remboursement, même après un dépassement de délai?

Afficher la réponse

L’analyse est idempotente : elle lit le billet et la commande, puis renvoie un brouillon, sans rien changer hors de l’assistant. La faire deux fois coûte un peu plus, mais l’effet reste le même. Après un dépassement de délai, un appel de remboursement peut avoir atteint le système de paiement et effectué le remboursement. Le renvoyer pourrait rembourser deux fois. Une écriture ne peut être relancée qu’avec une clé d’idempotence. Ainsi, le destinataire effectue le travail une seule fois, quel que soit le nombre de requêtes.

Récapitulatif

  • Une limite de débit produit une réponse HTTP 429, sans effectuer de travail. Pendant l’enregistrement, quatre processus parallèles ont reçu 42 véritables erreurs 429 en environ huit minutes.
  • Réessayez seulement après des échecs temporaires (429, dépassements de délai, 5xx), et seulement les requêtes qu’on peut répéter sans risque. Ne réessayez jamais après une erreur 400. Une écriture exige une clé d’idempotence.
  • Le délai d’attente croissant exponentiel double l’attente (0,5, 1, 2 secondes, plafond de 8 secondes). La gigue rend chaque délai aléatoire, pour que les clients qui échouent ensemble ne reviennent pas ensemble.
  • La véritable réponse 429 indiquait retry-after: 30 et retry-after-ms: 511. Utilisez l’en-tête précis comme délai minimal; gérez les deux, car de nombreux fournisseurs n’en envoient qu’un, ou aucun.
  • Le projet s’arrête après 4 tentatives ou 20 secondes d’attente, et quand le fournisseur demande plus de 8 secondes. Le résultat est alors failed, et une personne prend en charge le billet.

Ensuite, vous consignez chaque appel : jetons, durée et coût.

Leçon suivante : Mesurer l'utilisation et le coût