Réessais Idempotence Fiabilité API LLM

Réessais et idempotence des API LLM en toute sûreté

Réessayer un appel d'API LLM sans double facturation ni effet de bord dupliqué : quelles erreurs relancer, backoff avec jitter, clés d'idempotence et déduplication au niveau de la passerelle.

AveMujica API 9 min de lecture

Réessayer un appel d'API LLM en échec n'est sûr que si vous pouvez garantir que l'appel s'exécute au plus une fois, même lorsque le réseau donne l'impression qu'il a échoué deux fois. Le danger est subtil : une requête peut aboutir chez le fournisseur, puis la réponse se perd sur le chemin du retour, votre client voit un timeout, réessaie, et vous voilà facturé deux fois ou avec un effet de bord déclenché deux fois. La solution tient à deux disciplines qui voyagent toujours ensemble : ne relancer que les erreurs qui méritent de l'être, avec backoff et jitter, et rendre chaque requête relançable idempotente afin qu'un doublon soit reconnu et fusionné plutôt que réexécuté. Cet article montre comment faire les deux.

Pourquoi cela compte particulièrement pour le trafic LLM : les appels de génération coûtent de l'argent réel par token, et les workflows d'agents enchaînent des appels où une étape dupliquée corrompt l'état en aval. « Réessayer en cas d'échec » convient pour un GET ; c'est un incident de facturation en puissance pour un POST qui génère des tokens.

Classez les erreurs avant de réessayer quoi que ce soit

Toutes les défaillances ne doivent pas être relancées. Réessayer une erreur permanente fait perdre du temps et, en situation de limitation de débit, vous nuit activement, car les requêtes infructueuses comptent toujours dans votre quota. Triez les défaillances en trois catégories :

ClasseExemplesAction
Transitoire relançabletimeouts, réinitialisations de connexion, 500, 502, 503, 429Réessayer avec backoff
Erreur client non relançable400 requête invalide, 401/403 auth, 404, 422Échouer vite, remonter l'erreur
Ambigutimeout après l'envoi de la requêteRéessayer uniquement si idempotent

La troisième ligne est la dangereuse. Un timeout ne vous dit pas si le fournisseur a traité la requête — il vous dit seulement que la réponse n'est pas arrivée. Réessayer une défaillance ambiguë sans idempotence, c'est exactement ainsi que surviennent les doubles facturations. Voilà pourquoi les deux sujets sont indissociables.

Boucle de réessai : backoff, jitter et plafond

Une boucle de réessai correcte applique un backoff exponentiel, ajoute du jitter pour que les clients concurrents ne se synchronisent pas, plafonne le nombre de tentatives et ne relance que les classes relançables ci-dessus.

import random, time
import httpx

RETRYABLE = {429, 500, 502, 503, 504}

def call_with_retries(payload, key, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            r = httpx.post(
                "https://your-gateway.example.com/v1/chat/completions",
                json=payload,
                headers={"Authorization": "Bearer AVE_GATEWAY_API_KEY",
                         "Idempotency-Key": key},
                timeout=30,
            )
        except (httpx.TimeoutException, httpx.TransportError):
            _sleep(attempt); continue          # ambiguous: safe because key is stable
        if r.status_code in RETRYABLE:
            _sleep(attempt); continue
        return r                                # success or non-retryable client error
    raise RuntimeError("exhausted retries")

def _sleep(attempt, base=0.5, cap=15.0):
    time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))

Remarquez que la Idempotency-Key est passée en argument et reste constante à travers chaque tentative de la même requête logique. C'est ce seul détail qui rend sûr le réessai après un timeout ambigu. Si vous génériez une nouvelle clé à chaque tentative, vous saperiez tout le mécanisme.

Ce que fait réellement une clé d'idempotence

Une clé d'idempotence est un identifiant unique généré par le client — un UUID v4 est le choix courant — que vous attachez à une requête modifiant l'état via l'en-tête Idempotency-Key. Le serveur stocke le résultat de la première requête sous cette clé et, pour tout doublon portant la même clé, renvoie le résultat stocké au lieu de réexécuter l'opération. Générez la clé une fois par opération logique et réutilisez-la à chaque réessai de cette opération :

import uuid
key = str(uuid.uuid4())          # once, before the first attempt
result = call_with_retries(payload, key)

