Преглед изворни кода

API_TIMEOUT : une réponse trop lente n'est plus relancée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet пре 1 дан
родитељ
комит
48fc8ba079
6 измењених фајлова са 69 додато и 6 уклоњено
  1. 4 2
      .env.exemple
  2. 2 0
      CHANGELOG.md
  3. 1 0
      README.md
  4. 8 4
      bot/claude_client.py
  5. 7 0
      bot/discord_bot.py
  6. 47 0
      tests/test_timeout.py

+ 4 - 2
.env.exemple

@@ -21,12 +21,14 @@ THINKING=on
 # Niveau d'effort : low, medium, high, xhigh ou max (xhigh et max nécessitent THINKING=on,
 # sauf sur claude-opus-5-5). Vide = valeur par défaut du modèle.
 EFFORT=medium
-# Délai maximal d'attente d'une réponse, en secondes
+# Délai maximal d'attente d'une réponse, en secondes, relances comprises : une requête trop
+# lente n'est pas relancée. Compte environ 1 seconde pour 80 tokens de MAX_TOKENS ; le bot
+# prévient au démarrage si c'est trop court.
 API_TIMEOUT=60
 
 # --- Accès à Internet ----------------------------------------------------
 # Recherche web et lecture de liens : off ou on (payant : voir PRICE_WEB_SEARCH_PER_1000).
-# Avec on, prévois un API_TIMEOUT d'au moins 90 secondes.
+# Avec on, prévois un API_TIMEOUT d'au moins 90 secondes (40 de plus que sans la recherche).
 WEB_SEARCH=off
 # Nombre maximal de recherches (et de pages lues) par message
 WEB_MAX_USES=3

+ 2 - 0
CHANGELOG.md

@@ -18,6 +18,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **« Modifier » ne signale plus de faux conflit.** Quand l'écriture sur le disque prenait plus de 3 secondes, Discord considérait l'envoi comme échoué alors que le texte était enregistré : la fenêtre restait ouverte, et un second clic répondait que le fichier avait changé entre-temps. Le bot répond maintenant tout de suite à Discord, avant d'enregistrer, et un même texte envoyé deux fois est accepté.
 
+- **Une réponse trop lente n'est plus relancée trois fois.** Quand Claude dépassait `API_TIMEOUT`, la requête était recommencée jusqu'à 3 fois, recherches web comprises (et refacturées) : un membre pouvait attendre 4 minutes avant « Je mets trop de temps à répondre ». `API_TIMEOUT` borne maintenant l'attente totale ; une erreur passagère (API surchargée) est toujours réessayée dans cette limite. Le bot prévient au démarrage si `API_TIMEOUT` est trop court pour `MAX_TOKENS` (environ 80 tokens par seconde), et 40 secondes de plus avec `WEB_SEARCH=on`.
+
 - **Une personnalité illisible ne rend plus Rika muette.** Un fichier de personnalité qui n'est pas en UTF-8 (enregistré en ANSI avec le Bloc-notes, par exemple), illisible, ou un `PERSONALITY_FILE` qui désigne un dossier (ConfigMap montée comme dossier sur OpenShift) faisait échouer chaque message sans réponse. Rika garde maintenant la dernière personnalité valide, ou celle par défaut au démarrage, et le journal indique une seule fois le problème et comment le corriger. Le fichier est relu dès qu'il est modifié.
 
 ---

+ 1 - 0
README.md

