@@ -15,6 +15,22 @@ class _ToolsUnsupported(Exception):
1515 response body preview for logging."""
1616
1717
18+ class LLMResponseError (requests .HTTPError ):
19+ """Non-OK HTTP response from the chat-completions endpoint that
20+ exhausted retries (or wasn't retryable to begin with). Carries the
21+ status code, upstream reason phrase, and a short preview of the
22+ response body so the web UI / action log can show *why* the request
23+ failed without re-fetching it.
24+ """
25+
26+ def __init__ (self , status_code : int , reason : str , url : str , body_preview : str ):
27+ self .status_code = status_code
28+ self .reason = reason
29+ self .url = url
30+ self .body_preview = body_preview
31+ super ().__init__ (f"{ status_code } { reason } for { url } : { body_preview } " )
32+
33+
1834@dataclass
1935class ToolCall :
2036 """One tool/function call emitted by the assistant. ``arguments`` is
@@ -196,6 +212,35 @@ def complete(
196212 est_input_tokens = est_input_tokens ,
197213 )
198214
215+ # Cap any single retry wait, even if the server hands us a huge
216+ # Retry-After. 120s is plenty for dynamic-rate-limit recovery
217+ # without letting a misbehaving upstream pin a worker forever.
218+ _RETRY_WAIT_CEILING_SECONDS = 120.0
219+
220+ @staticmethod
221+ def _retry_delay (attempt : int , response : "requests.Response" ) -> float :
222+ """Compute how long to sleep before retrying. Honors ``Retry-After``
223+ (seconds or HTTP-date form) when the server provides one; otherwise
224+ falls back to exponential backoff (2,4,8,...)."""
225+ ra = response .headers .get ("Retry-After" )
226+ # Guard against non-string values (e.g. Mock objects in tests).
227+ # Real ``requests.Response`` only ever returns ``str | None`` here.
228+ if isinstance (ra , str ) and ra :
229+ try :
230+ return min (float (ra ), ChatCompletionClient ._RETRY_WAIT_CEILING_SECONDS )
231+ except ValueError :
232+ # HTTP-date form (rare on LLM endpoints) — parse it.
233+ try :
234+ from email .utils import parsedate_to_datetime
235+
236+ target = parsedate_to_datetime (ra )
237+ secs = (target .timestamp () - time .time ())
238+ if secs > 0 :
239+ return min (secs , ChatCompletionClient ._RETRY_WAIT_CEILING_SECONDS )
240+ except Exception : # noqa: BLE001
241+ pass
242+ return float (2 ** attempt )
243+
199244 @staticmethod
200245 def _estimate_input_tokens (
201246 messages : list [dict [str , Any ]], tools : Optional [list [dict [str , Any ]]]
@@ -246,14 +291,23 @@ def _post_with_retries(
246291 timeout = 300 ,
247292 stream = self .stream ,
248293 )
249- if r .status_code >= 500 and attempt < attempts :
294+ # 429 (rate limit) and 5xx are retryable. 429 in particular
295+ # gets hit during agentic tool loops on providers with
296+ # bursty-traffic dynamic limits (e.g. Together.ai via HF
297+ # Router, where a few quick tool turns can exceed a 1 RPM
298+ # cap). Honor Retry-After when the server provides it.
299+ if (
300+ r .status_code == 429 or r .status_code >= 500
301+ ) and attempt < attempts :
302+ delay = self ._retry_delay (attempt , r )
250303 log .warning (
251- "LLM call attempt %d/%d returned %d; retrying" ,
304+ "LLM call attempt %d/%d returned %d; retrying in %.1fs " ,
252305 attempt ,
253306 attempts ,
254307 r .status_code ,
308+ delay ,
255309 )
256- time .sleep (2 ** attempt )
310+ time .sleep (delay )
257311 continue
258312 if not r .ok :
259313 # Surface the upstream error body so the action log
@@ -274,7 +328,9 @@ def _post_with_retries(
274328 "not support function-calling for this model)"
275329 )
276330 raise _ToolsUnsupported (body_preview )
277- r .raise_for_status ()
331+ raise LLMResponseError (
332+ r .status_code , r .reason or "" , url , body_preview
333+ )
278334 if self .stream :
279335 (
280336 content ,
0 commit comments