Les implémentations robustes ajoutent une empreinte : un hachage du corps de la requête stocké aux côtés de la clé. Si une requête ultérieure arrive avec la même clé mais un corps différent, le serveur la rejette — le plus souvent avec un HTTP 409 Conflict (un idempotency_conflict) — plutôt que de renvoyer silencieusement le mauvais résultat mis en cache. Les clés ont un TTL (24 heures est une fenêtre typique) ; après expiration, la clé est oubliée et une réutilisation démarre une nouvelle opération.

Une note sur la prise en charge par les fournisseurs : OpenAI documente explicitement Idempotency-Key pour son flux de paiement Agentic Commerce, où répéter un appel de création ou de finalisation avec la même clé doit renvoyer le même résultat, et où une divergence de paramètres renvoie un 409. Les endpoints généraux chat et responses ne le présentent pas comme une fonctionnalité majeure, et la plupart des passerelles — ce projet compris — ne dédupliquent aujourd'hui ni les appels /v1/chat/completions ni les appels /v1/responses à votre place. C'est précisément pourquoi la passerelle est le bon endroit où ajouter l'idempotence : elle constitue l'unique point de passage où bâtir une seule fois le magasin de déduplication afin de couvrir tous les services, quel que soit le comportement de chaque amont.

Implémenter l'idempotence au niveau de la passerelle

Lorsque de nombreux services appellent les modèles via une seule passerelle, celle-ci peut intercepter un doublon pour tout le système — à condition d'implémenter le cache indexé par clé. Voici le modèle à construire, et non une option à activer :

  1. Le client envoie une requête avec Idempotency-Key: <uuid>.
  2. La passerelle recherche la clé dans un magasin à TTL court (Redis convient bien).
  3. Absence → transmettre à l'upstream, puis stocker {clé → réponse, empreinte} avant de renvoyer.
  4. Présence, même empreinte → renvoyer la réponse stockée sans appeler l'upstream.
  5. Présence, empreinte différente → renvoyer 409 Conflict.
  6. Présence, toujours en cours → renvoyer 409 (ou attendre) pour qu'une double soumission rapide ne s'exécute pas deux fois.

Construire cela une seule fois transforme la « sûreté des réessais », que chaque service réimplémenterait autrement, en une propriété imposée au même endroit. Cela protège aussi contre les doublons que vous n'avez pas provoqués : un utilisateur qui double-clique, une file qui redélivre un message ou un proxy qui réessaie en coulisse. Journalisez la clé et son résultat afin qu'un doublon supprimé reste auditable dans votre propre magasin au lieu de devenir invisible. Les tempêtes de réessais sur de nombreux canaux expliquent également l'intérêt de cette centralisation : lisez Limites de débit et contre-pression des API LLM pour le versant concurrence du même problème.

Une réserve honnête sur les garanties : le véritable « au plus une fois » n'existe que si la couche qui produit l'effet de bord coopère. Si l'amont n'est pas lui-même idempotent, la déduplication de la passerelle fusionne les doublons visibles par le client, mais ne peut pas retirer une requête que l'amont a déjà traitée lorsque sa réponse s'est perdue. Considérez le cache indexé par clé comme une protection solide contre les nouvelles soumissions, et réservez l'expression « exactement une fois » aux chemins où une clé d'idempotence amont ou une coordination atomique de l'effet de bord existe réellement.

Idempotence et streaming

Le streaming complique la déduplication, car la réponse est livrée de façon incrémentale et peut être interrompue en plein vol. Si un flux se coupe après avoir émis la moitié de ses tokens, un réessai aveugle avec la même clé sur un cache naïf pourrait rejouer un résultat partiel. Modèles plus sûrs : ne mettre en cache que le résultat complet sous la clé, traiter un flux interrompu comme une absence de résultat (afin que le réessai régénère proprement), ou se reconnecter via le mécanisme de reprise propre au flux plutôt que de réémettre la requête. Les détails de la reprise SSE se trouvent dans Réponses LLM en streaming avec SSE.

