Skip to content

Commit 86adf79

Browse files
committed
add error handling
1 parent 84d9ae8 commit 86adf79

3 files changed

Lines changed: 134 additions & 5 deletions

File tree

reviewbot/llm_client.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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
1935
class 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,

reviewbot/webapp.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
installation_token,
4343
)
4444
from .github_client import GitHubClient
45+
from .llm_client import LLMResponseError
4546
from .reviewer import (
4647
DraftComment,
4748
ReviewDraft,
@@ -418,6 +419,34 @@ def _run_review_worker(job: Job) -> None:
418419
_push_event(job, "step", "error")
419420
_push_event(job, "error", job.error)
420421
_push_event(job, "done", "")
422+
except LLMResponseError as exc:
423+
# Upstream chat-completions endpoint returned a non-OK status
424+
# (and either wasn't retryable, or retries didn't recover).
425+
# Surface the status code + a body excerpt directly to the SSE
426+
# client — without it the UI just shows "review crashed (see
427+
# server log)" and the user has to SSH into the box to figure
428+
# out whether it was a 429 (rate limit), a 400 (bad schema),
429+
# auth, etc. The body comes from the LLM provider's own error
430+
# response — no auth tokens of ours are echoed there.
431+
log.warning(
432+
"LLM endpoint returned %d for job %s: %s",
433+
exc.status_code,
434+
job.id,
435+
exc.body_preview[:400],
436+
)
437+
job.status = "error"
438+
excerpt = exc.body_preview.strip()
439+
if len(excerpt) > 600:
440+
excerpt = excerpt[:600] + "…"
441+
reason_part = f" {exc.reason}" if exc.reason else ""
442+
job.error = (
443+
f"LLM endpoint returned {exc.status_code}{reason_part}: {excerpt}"
444+
if excerpt
445+
else f"LLM endpoint returned {exc.status_code}{reason_part}"
446+
)
447+
_push_event(job, "step", "error")
448+
_push_event(job, "error", job.error)
449+
_push_event(job, "done", "")
421450
except Exception as exc: # noqa: BLE001
422451
log.exception("review worker crashed for job %s", job.id)
423452
job.status = "error"

tests/test_llm_client.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import requests
66

7-
from reviewbot.llm_client import ChatCompletionClient
7+
from reviewbot.llm_client import ChatCompletionClient, LLMResponseError
88

99

1010
def _interrupted_iter_lines(prefix_lines: list[str], exc: Exception):
@@ -158,6 +158,50 @@ def test_complete_retries_on_5xx_then_succeeds(self) -> None:
158158
self.assertEqual(result.content, "ok")
159159
self.assertEqual(mock_post.call_count, 2)
160160

161+
def test_complete_retries_on_429_then_succeeds(self) -> None:
162+
rate_limited = Mock(
163+
status_code=429,
164+
ok=False,
165+
reason="Too Many Requests",
166+
text="rate limit",
167+
headers={"Retry-After": "1"},
168+
)
169+
ok = Mock(
170+
status_code=200,
171+
json=Mock(return_value={"choices": [{"message": {"content": "ok"}}]}),
172+
raise_for_status=Mock(),
173+
)
174+
with patch("reviewbot.llm_client.time.sleep") as mock_sleep, patch(
175+
"reviewbot.llm_client.requests.post", side_effect=[rate_limited, ok]
176+
) as mock_post:
177+
client = ChatCompletionClient("https://example.com/v1", "token", "fixed-model")
178+
result = client.complete([{"role": "user", "content": "hi"}])
179+
180+
self.assertEqual(result.content, "ok")
181+
self.assertEqual(mock_post.call_count, 2)
182+
# Honored the Retry-After: 1 header, capped to the per-wait ceiling.
183+
mock_sleep.assert_called_once_with(1.0)
184+
185+
def test_complete_raises_llmresponseerror_with_status_and_body_after_4xx(self) -> None:
186+
bad = Mock(
187+
status_code=400,
188+
ok=False,
189+
reason="Bad Request",
190+
text='{"error":{"message":"response_format.type bad"}}',
191+
headers={},
192+
)
193+
with patch("reviewbot.llm_client.requests.post", return_value=bad):
194+
client = ChatCompletionClient("https://example.com/v1", "token", "fixed-model")
195+
with self.assertRaises(LLMResponseError) as ctx:
196+
client.complete([{"role": "user", "content": "hi"}])
197+
198+
exc = ctx.exception
199+
self.assertEqual(exc.status_code, 400)
200+
self.assertEqual(exc.reason, "Bad Request")
201+
self.assertIn("response_format.type bad", exc.body_preview)
202+
# Also a real requests.HTTPError so existing handlers still catch it.
203+
self.assertIsInstance(exc, requests.HTTPError)
204+
161205
def test_complete_consumes_sse_stream_when_streaming_enabled(self) -> None:
162206
sse_lines = [
163207
'data: {"choices":[{"delta":{"content":"he"}}]}',

0 commit comments

Comments
 (0)