Erreurs, nouvelles tentatives, limites de débit et coûts
Reproduire les erreurs d'API les plus courantes, voir quelles nouvelles tentatives le SDK openai fait déjà pour vous, puis écrire une fonction d'appel avec délai d'expiration, attente exponentielle, limitation de la concurrence et suivi des coûts, prête à intégrer dans un projet.
- Environ 40 minutes
- Niveau : Intermédiaire
- Testé : 2026-09-14 deepseek-flash, openai 3.14
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
Quand on exécute des exemples sur son propre ordinateur, les appels d'API n'échouent presque jamais. Une fois en production, c'est autre chose : aux heures de pointe, le serveur renvoie 429 pour trop de requêtes, une requête reste bloquée une minute sans réponse, et il y a parfois des erreurs 500. Si le code n'y est pas préparé, un utilisateur voit un long message d'erreur, ou une page qui tourne indéfiniment.
Et il y a l'argent. Une boucle mal écrite, des nouvelles tentatives sans limite, et un mois de budget peut partir en une nuit.
Cette leçon reproduit d'abord les erreurs courantes, puis écrit une fonction d'appel que l'on peut intégrer directement dans un projet.
Les erreurs courantes
code/03-llm-apps/errors.py provoque volontairement trois erreurs :
print("== 1. 密钥错误")
try:
OpenAI(api_key="sk-wrong", base_url=BASE_URL).chat.completions.create(model=MODEL, messages=HELLO)
except openai.AuthenticationError as e:
print(f"{type(e).__name__},状态码 {e.status_code}")
print("== 2. 模型名写错")
try:
OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL).chat.completions.create(model="deepseek-flsh", messages=HELLO)
except openai.APIStatusError as e:
print(f"{type(e).__name__},状态码 {e.status_code},{e.message[:100]}")
print("== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)")
start = time.time()
try:
OpenAI(api_key=os.environ["LLM_API_KEY"], base_url=BASE_URL, timeout=0.5, max_retries=0).chat.completions.create(
model=MODEL, messages=[{"role": "user", "content": "写一篇 800 字的文章"}], extra_body=NO_THINKING)
except openai.APITimeoutError as e:
print(f"{type(e).__name__},用了 {time.time() - start:.1f} 秒")
Résultat :
== 1. 密钥错误
AuthenticationError,状态码 401
== 2. 模型名写错
BadRequestError,状态码 400,Error code: 400 - {'error': {'message': 'The supported API model names are deepseek-flash, deepseek-
== 3. 超时(故意把超时设成 0.5 秒,并关掉 SDK 自带的重试)
APITimeoutError,用了 0.7 秒
Le SDK openai transforme les différentes erreurs en différentes classes d'exception, que l'on peut traiter séparément selon leur type. Les plus courantes :
| Exception | Code de statut | Cause | Faut-il réessayer ? |
|---|---|---|---|
AuthenticationError |
401 | Clé erronée ou invalide | Non, le résultat sera toujours le même |
PermissionDeniedError |
403 | Pas d'autorisation | Non |
BadRequestError |
400 | La requête elle-même pose problème : nom de modèle erroné, paramètre invalide, longueur de contexte dépassée | Non, il faut corriger le code |
RateLimitError |
429 | Trop de requêtes, débit limité | Oui, après une attente |
InternalServerError |
500 et plus | Problème côté serveur | Oui, c'est généralement passager |
APITimeoutError |
aucun | Pas de réponse après une longue attente | Oui |
APIConnectionError |
aucun | Réseau injoignable | Oui |
La règle est simple : si l'erreur vient de vous, réessayer ne sert à rien ; si elle vient de l'autre côté ou du réseau, on peut réessayer. Le message d'erreur pour un nom de modèle erroné est très clair : il liste directement les noms de modèles pris en charge ; il suffit de corriger.
Par ailleurs, quand le solde est insuffisant, DeepSeek renvoie le code 402, qui devient une APIStatusError générique dans le SDK. Cette erreur non plus ne doit pas être réessayée ; il faut vous prévenir pour recharger le compte.
Ce que le SDK fait déjà pour vous
Beaucoup l'ignorent : le SDK openai réessaie automatiquement par défaut. J'ai regardé le code source de la version actuelle (3.14) :
- Par défaut
max_retries=2, soit au plus 2 nouvelles tentatives après un échec. - Cas réessayés : délai dépassé, échec de connexion, et codes 408, 409, 429 ainsi que toute erreur 500 et plus. Quand le serveur demande explicitement dans les en-têtes de réponse de réessayer ou non, il obéit aussi.
- Entre deux tentatives, il attend, en partant de 0,5 seconde et en doublant à chaque fois, jusqu'à 8 secondes au plus.
- Le délai d'expiration par défaut est de 600 secondes, dont au plus 5 pour établir la connexion.
Autrement dit, même sans rien écrire, les 429 et 500 occasionnels sont réessayés silencieusement par le SDK. C'est une bonne chose, mais deux points méritent attention.
600 secondes de délai, c'est beaucoup trop. Aucun utilisateur n'attendra 10 minutes sur une page web. Pour une conversation classique, je conseille 30 à 60 secondes ; un peu plus pour des tâches complexes avec réflexion. Il suffit de passer timeout=60 à la création du client.
Vous ne voyez pas les nouvelles tentatives. Elles sont silencieuses : vous ne savez pas qu'un appel a en fait été tenté trois fois en attendant plus de dix secondes. Pour diagnostiquer « pourquoi est-ce si lent ? », cette information est précieuse.
Une fonction d'appel prête pour un projet
C'est pourquoi je désactive généralement les nouvelles tentatives intégrées du SDK et j'écris ma propre fonction d'appel, qui regroupe nouvelles tentatives, limitation et comptabilité :
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=BASE_URL,
timeout=60, # 单次请求最多等 60 秒
max_retries=0, # 关掉 SDK 自带的重试,由下面的函数统一处理,方便记录
)
RETRYABLE = (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, openai.InternalServerError)
limiter = threading.Semaphore(5) # 同一时刻最多 5 个请求在路上
log_lock = threading.Lock()
LOG = Path("calls.jsonl")
def call_llm(messages, max_attempts=4, **kwargs):
for attempt in range(1, max_attempts + 1):
start = time.time()
try:
with limiter:
response = client.chat.completions.create(model=MODEL, messages=messages, **kwargs)
except RETRYABLE as e:
if attempt == max_attempts:
raise
# 指数退避:1 秒、2 秒、4 秒……再加一点随机,避免大家同时重试
wait = 2 ** (attempt - 1) + random.random()
print(f" 第 {attempt} 次失败({type(e).__name__}),{wait:.1f} 秒后重试")
time.sleep(wait)
continue
record = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"model": response.model,
"seconds": round(time.time() - start, 2),
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"cost_usd": round(cost_usd(response.usage, MODEL), 6),
"attempts": attempt,
}
with log_lock:
with LOG.open("a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return response
Explications, bloc par bloc.
Ne réessayer que ce qui doit l'être. RETRYABLE liste les quatre exceptions réessayables. Les erreurs comme 401 ou 400 n'y figurent pas et sont levées directement, pour que vous les voyiez tout de suite.
Attente exponentielle avec gigue aléatoire. Premier échec, un peu plus d'une seconde d'attente ; deuxième, un peu plus de 2 ; troisième, un peu plus de 4. Doubler l'attente laisse au serveur le temps de se rétablir : s'il est surchargé, réessayer chaque seconde ne ferait qu'empirer les choses. La partie aléatoire random.random() évite que de nombreuses requêtes échouent au même moment puis réessaient toutes au même moment, créant des vagues successives d'assaut.
Limiter le nombre de tentatives. Si 4 tentatives échouent, on abandonne et on lève l'exception vers l'appelant. N'écrivez jamais de tentatives infinies.
Limiter la concurrence. threading.Semaphore(5) garantit qu'au plus 5 requêtes sont en cours à un instant donné. Les fournisseurs limitent la concurrence et le nombre de requêtes par minute (en septembre 2026, la documentation de DeepSeek indique une concurrence maximale de 2500 pour deepseek-flash et de 500 pour deepseek-v4-pro) ; mieux vaut se limiter soi-même que d'attendre les 429. Surtout, cela empêche un bug dans le code d'envoyer instantanément des milliers de requêtes.
Enregistrer chaque appel. Heure, modèle, durée, tokens d'entrée et de sortie, coût, nombre de tentatives, écrits en une ligne JSON ajoutée à calls.jsonl. Le coût est calculé avec le cost_usd de la leçon 4 du module 01. Ce journal permet de répondre à « combien coûte cette fonctionnalité par jour ? », « quelles requêtes sont particulièrement lentes ? », « à quelle fréquence réessaie-t-on ? ». La leçon 3 du module 06 construit sur cette base un système complet de journaux et de supervision.
Essai : 20 requêtes en parallèle
questions = [f"用一句话解释 HTTP 状态码 {code} 的含义。" for code in [200, 201, 204, 301, 302, 304, 400, 401, 403, 404,
405, 408, 409, 418, 429, 500, 502, 503, 504, 505]]
with ThreadPoolExecutor(20) as pool:
answers = list(pool.map(lambda q: call_llm([{"role": "user", "content": q}], extra_body=NO_THINKING), questions))
20 threads appellent en même temps, mais limiter n'en laisse passer que 5 à la fois :
== 4. 并发 20 个请求,最多同时 5 个,每次调用记账
20 个请求用了 3.5 秒
第一条回答: HTTP 状态码 200 表示服务器成功处理了请求,并正常返回了所请求的资源。
日志共 20 条,总花费 0.000655 美元,第一条:{'time': '2026-09-14 21:35:48', 'model': 'deepseek-flash', 'seconds': 0.62, 'prompt_tokens': 16, 'completion_tokens': 19, 'cost_usd': 2.8e-05, 'attempts': 1}
Les 20 requêtes ont pris 3,5 secondes ; en série, une par une, il faudrait environ 12 secondes. Cette exécution n'a rencontré aucune erreur nécessitant une nouvelle tentative, donc chaque enregistrement a attempts à 1.
Quelques garde-fous pour la facture
Nouvelles tentatives et contrôle de la concurrence protègent contre les incidents ; les mesures suivantes protègent la facture :
- Fixer
max_tokenspour chaque appel. Pour empêcher le modèle de produire sans fin. Avec la réflexion, soyez un peu plus généreux ; la leçon 3 du module 00 a montré que la réflexion consomme ce quota. - Limiter les boucles. Boucle d'appel d'outils, boucle de nouvelles tentatives : toutes doivent avoir un nombre maximal. La boucle d'outils de la leçon 3 est limitée à 5 tours pour cette raison.
- Configurer une alerte de solde sur la plateforme. DeepSeek est prépayé : quand le solde est épuisé, tout s'arrête, ce qui constitue une limite naturelle. Mieux vaut quand même ne recharger que ce qu'il faut pour une période donnée et surveiller le solde.
- Lire le journal. Un coup d'œil quotidien au coût total dans
calls.jsonlsuffit pour repérer une hausse anormale.
Exercices
- Passez la concurrence de
limiterà 1 puis à 20, et comparez le temps total des 20 requêtes. - Simulez une panne : définissez une fonction qui lève
openai.APITimeoutErroraux deux premiers appels et n'appelle réellement le modèle qu'au troisième. Mettez-la à la place danscall_llm, et observez la sortie des nouvelles tentatives et des attentes, ainsi queattemptsdans le journal. (Indice :openai.APITimeoutError(request=...)demande un paramètrerequest; utilisezhttpx.Request("POST", "https://example.com").) - Écrivez un petit script qui lit
calls.jsonlet affiche le nombre total d'appels, le coût total, la durée moyenne et les 3 appels les plus lents.
Auto-test
1. Face à une erreur 401, faut-il réessayer ? Et face à une 429 ?
Une 401 signifie une clé erronée : quel que soit le nombre de tentatives, le résultat est le même ; il faut lever l'erreur directement pour que quelqu'un vérifie la clé. Une 429 signifie trop de requêtes, une limitation passagère : il faut attendre un peu avant de réessayer, avec une attente qui s'allonge à chaque fois.
2. Pourquoi l'attente entre deux tentatives doit-elle doubler à chaque fois, avec en plus un peu d'aléatoire ?
Doubler laisse au serveur le temps de se rétablir ; réessayer souvent alors qu'il est surchargé ne ferait qu'empirer les choses. L'aléatoire décale les nouvelles tentatives des nombreuses requêtes qui ont échoué en même temps, pour éviter qu'elles ne se précipitent de nouveau sur le serveur au même instant.
3. Sans aucun réglage, le SDK openai réessaie-t-il automatiquement ?
Oui. La version actuelle a max_retries=2 par défaut : en cas de délai dépassé, d'échec de connexion, ou de codes 408, 409, 429 et 500 et plus, il attend un peu puis réessaie automatiquement, au plus 2 fois. Le délai par défaut est de 600 secondes, bien trop long pour la plupart des usages interactifs ; mieux vaut définir soi-même timeout.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…