@@ -107,6 +107,7 @@ Tous les réglages se trouvent dans le `.env`, et chacun est expliqué dans `.en
 | `MAX_TOKENS` | Longueur maximale d'une réponse (réflexion et notes de mémoire comprises), en tokens ; ne coûte que si elle est atteinte. Avec la mémoire, au moins 4000 : le bot prévient au démarrage si c'est trop peu | `4000` |
 | `MAX_HISTORY_LENGTH` | Messages gardés en mémoire dans le salon dédié (jusqu'à la moitié de plus avant d'y revenir d'un coup, ce qui garde la mémoire en cache) | `10` |
 | `CONTEXT_MESSAGE_LIMIT` | Messages lus autour d'une mention | `20` |
+| `API_TIMEOUT` | Attente maximale d'une réponse, en secondes, relances comprises (une requête trop lente n'est pas relancée) ; le bot prévient au démarrage s'il est trop court pour `MAX_TOKENS` | `60` |
 | `WEB_SEARCH` | Recherche web et lecture de liens (`on` ou `off`) ; prévois alors un `API_TIMEOUT` d'au moins 90 secondes | `off` |
 | `PRESENCE` | Statut Discord et absences (`on` ou `off`) ; les `PRESENCE_*` règlent les chances de chaque statut et la nuit | `on` |
 | `MEMORY` | Mémoire des membres (`on` ou `off`) ; `MEMORY_MEMBER_LIMIT` et `MEMORY_SERVER_LIMIT` règlent la taille des fiches et du fichier général (3000 et 5000 caractères) | `off` |

+ 8 - 4
bot/claude_client.py

@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import base64
 import logging
 import re
@@ -190,7 +191,8 @@ class ClaudeClient:
     def __init__(self, settings: Settings, memory: MemoryStore | None = None):
         self.settings = settings
         self.memory = memory  # None : mémoire des membres désactivée
-        # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
+        # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…),
+        # dans la limite de API_TIMEOUT au total (voir _call).
         self._client = anthropic.AsyncAnthropic(
             api_key=settings.anthropic_api_key,
             timeout=settings.api_timeout,
@@ -447,12 +449,14 @@ class ClaudeClient:
     async def _call(self, params: dict, attachments: set[str] | frozenset[str] = frozenset()):
         """Appel à l'API ; attachments : pièces jointes envoyées ("image", "pdf"), pour les messages d'erreur."""
         try:
-            return await self._client.messages.create(**params)
+            # API_TIMEOUT borne l'attente totale, relances du SDK comprises : une erreur rapide (surcharge)
+            # est encore réessayée, mais une requête trop lente n'est pas relancée (recherches refacturées).
+            return await asyncio.wait_for(self._client.messages.create(**params), self.settings.api_timeout)
         except anthropic.RateLimitError:
             logger.warning("Limite de requêtes Anthropic atteinte.")
             raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
-        except anthropic.APITimeoutError:
-            logger.warning("Délai dépassé lors de l'appel à Claude.")
+        except (anthropic.APITimeoutError, asyncio.TimeoutError):
+            logger.warning("Délai dépassé lors de l'appel à Claude (API_TIMEOUT=%g s).", self.settings.api_timeout)
             raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
         except anthropic.APIConnectionError as exc:
             logger.error("Connexion à l'API impossible : %s", exc)

+ 7 - 0
bot/discord_bot.py

@@ -193,6 +193,13 @@ class ChatBot(commands.Bot):
                 prices = (" S'il existe bien, renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK "
                           "pour estimer les coûts.")
             logger.warning("Modèle %s inconnu du bot : vérifie CLAUDE_MODEL dans le .env.%s", s.model, prices)
+        # Une réponse qui remplit MAX_TOKENS s'écrit à environ 80 tokens par seconde au mieux ;
+        # les recherches web et les pages lues ajoutent leur propre attente.
+        needed = s.max_tokens // 80 + (40 if s.web_search else 0)
+        if s.api_timeout < needed:
+            logger.warning("API_TIMEOUT (%g s) est court pour MAX_TOKENS=%s%s : une longue réponse serait "
+                           "abandonnée (« Je mets trop de temps à répondre »). Mets au moins %s dans le .env.",
+                           s.api_timeout, s.max_tokens, " avec la recherche web" if s.web_search else "", needed)
         if self.memory:
             # Un fichier plein se réécrit d'un seul appel d'outil, qui doit tenir dans MAX_TOKENS avec
             # la réflexion : environ 2,5 caractères par token en français (estimation prudente),

+ 47 - 0
tests/test_timeout.py

@@ -0,0 +1,47 @@
+"""Tests du délai maximal d'attente (API_TIMEOUT). Lancer depuis la racine du projet : python -m unittest"""
+
+import asyncio
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import anthropic
+
+from bot.claude_client import ClaudeClient, ClaudeError
+
+
+def fake_client(create) -> ClaudeClient:
+    # Sans __init__ : pas de client Anthropic à créer.
+    client = ClaudeClient.__new__(ClaudeClient)
+    client.settings = SimpleNamespace(api_timeout=0.2)
+    client._client = SimpleNamespace(messages=SimpleNamespace(create=create))
+    return client
+
+
+class TimeoutTest(unittest.IsolatedAsyncioTestCase):
+    async def test_total_wait_is_capped(self):
+        # Le SDK relancerait la requête après chaque délai dépassé : l'attente totale reste bornée.
+        async def slow(**_):
+            await asyncio.sleep(10)
+
+        client = fake_client(slow)
+        loop = asyncio.get_running_loop()
+        start = loop.time()
+        with self.assertLogs("chatbot.claude", "WARNING"), self.assertRaises(ClaudeError) as ctx:
+            await client._call({})
+        self.assertLess(loop.time() - start, 2)
+        self.assertIn("trop de temps", ctx.exception.user_message)
+
+    async def test_sdk_timeout_same_message(self):
+        create = mock.AsyncMock(side_effect=anthropic.APITimeoutError(request=mock.Mock()))
+        with self.assertLogs("chatbot.claude", "WARNING"), self.assertRaises(ClaudeError) as ctx:
+            await fake_client(create)._call({})
+        self.assertIn("trop de temps", ctx.exception.user_message)
+
+    async def test_fast_answer_returned(self):
+        create = mock.AsyncMock(return_value="réponse")
+        self.assertEqual(await fake_client(create)._call({}), "réponse")
+
+
+if __name__ == "__main__":
+    unittest.main()