Checklist réessais et idempotence

  • Erreurs classées : réessayer transitoires/429/5xx, échouer vite sur les 4xx.
  • Timeouts ambigus réessayés uniquement lorsque la requête est idempotente.
  • Backoff exponentiel avec full jitter et plafond strict de tentatives.
  • Une seule Idempotency-Key par opération logique, réutilisée sur tous les réessais.
  • Le serveur ou la passerelle stocke clé + empreinte avec un TTL.
  • Un doublon au corps divergent renvoie 409, pas un succès périmé.
  • Les doublons en cours sont gérés, pas réexécutés.
  • Les réessais en streaming se reconnectent ou régénèrent au lieu de rejouer des fragments partiels.

Choisir un budget de réessais qui échoue en toute sûreté

Les réessais échangent de la latence contre une probabilité de succès, et des réessais non bornés bradent le système tout entier. Fixez un budget explicite avec trois chiffres : nombre maximal de tentatives, temps total maximal et timeout par tentative. Lorsque le budget est épuisé, remontez une erreur propre plutôt que de réessayer indéfiniment — une requête qui a échoué cinq fois avec backoff ne réussira presque jamais à la sixième, et les tentatives consomment de la capacité dont d'autres requêtes ont besoin.

Un mode de défaillance subtil apparaît à l'échelle : si chacun de dix services réessaie indépendamment, leur trafic de réessais combiné peut à lui seul dépasser votre limite de débit, ce qui produit davantage de 429, puis davantage de réessais. C'est une tempête de réessais auto-infligée. Le principal garde-fou est un budget de réessais exprimé comme une fraction du trafic total : plafonnez les réessais à, par exemple, 10 % des requêtes afin qu'une panne étendue ne puisse pas doubler la charge. Lorsque c'est un canal entier qui échoue plutôt que quelques appels isolés, il faut cesser de réessayer contre un mur et déplacer le trafic ; les mécanismes de disjoncteur et de bascule relèvent de la stratégie de failover pour API LLM. Associez donc ce budget à cette stratégie au lieu de dupliquer ici la logique du disjoncteur.

  • Bornez les tentatives et le temps horloge total, pas seulement le nombre de tentatives.
  • Plafonnez les réessais agrégés pour que le trafic de reprise ne puisse pas amplifier une panne.
  • Confiez les échecs persistants d'un canal à la bascule plutôt que de réessayer aveuglément.

L'observabilité boucle la boucle : instrumentez votre propre taux de réessais — la fraction des appels qui sont des réessais — afin qu'une hausse vous alerte avant de devenir un incident. Cette métrique se calcule à partir de vos journaux d'appels ; une fraction de réessais en hausse est le premier signal qu'un budget doit être resserré.

FAQ

Si une requête expire, a-t-elle été traitée ou non ?

Vous ne pouvez pas le déduire d'un timeout seul — il signifie seulement que la réponse n'est pas arrivée, pas que le travail n'a pas eu lieu. Traitez-le comme ambigu. Ne le réessayez que si la requête porte une clé d'idempotence stable, afin qu'une exécution en double soit fusionnée côté serveur ; sinon, remontez l'ambiguïté à l'appelant.

Dois-je générer une nouvelle clé d'idempotence à chaque réessai ?

Non — c'est l'erreur classique. Générez une clé par opération logique et envoyez la clé identique à chaque réessai de celle-ci. Une nouvelle clé par tentative fait passer chaque réessai pour une toute nouvelle requête, si bien que les doublons s'exécutent normalement et que vous perdez toute protection.

Que se passe-t-il si deux requêtes identiques arrivent presque au même instant ?

Gérez explicitement le cas « en cours ». Avant que la première requête ne se termine, la seconde avec la même clé devrait recevoir un 409 Conflict (ou bloquer jusqu'à ce que la première s'achève) plutôt que d'être transmise — sinon une double soumission rapide dépasse le cache et s'exécute deux fois.

Prouvez la déduplication sur un vrai doublon

Enveloppez vos appels de modèle dans une fonction de réessai qui reçoit une Idempotency-Key, puis générez cette clé une seule fois par opération. Mettez ensuite en place le magasin indexé par clé décrit ci-dessus et faites-y passer délibérément un doublon : confirmez dans votre propre journal de déduplication que le second appel a été supprimé et facturé une seule fois. Tant que ce magasin n'existe pas, une clé répétée n'est qu'un en-tête que l'amont peut ignorer ; seule la preuve de la suppression d'un vrai doublon compte.

Références