- Points clés à retenir
- Liste de contrôle diagnostique : trouvez d'abord votre cause racine
- Cause 1 : Panne du fournisseur en cours de flux (surcharge, filtre de contenu, crash du fournisseur)
- Cause 2 : Timeout de lecture ou stagnation du flux inactif
- Cause 3 : Limite de débit (429) en cours de flux
- Cause 4 : Chunk malformé ou inattendu cassant votre analyseur
- Cause 5 : Connexion réseau interrompue
- Cause 6 : Échec d'authentification ou de quota se manifestant en cours de flux
- Modèle de réessai et de backoff qui gère les six
- Conclusion
- FAQ
- Articles recommandés
Un message d’erreur error: llm api error: an error occurred during streaming provient presque toujours de l’une des six sources suivantes : une panne du fournisseur en cours de flux envoyée comme un événement SSE après que la connexion a déjà renvoyé 200, un timeout de lecture côté client ou passerelle, une limite de débit (429) atteinte en cours de génération, un chunk malformé ou inattendu qui casse votre analyseur SSE, une connexion réseau interrompue, ou un problème d’authentification/quota qui ne se manifeste qu’une fois le flux ouvert. Vérifiez-les dans cet ordre — la plupart des échecs de streaming tombent dans les trois premiers.
Faites correspondre ce que vous observez à une cause avant de lire plus loin :
| Ce que vous observez | Aller à |
|---|---|
| Le flux démarre, quelques tokens arrivent, puis il s’arrête sans changement de statut HTTP | Cause 1 |
| Le flux stagne pendant 10s+ sans nouveau token, puis votre client lève un timeout | Cause 2 |
| L’erreur survient surtout lors des pics de trafic ou juste après une rafale de requêtes | Cause 3 |
L’exception mentionne KeyError, JSONDecodeError, ou un échec d’analyse, pas une terminaison réseau |
Cause 4 |
L’erreur est générique (APIConnectionError, ECONNRESET) et survient de manière irrégulière, y compris sur des réseaux stables |
Cause 5 |
| L’erreur se produit dès la première requête après avoir changé de clé, atteint un plafond de dépenses ou changé d’environnement | Cause 6 |
La liste de contrôle diagnostique complète ci-dessous répète cela avec la cause probable indiquée par ligne.
Points clés à retenir
- Une erreur de streaming peut arriver sous forme d’événement SSE
erroraprès que le code de statut HTTP a déjà renvoyé 200, donc une logique de réessai basée uniquement sur le code de statut ne la détecte pas. - L’API Messages d’Anthropic signale les surcharges en cours de flux comme
event: erroravec"type": "overloaded_error"dans le corps du flux, pas comme un nouveau HTTP 529. - OpenRouter documente le même schéma chez tous les fournisseurs : une fois le premier token émis, une panne arrive sous forme de
chat.completion.chunkavec un champerrorde premier niveau etfinish_reason: "error". - Les bibliothèques clientes qui supposent que chaque chunk streamé est en forme de succès planteront avec une erreur trompeuse (un
APIConnectionErrorau lieu de la vraie cause) lorsqu’un fournisseur envoie une charge utile d’erreur structurée en cours de flux. - La plupart des correctifs sont les mêmes quel que soit le fournisseur : analyser le corps du flux pour les événements d’erreur, définir un timeout de stagnation au niveau du token distinct du timeout de connexion, et utiliser un backoff exponentiel basé sur le type d’erreur, pas seulement sur le statut HTTP.
Liste de contrôle diagnostique : trouvez d’abord votre cause racine
Parcourez ceci avant de modifier du code. Chaque ligne associe un symptôme que vous pouvez observer à la section qui le corrige.
| Ce que vous observez | Cause probable | Aller à |
|---|---|---|
| Le flux démarre, quelques tokens arrivent, puis il s’arrête sans changement de statut HTTP | Erreur du fournisseur en cours de flux (surcharge, filtre de contenu, crash du fournisseur) | Cause 1 |
| Le flux stagne pendant 10s+ sans nouveau token, puis votre client lève un timeout | Timeout de lecture ou stagnation du flux inactif | Cause 2 |
| L’erreur survient surtout lors des pics de trafic ou juste après une rafale de requêtes | Limite de débit atteinte en cours de flux | Cause 3 |
L’exception mentionne KeyError, JSONDecodeError, ou un échec d’analyse, pas une terminaison réseau |
Chunk malformé ou inattendu | Cause 4 |
L’erreur est générique (APIConnectionError, ECONNRESET) et survient de manière irrégulière, y compris sur des réseaux stables |
Connexion interrompue ou erreur fournisseur masquée | Cause 5 et Cause 4 |
| L’erreur se produit dès la première requête après avoir changé de clé, atteint un plafond de dépenses ou changé d’environnement | Échec d’authentification ou de quota | Cause 6 |
Si vos logs n’affichent qu’un nom d’exception générique et aucun message en amont, cela en soi est un symptôme — voir Cause 4 et Cause 5 pour comprendre pourquoi les wrappers génériques cachent la vraie cause.
Cause 1 : Panne du fournisseur en cours de flux (surcharge, filtre de contenu, crash du fournisseur)
C’est la cause qui embrouille le plus de monde, car la requête semblait réussie. Une fois qu’une réponse en streaming commence, le code de statut HTTP et les en-têtes sont déjà engagés côté client. Si le fournisseur rencontre ensuite une panne — épuisement de la capacité, erreur interne, déclenchement d’un filtre de contenu après une sortie partielle, ou crash du processus du modèle — il ne peut pas changer le statut HTTP en code d’erreur. L’échec doit voyager à l’intérieur du flux lui-même sous forme d’événement spécial.
L’API Messages d’Anthropic documente cela directement : l’API « peut occasionnellement envoyer [des erreurs] dans le flux d’événements », et donne cet exemple exact pour une condition de surcharge arrivant en cours de flux :
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
C’est une véritable lacune dans beaucoup de logiques de réessai. Si votre code inspecte seulement response.status_code, il a déjà vu 200 avant que l’échec ne se produise, donc un réessai n’est jamais déclenché. Un compte rendu d’incident tiers décrivant ce schéma exact le dit clairement : « La logique de réessai basée sur le code de statut HTTP ne se déclenche jamais car le statut était déjà 200. Le résultat est une réponse tronquée silencieusement » — et recommande d’analyser le corps du flux pour les événements d’erreur plutôt que de se fier au seul code de statut.
La documentation d’OpenRouter décrit le même problème structurel chez tous les fournisseurs qu’il achemine, pas seulement un vendeur. Une fois le premier token écrit, « le statut HTTP 200 OK et les en-têtes sont déjà engagés — ils ne peuvent pas être modifiés », donc un échec du fournisseur « doit arriver in-band comme un événement SSE ». Ses causes documentées pour cette classe d’erreur :
- Déconnexion du fournisseur — la connexion amont tombe après une sortie partielle (problème réseau, crash du fournisseur, timeout de l’équilibreur de charge)
- Timeout du fournisseur — le modèle cesse de répondre en cours de génération et le délai de lecture expire
- Limite de tokens atteinte pendant la génération — le modèle atteint
max_tokensou la fenêtre de contexte se remplit pendant la production de la sortie - Filtre de contenu de sortie — un système de modération signale le texte généré après qu’une partie a déjà été diffusée
- Surcharge du fournisseur — l’amont renvoie une erreur de limite de débit ou de capacité après avoir commencé à diffuser
La charge utile d’erreur en cours de flux d’OpenRouter transporte l’erreur dans un chunk d’apparence normale, avec un finish_reason qui vous dit que le flux s’est terminé anormalement :
{"id":"gen-abc123","object":"chat.completion.chunk","created":1234567890,"model":"openai/gpt-4o","provider":"OpenAI","error":{"code":429,"message":"Rate limit exceeded","metadata":{"error_type":"rate_limit_exceeded"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
Comment confirmer que c’est votre cause : enregistrez le flux SSE brut (pas seulement la sortie textuelle analysée) pour une requête en échec. Si vous voyez un événement error ou un chunk avec un champ error de premier niveau quelque part dans le flux avant l’événement terminal, c’est ça.
Correctif : traitez les charges utiles finish_reason: "error" et event: error comme des échecs réessayables, pas comme des complétions réussies avec un contenu vide. Réessayez avec backoff sur le type d’erreur spécifique dans la charge utile, pas sur le statut HTTP, puisque le statut HTTP sera déjà 200.
Cause 2 : Timeout de lecture ou stagnation du flux inactif
Une stagnation diffère d’une erreur dure : la connexion reste ouverte, mais aucun nouveau token n’arrive pendant une période prolongée. La plupart des clients HTTP confondent deux concepts de timeout — un timeout total de requête, et un timeout par lecture (inactivité). Un flux long peut légitimement dépasser un timeout total court sur une réponse volumineuse, tandis qu’un flux réellement bloqué peut rester bien dans une fenêtre de timeout total si aucune vérification d’inactivité de lecture n’existe.
Le SDK Python OpenAI définit un timeout total de requête par défaut de 10 minutes : « Par défaut, les requêtes expirent après 10 minutes. Vous pouvez configurer cela avec une option timeout, qui accepte un float ou un objet httpx.Timeout… En cas de timeout, une APITimeoutError est levée. » Il réessaie également certains échecs automatiquement : « Les erreurs de connexion (par exemple, dues à un problème de connectivité réseau), 408 Request Timeout, 409 Conflict, 429 Rate Limit, et les erreurs >=500 Internal sont toutes réessayées par défaut », deux fois, configurables via max_retries.
Confirmez-le : mesurez l’écart entre le dernier token reçu et l’erreur. Une durée fixe correspondant à votre valeur de timeout configurée indique un timeout, pas un échec côté fournisseur.
Correctif : définissez deux timeouts, pas un — un timeout de connexion/total pour le cycle de vie de la requête, et un timeout de lecture inactif séparé qui se déclenche si aucun chunk n’arrive dans les N secondes. Cela vous permet de distinguer « le modèle est lent » de « le flux est mort ». 15 à 30 secondes est un timeout de lecture inactif raisonnable pour les complétions de chat ; augmentez-le si votre modèle a de longues phases silencieuses de « réflexion » avant le premier token.
Cause 3 : Limite de débit (429) en cours de flux
Les limites de débit rejettent généralement une requête avant qu’elle ne commence. Mais certaines passerelles appliquent des limites par token ou par fenêtre, donc une requête peut commencer à streamer et être interrompue une fois qu’elle dépasse un budget en cours de génération — la même forme d’erreur in-band que Cause 1, avec un code 429 arrivant à l’intérieur du flux au lieu du statut de réponse initial.
Confirmez-le : vérifiez si les échecs se regroupent lors des pics de trafic ou à un seuil cohérent de requêtes par minute. Un en-tête Retry-After, ou un champ équivalent dans la charge utile d’erreur, le confirme.
Correctif : respectez Retry-After lorsqu’il est présent, réduisez la concurrence avant de réessayer, et backoff exponentiel. Des occurrences fréquentes sont un signal pour réduire le nombre de flux parallèles ou passer à un niveau de compte supérieur, pas pour réessayer plus agressivement.
Cause 4 : Chunk malformé ou inattendu cassant votre analyseur
Toutes les « erreurs de streaming » ne sont pas la faute du modèle. Certaines sont entièrement côté client : votre analyseur SSE suppose que chaque chunk a une forme fixe, et une charge utile de forme différente — y compris une charge utile d’erreur légitime du fournisseur — brise cette supposition et lève une exception d’apparence non liée.
Un cas documenté : le gestionnaire de streaming ollama_chat de LiteLLM s’attendait à ce que chaque chunk contienne un champ message. Quand Ollama renvoyait à la place une erreur structurée comme {"error": "error parsing tool call: ..."}, le gestionnaire effectuait une recherche non protégée chunk["message"], levant KeyError: 'message'. Un gestionnaire d’exception plus large l’attrapait et le relançait comme APIConnectionError — une erreur à consonance réseau pour ce qui était en réalité un échec d’analyse JSON dans la sortie d’appel d’outil du modèle. Le rapport de bug est explicite sur le coût : les ingénieurs qui le déboguaient « ont enregistré reason=timeout / LLM request timed out pour ce qui était en fait du JSON d’appel d’outil malformé », coûtant « deux jours de mauvais diagnostic » à courir après des causes réseau et d’infrastructure qui n’étaient pas le problème.
Le schéma général : un fournisseur envoie une erreur dans une forme que votre code d’analyse n’attend pas, une exception de bas niveau se déclenche (KeyError, TypeError, une erreur de décodage JSON), et un bloc except générique l’enveloppe dans une erreur de connexion ou de streaming générique qui efface la vraie cause.
Confirmez-le : enregistrez le chunk brut avant que votre code d’encapsulation d’exception ne s’exécute. Si la charge utile brute a un champ error avec un vrai message, votre client a jeté des informations utiles en route vers l’erreur générique que vous voyez.
Correctif : vérifiez la présence d’une clé error avant d’accéder aux champs attendus comme message ou delta, et bifurquez dessus explicitement. Préservez le message d’erreur amont et le code de statut lors de la relance, au lieu de réduire chaque échec à un seul type d’exception générique.
Cause 5 : Connexion réseau interrompue
Parfois, la cause est vraiment le réseau : un proxy ou un équilibreur de charge ferme une connexion longue durée après une période d’inactivité, ou le réseau d’un client change en cours de requête. Cela ressemble à Cause 2 mais le correctif diffère — la connexion elle-même est perdue, pas seulement inactive dans votre propre fenêtre de timeout.
Confirmez-le : recherchez des erreurs de type réinitialisation de connexion (ECONNRESET, Broken pipe, Connection reset by peer) plutôt qu’une exception de timeout au niveau application, et vérifiez si les échecs sont corrélés avec le timeout de connexion inactive d’un intermédiaire connu (de nombreux équilibreurs de charge ont un défaut de 60s d’inactivité).
Correctif : si vous contrôlez le proxy ou l’équilibreur de charge, augmentez son timeout de connexion inactive au-dessus de la durée maximale attendue de votre flux, ou ajoutez des pings keep-alive. Si la coupure est hors de votre contrôle, traitez-la comme réessayable — la plupart des API de streaming ne supportent pas la reprise d’un flux partiel, donc un réessai signifie recommencer la génération.
Cause 6 : Échec d’authentification ou de quota se manifestant en cours de flux
Certaines passerelles valident l’authentification et la facturation de manière paresseuse — la connexion s’ouvre, puis la vérification d’authentification ou de solde a lieu pendant la génération du premier chunk, et un échec à cet endroit est signalé comme une erreur de streaming plutôt qu’un 401/402 propre au moment de la requête.
Confirmez-le : vérifiez si l’erreur se produit sur chaque requête d’une clé donnée, plutôt que par intermittence, et si elle a commencé juste après une rotation de clé, un événement de facturation, ou un changement d’environnement (clé de développement contre URL de base de production, ou vice versa).
Correctif : vérifiez le format de la clé (Authorization: Bearer <key>, pas la clé brute), confirmez que la clé est active, et vérifiez le solde du compte séparément du débogage au niveau de la requête. Chaque requête échouant de manière identique quel que soit le prompt ou le modèle pointe ici plutôt que vers les cinq causes précédentes.
Modèle de réessai et de backoff qui gère les six
Une seule stratégie de réessai peut couvrir les Causes 1 à 5 si elle vérifie le corps du flux, pas seulement le statut HTTP :
import time
import openai
def stream_with_recovery(client, **kwargs):
max_attempts = 3
for attempt in range(max_attempts):
try:
collected = ""
stream = client.chat.completions.create(stream=True, **kwargs)
for chunk in stream:
choice = chunk.choices[0] if chunk.choices else None
if choice and getattr(choice, "finish_reason", None) == "error":
raise RuntimeError(f"mid-stream error: {chunk}")
if choice and choice.delta.content:
collected += choice.delta.content
return collected
except (openai.APITimeoutError, openai.APIConnectionError, openai.RateLimitError) as e:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return collected
Cet exemple utilise la forme de client compatible OpenAI qui fonctionne également avec le point de terminaison de complétion de chat compatible OpenAI de Novita AI en définissant base_url="https://api.novita.ai/openai". Trois choses comptent au-delà du code ci-dessus :
- Vérifiez le contenu du chunk pour une erreur in-band avant de supposer une complétion normale, selon Cause 1.
- Utilisez un backoff exponentiel (
2 ** attempt, plafonné) plutôt que des réessais immédiats, surtout pour les limites de débit et les erreurs de surcharge. - Enregistrez le message d’erreur brut amont avant de l’envelopper dans votre propre type d’exception, afin que le débogage futur ne répète pas le mauvais diagnostic de deux jours de Cause 4.
Si un fournisseur ou un modèle spécifique échoue plus souvent que d’autres, acheminer cette classe de requêtes vers un modèle différent est une atténuation qui vaut la peine d’être mise en place avant d’en avoir besoin — voir exploitation d’un service LLM multi-fournisseur avec un objectif de disponibilité défini pour savoir comment définir cette politique de basculement plutôt que de l’improviser lors d’un incident.
Conclusion
Diagnostiquer cette erreur est un processus d’élimination, pas de devinette : utilisez la liste de contrôle en haut pour faire correspondre votre symptôme à l’une des six causes, puis confirmez-la avec le signal spécifique que chaque section mentionne — un événement error in-band, une durée de stagnation correspondant à votre timeout, un 429 corrélé à un pic, une charge utile brute que votre analyseur a étouffée, une chaîne de réinitialisation de connexion, ou un échec qui se répète sur chaque requête d’une clé. Les causes 1 à 3 sont les plus courantes, et les causes 1, 3 et 5 partagent le même correctif sous-jacent : arrêtez de vous fier au code de statut HTTP une fois qu’un flux a commencé, et réessayez avec backoff en fonction de ce que le flux lui-même rapporte.
Corriger la logique de réessai une fois, en utilisant le modèle ci-dessus, ferme les cinq premières causes en même temps. La cause 6 est l’exception — aucun nombre de réessais ne corrige une clé invalide ou un solde vide, donc traitez les échecs identiques sur chaque requête comme une vérification de configuration, pas un problème réseau.
FAQ
Pourquoi ma requête d’API LLM fonctionne-t-elle parfois et échoue-t-elle avec une erreur de streaming d’autres fois ?
Les échecs intermittents pointent vers une cause en cours de flux plutôt qu’un problème de configuration — les erreurs de configuration comme une mauvaise clé ou un mauvais point de terminaison échouent à chaque fois. Vérifiez d’abord la surcharge du fournisseur et les limites de débit, car les deux dépendent du trafic.
Une erreur de streaming est-elle la même chose qu’un timeout ?
Pas toujours. Un timeout signifie qu’aucune réponse n’est arrivée dans votre fenêtre configurée. Une erreur en cours de flux signifie qu’une réponse a commencé, que des tokens sont arrivés, puis qu’un événement d’échec a été envoyé à l’intérieur du flux. La gestion des erreurs doit distinguer les deux, car les correctifs diffèrent.
Pourquoi mon message d’erreur dit-il « erreur de connexion » alors que le vrai problème était autre chose ?
De nombreuses bibliothèques clientes encapsulent les exceptions inattendues dans un type d’erreur de connexion générique lorsqu’un chunk ne correspond pas à la forme attendue par l’analyseur — voir Cause 4 pour un cas documenté où une erreur d’analyse JSON a été signalée comme un timeout de connexion pendant deux jours avant que la vraie cause ne soit trouvée.
Puis-je reprendre un flux après une erreur en cours de flux au lieu de recommencer ?
La plupart des API de streaming compatibles OpenAI et de style Anthropic ne supportent pas la reprise d’un flux partiel au point de l’échec. Réessayez la requête complète, en rejetant la sortie partielle plutôt qu’en l’ajoutant, pour éviter un contenu dupliqué.
Dois-je toujours utiliser le streaming pour les appels d’API LLM ?
Le streaming évite qu’une seule grande requête n’expire, mais il introduit le mode d’échec de sortie partielle couvert tout au long de ce guide. Pour les réponses courtes où vous n’avez pas besoin d’afficher une sortie partielle, une requête non streamée est plus simple à gérer en termes d’erreurs.
Que signifie finish_reason: error dans une réponse streamée ?
C’est un signal terminal que certaines passerelles attachent au dernier chunk d’un flux qui a échoué en cours de route, distinct des valeurs normales comme stop ou length. Traitez-le comme une génération échouée, même si le statut HTTP de la requête était 200.
Articles recommandés
- Meilleur service LLM multi-fournisseur pour un coût réduit et une disponibilité plus élevée ? — Conception de SLO, surveillance de la santé des fournisseurs et playbooks d’incident pour les équipes gérant du trafic LLM sur plusieurs fournisseurs.
- SDK Python OpenAI : installation, configuration et intégration pratique — Configuration client, streaming, réessais et options de timeout pour le SDK utilisé dans l’exemple de code ci-dessus.
- Qu’est-ce que la limitation de débit ? Un guide pratique pour les services d’IA — Comment les limites de débit sont appliquées et comment concevoir un comportement de réessai autour d’elles.
