fix(llm): surface the real error when retries are exhausted, cap OSError backoff - #304
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.
The v0.5.0 release commit (12d3fd8) appears to have been branched from around 2026-04-09 and squashed onto main on 05-19, so everything merged in between was reverted without any mention of it. Fixes dated 04-06 to 04-08 survive intact; almost everything from 04-10 onward is gone. Restores three correctness fixes whose removal is unambiguous, each of which maps to an issue that is currently closed but reproducible again at HEAD: - Doctor gateway probe: health.py was back to `method="HEAD"` on /models. Gateways that reject HEAD but serve GET fine report a false doctor failure (#292, #241). - SOTA hallucination (#238): the release restored the exact prompt line the fix removed ("State whether SOTA results exist ... and what they are"), and dropped the goal.md disclaimer. The prompt block moved from prompts.py to prompts/ml.py in the refactor, carrying the pre-fix text with it, so this reads as an artifact of the squash rather than intent. - Search plan schema variants (#243): _literature.py was back to `plan.get("search_strategies", [])`, so plans returned as search_phases/phases or with dict-wrapped queries silently yield no queries. #234's retry fix was reverted the same way; that one is already restored in PR #304, so it is deliberately not touched here. Deliberately NOT restored, because removal may have been intentional and that is the maintainers' call: the Gemini native adapter (#227, llm/gemini_adapter.py is deleted and no gemini preset remains) and the Volcengine/BytePlus presets (#240, merged in 84dad0a — the release's own parent — and now absent from PROVIDER_PRESETS entirely). Tests: the #239 tests were dropped too and are restored. Added coverage for the doctor probe. #243 shipped with no tests, which is a large part of why it vanished unnoticed; its parsing is inline in a heavily-coupled stage function, so it stays untested here.
|
Merged. Verified the failure mode independently before accepting, since the change alters what Against unfixed code, a persistent 429 through After: Exactly as you described — the bare On the blast radius of raising Keeping the unreachable trailing Post-merge: 2956 passed, no regressions. |
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.