From 6f7a0b02d9e2d029789c9cf495490ad96146d77d Mon Sep 17 00:00:00 2001 From: Priyanshu Jha Date: Fri, 31 Jul 2026 12:57:08 +0530 Subject: [PATCH 1/2] Retry Groq tool_use_failed 400s during chat. Malformed tool XML from the model is transient; retry instead of failing the Action. --- src/leanci/llm.py | 50 ++++++++++++++++++++++++++++------------------- tests/test_llm.py | 24 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/leanci/llm.py b/src/leanci/llm.py index 72588b1..c1ae4dd 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,38 @@ 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 "" + if raw: + setattr(exc, "_leanci_body", raw) + if not raw: + return "" + return raw.decode("utf-8", errors="replace") def _retry_after_seconds(exc: HTTPError) -> float | None: @@ -186,16 +211,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 +220,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..0a18c13 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: + client = LLMClient(api_key="sk", model="llama-3.3-70b-versatile") + body = b'{"error":{"code":"tool_use_failed","message":"Failed to call a function"}}' + fp = MagicMock() + fp.read.return_value = body + err = HTTPError( + url="http://x", + code=400, + msg="Bad Request", + hdrs=None, # type: ignore[arg-type] + fp=fp, + ) + 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( From b89d06841d2164ad91d6879407acb21b7272ad4f Mon Sep 17 00:00:00 2001 From: Priyanshu Jha Date: Fri, 31 Jul 2026 12:58:24 +0530 Subject: [PATCH 2/2] Harden HTTP error body parsing for tool_use retries. Normalize urllib error payloads so Groq 400 retries work reliably in tests and CI. --- src/leanci/llm.py | 13 ++++++++++++- tests/test_llm.py | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/leanci/llm.py b/src/leanci/llm.py index c1ae4dd..0e14aec 100644 --- a/src/leanci/llm.py +++ b/src/leanci/llm.py @@ -194,8 +194,19 @@ def _peek_http_body(exc: HTTPError) -> str: 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", raw) + setattr(exc, "_leanci_body", bytes(raw)) + raw = bytes(raw) if not raw: return "" return raw.decode("utf-8", errors="replace") diff --git a/tests/test_llm.py b/tests/test_llm.py index 0a18c13..eb42107 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -249,16 +249,16 @@ def test_chat_retries_429_honors_retry_after_header() -> None: 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"}}' - fp = MagicMock() - fp.read.return_value = body err = HTTPError( url="http://x", code=400, msg="Bad Request", hdrs=None, # type: ignore[arg-type] - fp=fp, + fp=io.BytesIO(body), ) ok = _http_response(_assistant_payload(content="ok"))