Add timeout and retry logic to Ollama requests - #34
Conversation
There was a problem hiding this comment.
Thanks for tackling this — the connection-refused retry is genuinely useful. But as written the retry loop makes the "Ollama is slow" case notably worse, which is the case the PR description says it fixes. Details below.
1. Blocking: retrying on socket.timeout triples the hang instead of preventing it (agent/core/llm.py:96-105)
urllib.request.urlopen(timeout=...) applies to the socket for both connect and read, so a socket.timeout on /api/chat or /api/generate overwhelmingly means "the model is still generating," not "the network blipped." Two consequences:
- Wall-clock regression.
LLMConfig.timeoutdefaults to120(agent/core/config.py:24,config.yaml:13). A slow generation used to fail after 120s; it now blocks for120 + 1 + 120 + 2 + 120 = 363sbefore surfacing the same error. The PR body says this "prevents hanging when the Ollama service is slow" — it does the opposite. - It amplifies the overload. These are non-idempotent POSTs. The server is still working on attempt 1's generation when attempt 2 arrives, so a struggling Ollama instance gets three concurrent full generations queued against it.
/api/pull(llm.py:294) is worse: a multi-GB pull that exceeds the timeout now restarts from scratch three times.
Suggested fix: only retry errors that mean the connection was never established — e.g. URLError whose reason is ConnectionRefusedError or socket.gaierror — and let socket.timeout fail fast on the first attempt as before. If you do want timeout retries, they should at minimum be opt-in and off by default for the generation endpoints.
Relatedly, agent/core/llm.py:107's isinstance guard includes bare OSError, which is a superclass of socket.timeout, socket.error and ConnectionRefusedError — so the tuple is effectively just OSError and the retry path catches far more than connection failures.
2. The retry doesn't cover the primary code path (agent/core/llm.py:93-94)
For stream=True, urlopen returns as soon as response headers arrive, so the retry loop only guards header receipt. Timeouts during body iteration are raised inside _stream_chat and caught at agent/core/llm.py:243, entirely outside the loop. Since streaming chat is the main path, the retry adds little there — worth calling out so the change isn't assumed to cover more than it does.
3. last_error is dead (agent/core/llm.py:83, :97, :108)
It's assigned on every failure and never read. Either drop it, or use it to chain context via raise OllamaError(...) from last_error — the current raise statements lose the original exception's __cause__.
4. No test coverage
tests/test_llm.py only patches _request wholesale, so nothing exercises the new loop. Please add a test asserting attempt counts for a retryable error vs. a non-retryable one, with time.sleep patched so the suite stays fast. This is also the natural place to pin down the intended timeout behavior from point 1.
5. Pre-existing, but this diff rewrote the block (agent/core/llm.py:121-123)
urllib.error.HTTPError subclasses URLError, so the URLError handler at :106 always wins and the HTTPError handler is unreachable. HTTPError.reason returns self.msg (a str), so the guard at :107 is False and every HTTP 4xx/5xx from Ollama is reported as "Cannot connect to Ollama... Make sure Ollama is running" rather than the actual API error body. This is on main already and not a regression from this PR, so it's your call whether to fix here — but the fix is just moving the HTTPError clause above the URLError clause, and this diff is already touching these exact lines.
Points 1 and 3 are what I'd want addressed before merge; 2, 4 and 5 are worth a look while you're in here.
…imeout Retrying socket.timeout in _request was tripling the hang time for a slow Ollama generation (120s -> 363s) and could queue duplicate concurrent generations against a struggling server. Narrow the retry to errors that mean the connection never got established (ConnectionRefusedError, socket.gaierror); timeouts and other URLErrors fail fast as before. Also drop the dead last_error variable in favor of `raise ... from e` to preserve exception context, and reorder the HTTPError/URLError except clauses so HTTPError (a URLError subclass) is actually reachable and its response body reaches the caller instead of being reported as a generic connection failure.
|
Verified each point against the code before making changes. Fixed:
Not changed:
Bonus: rewriting the retry loop also introduced a Checks: |
There was a problem hiding this comment.
Reviewed the diff in agent/core/llm.py and tests/test_llm.py.
What I checked:
- Exception ordering (llm.py:95-107):
socket.timeoutisTimeoutError(not aURLErrorsubclass) so it doesn't shadow the later clauses, andHTTPErroris now caught beforeURLError. That's a real fix — on main theHTTPErrorclause was dead code, so API errors were reported as "Cannot connect to Ollama" and the response body never reached the caller. - Retry safety (llm.py:108): retries are gated to
ConnectionRefusedError/socket.gaierroronly — cases where the request never reached the server — so re-POSTing to/api/chat,/api/generateand/api/pullcan't duplicate server-side work. Timeouts deliberately fail fast, which is correct for a model that may still be generating. - Loop bounds:
attempt < max_retries - 1gives 3 attempts and 2 backoff sleeps (1s, 2s); the trailing raise at llm.py:123 is unreachable-by-construction and only satisfies the return type.Requestis rebuilt per attempt rather than reused. - No collateral:
timewas already imported on main, and no caller of_request(llm.py:206/216/272/294) or any other module depends on the changed message strings. The timeout message now useseffective_timeout, so per-call overrides are reported accurately. - Tests cover both retried paths, both fail-fast paths, and that the HTTP error body surfaces.
Scope is limited to the _request method and its tests, matching the PR description. Approving; merging on green CI.
What
Add exponential backoff retry logic (up to 3 attempts) and request timeout handling to the
_requestmethod to gracefully handle network failures and unresponsive Ollama endpoints.Why
Prevents hanging or crashing when the Ollama service is slow or temporarily unreachable, improving reliability and user experience.