fix(llm): surface the real error when retries are exhausted, cap OSError backoff - #304
Open
atulya-singh wants to merge 1 commit into
Open
fix(llm): surface the real error when retries are exhausted, cap OSError backoff#304atulya-singh wants to merge 1 commit into
atulya-singh wants to merge 1 commit into
Conversation
…backoff Three problems in LLMClient._call_with_retry, all on the retry path: 1. A persistent retryable status (429/503/529/...) fell out of the loop and raised a bare RuntimeError, throwing away the HTTPError. preflight() classifies failures by digging the HTTPError out of RuntimeError.__cause__, so a rate-limited key reported "All models failed: LLM call failed after 3 retries" instead of "Rate limited - try again in a moment". The final attempt now re-raises the original exception, and the (now unreachable) trailing RuntimeError chains via `from last_exc`. This also restores the diagnostics from aiming-lab#234, which were reverted by the v0.5.0 release commit (12d3fd8). 2. The HTTPError branch slept its full backoff *after* the last attempt, before failing anyway - up to 390s of dead wait per model in the chain. N attempts now produce N-1 sleeps. 3. The TimeoutError/OSError branch never applied the _MAX_BACKOFF_SEC ceiling the other two branches use, so delays doubled unbounded (max_retries=12 reached a 1024s sleep). It now caps at 300s like the rest. The existing preflight tests patched chat() directly, so they never exercised the retry loop and missed all three. Added tests that drive _raw_call instead.
This was referenced Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three bugs in
LLMClient._call_with_retry, all on the path where retries run out. They're separate symptoms but they live in the same ~40 lines and the fix for one is the fix for the others, so I've kept them together.The error gets thrown away when retries are exhausted
This is the one that actually bites users. On a persistent retryable status (429, 503, 529…), the loop runs out and raises a bare
RuntimeError, dropping theHTTPErroron the floor.That matters because
preflight()classifies failures by pulling theHTTPErrorback out ofRuntimeError.__cause__:The cause was never set, so that lookup always missed. With a rate-limited key you got:
when the code has a perfectly good message sitting right there for exactly this case:
Same story for 401/403/404 behind a retry.
doctorandpreflightboth go through this, which I suspect is some of what was going on in #292 and #241.The final attempt now re-raises the original exception instead of falling out of the loop. The trailing
RuntimeErroris unreachable for any sanemax_retriesnow, but I left it as a guard formax_retries <= 0and chained it withfrom last_excso it can't swallow context either.Heads up: part of this is a regression
The diagnostics here were already fixed once in #234 (8ec230d, "track last error across retries"). The v0.5.0 release commit (12d3fd8) reverted it — silently, as far as I can tell, since it's a 318-file +42k/−8.6k commit and nothing in it mentions the retry path.
git log -S"last_err" -- researchclaw/llm/client.pyshows it going in and right back out.I went a bit further than #234 did: it tracked the error as a formatted string for the message, which reads better but still leaves
__cause__unset, sopreflight()stays broken. Keeping the exception object fixes both.Might be worth a look at what else went out in that release commit.
It sleeps after the last attempt
The
HTTPErrorbranch had no "is this the final attempt?" guard, so it slept the full backoff after the last try and then failed anyway. With the 300s ceiling plus 30% jitter that's up to ~390s of dead waiting per model, andchat()walks the whole fallback chain, so you pay it per model.The
URLErrorandOSErrorbranches already guarded this withif attempt < self.config.max_retries - 1;HTTPErrorjust never got it. N attempts now means N−1 sleeps.The backoff ceiling wasn't applied to connection errors
_MAX_BACKOFF_SEC = 300is described as a "5-minute ceiling for retry delays", and theHTTPErrorandURLErrorbranches bothmin()against it. TheTimeoutError/OSErrorbranch didn't — it just didretry_base_delay * (2**attempt)and let it rip. Measured delays atmax_retries=12:That's a 17-minute sleep on a flaky connection, from the branch that catches
ConnectionResetErrorand friends — i.e. exactly the transient case you'd want to recover from quickly. Now capped like the other two.Why the tests didn't catch any of this
The existing preflight tests patch
chat()directly:So
test_preflight_429_rate_limitedpasses while the real 429 path is broken — it never enters the retry loop at all. The new tests patch_raw_callinstead, so they go through_call_with_retryfor real, and they stubtime.sleepto assert on the delays rather than actually waiting.Added five: the HTTPError and URLError re-raise cases, preflight reporting a rate limit through the real path, the sleep count, and the backoff cap.
Testing
pytest tests/→ 2802 passed, 56 skipped. Same as before the change; the one warning is a pre-existing unawaited-coroutine thing intest_servers.py, unrelated.