Cinquième article de la série « Production Engineering, Evals & Security avec Claude ».
Une application Claude qui fonctionne parfaitement en développement peut tomber dès les premiers pics de trafic si aucun chemin d’erreur n’a été prévu.
Le document Production Engineering, Evals & Security insiste sur une idée simple : la première décision à prendre lorsqu’une erreur survient est de savoir si elle est retriable ou terminal. Toute la stratégie de résilience découle de cette classification.
La question fondamentale : attendre puis réessayer peut-il fonctionner ?
Pour classer une erreur, il faut se poser une seule question :
Si j’attends puis je renvoie exactement la même requête, a-t-elle une chance raisonnable de réussir ?
Si oui :
retriable
Si non :
terminal
Exemples :
429 rate limit
→ retriable
529 overloaded
→ retriable
500 server error
→ retriable
400 bad request
→ terminal
401 authentication failure
→ terminal
403 permission failure
→ terminal
Le document donne notamment cette classification :
RETRIABLE = {
429,
529,
500,
502,
503,
504
}
TERMINAL = {
400,
401,
403,
404
}
Pourquoi cette distinction est essentielle
Une erreur 429 provient d’une limite temporaire.
Le temps peut donc résoudre le problème.
À l’inverse, une erreur 400 signifie que la requête elle-même est incorrecte.
Attendre :
1 seconde
10 secondes
1 minute
ne change rien.
La même requête échouera encore.
C’est pourquoi :
retriable
→ retry
terminal
→ fail fast
Le piège des retries immédiats
Le mauvais pattern classique est :
def call_with_retry(make_call, max_attempts=5):
for attempt in range(max_attempts):
try:
return make_call()
except Exception:
time.sleep(0)
raise RetryBudgetExhausted()
Le défaut est évident :
time.sleep(0)
Le système recommence immédiatement.
Sur un 429, cela donne :
request
↓
429
↓
retry immédiatement
↓
429
↓
retry immédiatement
↓
429
Chaque retry peut aggraver la situation en consommant encore plus de capacité.
Le document décrit précisément ce cas de production : un développeur avait ajouté des retries instantanés après son premier rate limit, ce qui avait rendu le comportement encore pire.
Exponential backoff
Une stratégie correcte attend de plus en plus longtemps entre les tentatives.
Par exemple :
attempt 1
→ wait 1 s
attempt 2
→ wait 2 s
attempt 3
→ wait 4 s
attempt 4
→ wait 8 s
C’est le principe de :
exponential backoff
On ajoute généralement :
jitter
c’est-à-dire une petite composante aléatoire afin d’éviter que de nombreux clients réessaient exactement au même instant.
Une implémentation plus robuste
Exemple conceptuel fidèle au principe du document :
import random
import time
RETRIABLE = {
429,
500,
502,
503,
504,
529
}
TERMINAL = {
400,
401,
403,
404
}
def call_with_retry(make_call, max_attempts=5):
for attempt in range(max_attempts):
try:
return make_call()
except ApiError as exc:
status = exc.status_code
if status not in RETRIABLE:
raise
if attempt == max_attempts - 1:
raise RetryBudgetExhausted() from exc
wait = 2 ** attempt
jitter = random.uniform(0, 0.5)
time.sleep(wait + jitter)
L’idée importante est :
retry only retriable errors
+
backoff
+
jitter
+
cap attempts
Le retry budget
Les retries ne doivent jamais être illimités.
Sinon une dépendance défaillante peut bloquer une requête pendant très longtemps et multiplier les coûts.
Il faut donc définir un :
retry budget
Par exemple :
maximum attempts = 3
ou :
maximum total retry time = X
Le document insiste sur le fait que chaque retry inutile consomme :
- du temps ;
- des ressources ;
- du budget ;
- et réduit la marge disponible pour d’autres erreurs réellement temporaires.
retry-after : écouter le serveur
Le document souligne également l’importance du header :
retry-after
Lorsqu’un 429 ou 529 fournit cette information, le service indique combien de temps attendre avant de recommencer.
Le comportement recommandé est :
retry-after présent
→ utiliser cette valeur
sinon
→ exponential backoff
Conceptuellement :
retry_after = response.headers.get("retry-after")
if retry_after:
wait = float(retry_after)
else:
wait = exponential_backoff(attempt)
Le header doit être considéré comme plus précis qu’un délai choisi arbitrairement par l’application.
Les erreurs 5xx
Le document classe les erreurs serveur dans la catégorie retriable :
500
502
503
504
Pourquoi ?
Parce qu’elles correspondent généralement à un problème temporaire côté service.
Par exemple :
500
→ internal server error
503
→ service unavailable
504
→ timeout / gateway timeout
Une nouvelle tentative après attente peut donc fonctionner.
Les erreurs terminales
Certaines erreurs doivent au contraire provoquer un arrêt immédiat.
400 Bad Request
La requête est mal formée.
same request
→ same failure
Il faut corriger la requête.
401 Authentication
La clé ou l’authentification est incorrecte.
Un retry identique ne change rien.
403 Forbidden
La requête n’a pas les permissions nécessaires.
Attendre ne donne pas davantage de droits.
404 Not Found
Dans la classification présentée par le document, cette erreur est traitée comme terminale : la ressource ou le modèle demandé n’est pas trouvé.
Que faire lorsqu’on hésite ?
Le document adopte une position prudente :
En cas de doute, mieux vaut traiter l’erreur comme terminale et la remonter.
Pourquoi ?
Parce qu’une erreur incorrectement classée terminale :
fails loudly
et sera corrigée.
Une erreur incorrectement classée retriable peut :
hammer the service
+
hide the real problem
+
waste retry budget
C’est un principe de sécurité opérationnelle important.
Les timeouts
Les timeouts sont un cas intéressant.
Le document indique qu’ils sont généralement retriable, car une opération peut simplement avoir dépassé le temps d’attente du client.
Mais :
repeated timeout
sur la même requête peut indiquer que le problème n’est plus réellement transitoire.
Il peut alors être nécessaire de modifier :
- la requête ;
- la quantité de données ;
- l’architecture ;
- la stratégie de timeout.
Autrement dit :
un timeout
→ retry plausible
timeouts répétés
→ investigate root cause
Attention : le SDK Anthropic peut déjà effectuer des retries
C’est un piège de production important.
Le document précise que les clients SDK Anthropic peuvent automatiquement retenter certaines erreurs transitoires avec des délais progressifs, selon leur configuration.
Cela signifie qu’il faut éviter :
SDK retries
+
application retries
sans coordination.
Supposons :
SDK = 3 attempts
application = 5 attempts
On pourrait potentiellement multiplier le nombre d’appels effectifs.
Le système que l’on pensait limiter à cinq essais peut en déclencher beaucoup plus.
Où doit vivre la politique de retry ?
Deux approches sont possibles.
Option A : laisser le SDK gérer les erreurs transitoires
Puis réserver le code applicatif aux fallbacks spécifiques.
API call
↓
SDK retry policy
↓
application fallback
Option B : réduire les retries du SDK
Et gérer toute la politique explicitement dans l’application.
API call
↓
application retry policy
↓
fallback
L’anti-pattern est :
retry loop
inside another retry loop
sans coordination.
Une erreur de tool n’est pas une erreur API
Les agents introduisent une seconde catégorie de panne :
tool execution failure
Par exemple :
Claude
↓
tool_use
↓
database_search
↓
database unavailable
Que doit faire l’application ?
Surtout pas retourner :
""
ou :
null
comme si le tool avait simplement trouvé zéro résultat.
Pourquoi cacher une erreur de tool est dangereux
Supposons :
Tool failed
↓
application returns empty result
↓
Claude receives "no data"
↓
Claude continues reasoning
Le modèle peut alors produire une réponse confiante sur une fausse prémisse.
Le document insiste donc sur le fait que le résultat doit indiquer explicitement l’erreur avec :
is_error: true
Exemple de tool_result en erreur
Conceptuellement :
def run_tool(tool_use):
try:
result = execute(tool_use)
return {
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result
}
except Exception as exc:
return {
"type": "tool_result",
"tool_use_id": tool_use.id,
"is_error": True,
"content": f"Tool failed: {exc}"
}
Ainsi :
Claude
↓
tool_use
↓
application executes
↓
tool fails
↓
tool_result + is_error=true
↓
Claude knows it failed
Pourquoi renvoyer l’erreur à Claude ?
Parce que le modèle peut alors décider :
try another tool
ou :
ask user for clarification
ou :
stop
L’application ne doit pas transformer une défaillance technique en donnée valide.
Important pour l’examen : Claude demande, l’application exécute
Il faut conserver le modèle mental du tool use :
Application
↓
Claude
↓
tool_use
↓
Application decides / executes
↓
tool_result
↓
Claude
↓
Final response
Claude ne réalise pas lui-même l’action.
Il demande l’exécution du tool.
L’application :
- valide les paramètres ;
- vérifie les permissions ;
- exécute ;
- capture l’erreur ;
- renvoie
tool_result.
Cette séparation est particulièrement importante pour la fiabilité et la sécurité.
Le cas des refus
Le document attire aussi l’attention sur un comportement différent :
HTTP 200
+
stop_reason = "refusal"
Ce n’est pas une erreur transitoire.
Le transport HTTP a fonctionné.
Le modèle a pris une décision de contenu.
Le document recommande donc de traiter ce cas comme fail fast et de ne pas tenter automatiquement la même requête.
Conceptuellement :
if response.stop_reason == "refusal":
raise ValueError(
"Model refused the request."
)
Le point essentiel :
refusal
≠
429
≠
timeout
Il ne faut pas appliquer la politique de retry des erreurs réseau.
Le tableau de décision
Le document fournit une table très utile pour la production.
| Erreur | Stratégie |
|---|---|
429 | Retry + backoff + retry-after + limite |
529 | Retry avec backoff |
5xx transitoire | Retry |
400 | Fail fast |
401 | Fail fast |
403 | Fail fast |
| Tool error | Dépend de la cause, toujours la rendre visible |
| Refusal | Fail fast, loguer, ne pas retry aveuglément |
Les fallbacks
Un retry budget finit toujours par s’épuiser.
Il faut alors décider avant la production ce que l’application fait.
Exemples possibles cités ou suggérés par le document :
cached result
simpler path
fallback path
clean error to user
Par exemple :
API failure
↓
3 retries exhausted
↓
cached response available?
├── yes → use cache
└── no → graceful error
Le comportement final ne doit pas être :
unhandled exception
par défaut.
Exemple complet de stratégie
def answer_with_resilience(make_call):
max_attempts = 4
for attempt in range(max_attempts):
try:
return make_call()
except ApiError as exc:
status = exc.status_code
if status in {400, 401, 403, 404}:
raise
if status not in {429, 500, 502, 503, 504, 529}:
raise
if attempt == max_attempts - 1:
break
retry_after = getattr(
exc,
"retry_after",
None
)
if retry_after is not None:
wait = retry_after
else:
wait = min(
2 ** attempt,
8
)
wait += random.uniform(0, 0.5)
time.sleep(wait)
cached = load_cached_result()
if cached is not None:
return cached
raise ServiceUnavailable(
"Request could not be completed."
)
Ce code est illustratif ; le document source définit les principes de classification, backoff, limites et fallback, mais ne prescrit pas cette implémentation exacte.
Pourquoi prévoir le fallback dans le design document ?
Le module précédent de cette série montrait que le design document doit définir :
Failure handling
avant l’implémentation.
Il faut écrire :
429
→ retry
400
→ fail fast
tool unavailable
→ return error to Claude
retry budget exhausted
→ fallback / clean error
Ainsi, la première erreur réelle en production n’est pas le moment où l’équipe découvre qu’aucune décision n’avait été prise.
Tester les failure paths
Les chemins d’erreur doivent eux aussi être testés.
Par exemple :
def test_400_fails_fast():
...
def test_429_retries():
...
def test_retry_budget_is_capped():
...
def test_tool_error_sets_is_error():
...
def test_refusal_is_not_retried():
...
Une application avec :
excellent happy path
+
untested failure path
n’est pas réellement robuste.
Exemple : test d’un 429
def test_rate_limit_retries():
calls = 0
def make_call():
nonlocal calls
calls += 1
if calls < 3:
raise ApiError(status_code=429)
return "ok"
result = call_with_retry(make_call)
assert result == "ok"
assert calls == 3
On vérifie ici que le système :
does retry
mais également qu’il :
eventually stops retrying
Exemple : erreur terminale
def test_bad_request_does_not_retry():
calls = 0
def make_call():
nonlocal calls
calls += 1
raise ApiError(status_code=400)
with pytest.raises(ApiError):
call_with_retry(make_call)
assert calls == 1
Le comportement attendu est :
400
→ 1 call
→ fail
et non :
400
→ retry x5
Une erreur peut aussi venir du streaming
Le document aborde également le streaming dans la partie production.
Lorsqu’un stream est interrompu en cours de génération, le contenu partiel ne doit pas nécessairement être considéré comme une réponse complète.
Pour le tool use, c’est encore plus critique : les arguments JSON d’un tool peuvent arriver progressivement et ne sont pas sûrs à exécuter tant que le bloc n’est pas complet.
Il faut donc distinguer :
partial stream
de :
completed message
Un stream cassé peut nécessiter de retenter la requête complète, plutôt que de transmettre un résultat incomplet à l’étape suivante.
Anti-patterns à éviter
1. Retry sur toutes les exceptions
except Exception:
retry()
Mauvais, car :
400
401
403
ne doivent pas être retry.
2. Retry immédiat
time.sleep(0)
Mauvais pour les rate limits.
3. Retry illimité
while True:
sans cap.
Mauvais pour :
latency
cost
service load
4. Cacher un tool failure
except:
return ""
Très dangereux.
5. Double retry SDK + application
Peut multiplier les appels sans que l’équipe s’en rende compte.
6. Retenter un refusal
Un refusal n’est pas un incident réseau temporaire.
Ce qu’il faut retenir pour la certification
Première question
Would waiting and retrying
the exact same request
plausibly work?
Oui
retriable
Non
terminal
Classification importante
429 → retry
529 → retry
5xx transient → retry
400 → fail fast
401 → fail fast
403 → fail fast
404 → fail fast
selon le tableau présenté dans le module.
Pour une erreur retriable
backoff
+
jitter
+
retry-after
+
attempt cap
Pour une erreur terminale
fail fast
Tool failure
tool_result
+
is_error = true
et ne jamais convertir silencieusement l’erreur en résultat vide.
Refusal
HTTP 200
+
stop_reason = refusal
ne doit pas être traité comme une erreur réseau transitoire.
SDK retries
Toujours vérifier :
what the SDK already retries
avant d’ajouter une couche applicative supplémentaire.
Pièges d’examen
Scénario : l’API retourne 429.
→ Retry avec backoff, idéalement en respectant retry-after, avec nombre de tentatives limité.
Scénario : l’API retourne 400.
→ Fail fast.
Scénario : l’API retourne 401.
→ Corriger l’authentification ; le retry identique est inutile.
Scénario : un tool échoue et l’application renvoie une chaîne vide à Claude.
→ Mauvaise conception. Renvoyer un tool_result avec is_error: true.
Scénario : le SDK retente déjà trois fois et votre wrapper applicatif retente cinq fois.
→ Risque de retries imbriqués et multiplication involontaire des tentatives.
Scénario : la réponse HTTP est 200, mais stop_reason == "refusal".
→ Ce n’est pas une erreur transitoire. Ne pas appliquer aveuglément la politique de retry réseau.
À retenir en une phrase
La résilience commence par classer correctement les erreurs : retry uniquement ce que le temps peut réparer, avec backoff et budget limité ; faire échouer rapidement les erreurs terminales et toujours rendre les tool failures explicitement visibles à Claude.

