diff --git a/src/leanci/llm.py b/src/leanci/llm.py index 72588b1..0e14aec 100644 --- a/src/leanci/llm.py +++ b/src/leanci/llm.py @@ -152,7 +152,7 @@ def _post_chat(self, body: dict[str, Any]) -> dict[str, Any]: return _load_json_object(raw) except HTTPError as exc: last_error = exc - if exc.code not in {429, 500, 502, 503, 504} or attempt >= attempts - 1: + if not _is_retriable_http(exc) or attempt >= attempts - 1: detail = _http_error_detail(exc) raise LLMError( f"LLM HTTP {exc.code} from {url}: {exc.reason}{detail}" @@ -167,13 +167,49 @@ def _post_chat(self, body: dict[str, Any]) -> dict[str, Any]: raise LLMError(f"LLM request to {url} failed: {last_error}") +def _is_retriable_http(exc: HTTPError) -> bool: + """Transient upstream failures worth retrying (incl. Groq tool_use_failed).""" + if exc.code in {429, 500, 502, 503, 504}: + return True + if exc.code == 400: + # Groq sometimes returns 400 when the model emits malformed tool XML. + return "tool_use_failed" in _peek_http_body(exc) + return False + + def _retry_delay_s(exc: HTTPError, attempt: int) -> float: """Backoff for retriable HTTP errors; honor Retry-After / body hints on 429.""" if exc.code == 429: hinted = _retry_after_seconds(exc) floor = _RATE_LIMIT_BACKOFF_S[min(attempt, len(_RATE_LIMIT_BACKOFF_S) - 1)] return max(hinted or 0.0, floor) - return float(_RETRY_BACKOFF_S[attempt]) + return float(_RETRY_BACKOFF_S[min(attempt, len(_RETRY_BACKOFF_S) - 1)]) + + +def _peek_http_body(exc: HTTPError) -> str: + """Read and stash the HTTP error body once for retry checks + error text.""" + raw = getattr(exc, "_leanci_body", None) + if raw is None: + try: + raw = exc.read() + except Exception: + return "" + # urllib may hand back a file-like; normalize to bytes. + if hasattr(raw, "read") and not isinstance(raw, (bytes, bytearray)): + try: + raw = raw.read() + except Exception: + return "" + if isinstance(raw, str): + raw = raw.encode("utf-8", errors="replace") + if not isinstance(raw, (bytes, bytearray)): + return "" + if raw: + setattr(exc, "_leanci_body", bytes(raw)) + raw = bytes(raw) + if not raw: + return "" + return raw.decode("utf-8", errors="replace") def _retry_after_seconds(exc: HTTPError) -> float | None: @@ -186,16 +222,7 @@ def _retry_after_seconds(exc: HTTPError) -> float | None: except ValueError: pass # Gemini often embeds "Please retry in 48.55s" in the JSON body. - try: - body = exc.read() - except Exception: - return None - if not body: - return None - text = body.decode("utf-8", errors="replace") - # Stash for final error formatting if this was the last attempt — body already - # consumed, so attach a copy for _http_error_detail via a private attr. - setattr(exc, "_leanci_body", body) + text = _peek_http_body(exc) match = re.search(r"retry in\s+([0-9]+(?:\.[0-9]+)?)\s*s", text, flags=re.I) if match: return float(match.group(1)) @@ -204,15 +231,9 @@ def _retry_after_seconds(exc: HTTPError) -> float | None: def _http_error_detail(exc: HTTPError) -> str: """Best-effort provider error body for CI/debug (truncate to keep logs readable).""" - raw = getattr(exc, "_leanci_body", None) - if raw is None: - try: - raw = exc.read() - except Exception: - return "" - if not raw: + text = _peek_http_body(exc).strip() + if not text: return "" - text = raw.decode("utf-8", errors="replace").strip() if len(text) > 800: text = text[:800] + "…" return f" — {text}" diff --git a/tests/test_llm.py b/tests/test_llm.py index 64100b7..eb42107 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -248,6 +248,30 @@ def test_chat_retries_429_honors_retry_after_header() -> None: sleep.assert_called_once_with(90.0) +def test_chat_retries_groq_tool_use_failed_400() -> None: + import io + + client = LLMClient(api_key="sk", model="llama-3.3-70b-versatile") + body = b'{"error":{"code":"tool_use_failed","message":"Failed to call a function"}}' + err = HTTPError( + url="http://x", + code=400, + msg="Bad Request", + hdrs=None, # type: ignore[arg-type] + fp=io.BytesIO(body), + ) + ok = _http_response(_assistant_payload(content="ok")) + + with ( + patch("leanci.llm.urlopen", side_effect=[err, ok]), + patch("leanci.llm.time.sleep") as sleep, + ): + result = client.chat([{"role": "user", "content": "hi"}]) + + assert result.content == "ok" + sleep.assert_called_once_with(2) + + def test_chat_retries_twice_on_5xx_then_raises() -> None: client = LLMClient(api_key="sk", model="gpt-4.1-mini") err = HTTPError(