Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 41 additions & 20 deletions src/leanci/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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:
Expand All @@ -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))
Expand All @@ -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}"
Expand Down
24 changes: 24 additions & 0 deletions tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading