Fix: cap Ollama client-side retries so a hung request cannot multiply the stall (#1686)

A stalled local model wedged for `timeout * (max_retries + 1)`, which with
the default 6 retries turned one long stall into a ~21-minute block with no
progress. `_call_openai_compat` now defaults the Ollama backend to zero
client-side retries (a local model that stalls will not un-stall on retry);
set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged.

This bounds the wait; the underlying stall is driven by the model server
and is non-deterministic, so it is not eliminated. Thanks @Kyzcreig for the report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-06 12:44:47 +01:00
co-authored by Claude Opus 4.8
parent 0ff584f070
commit b78248f22a
3 changed files with 72 additions and 1 deletions
+1
View File
@@ -6,6 +6,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved.
- Fix: token estimation no longer crashes on files containing tiktoken special-token text like `<|endoftext|>` (#1685, thanks @Kyzcreig). `_TOKENIZER.encode(content)` raises `ValueError` by default when the text contains a special token, which aborted packing on docs/corpora that merely mention these strings. Both `encode` sites now pass `disallowed_special=()` so such text is tokenized as ordinary bytes.
- Fix: the Ollama backend no longer multiplies a hang by the retry count (#1686, thanks @Kyzcreig). A stalled local model would wedge for `timeout * (max_retries + 1)`, which with the default 6 retries turned one long stall into a very long one. Ollama now defaults to zero client-side retries (a local model that stalls will not un-stall on retry); set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged. Note: the underlying stall is non-deterministic and driven by the model server, so this bounds the wait rather than eliminating the hang.
## 0.9.7 (2026-07-06)
+11 -1
View File
@@ -962,8 +962,18 @@ def _call_openai_compat(
# default. Honour GRAPHIFY_API_TIMEOUT (seconds) for explicit override;
# default to 600s, which is long enough for a 31B model on a 16k chunk
# but still bounds runaway connections (issue #792 addendum).
# The SDK's transient-error retries (default 6) exist for cloud rate limits
# (429). A local Ollama server does not rate-limit, and if it wedges it will
# not recover by retrying, so 6 retries turn a 180s --api-timeout into a
# ~21min block (7 attempts x 180s) with no progress (#1686). Default ollama
# to 0 SDK retries so --api-timeout is the hard wall-clock bound and a hung
# request fails fast into the chunk-level retry/skip. An explicit
# GRAPHIFY_MAX_RETRIES still wins for users who want it.
_retries = _resolve_max_retries()
if backend == "ollama" and not os.environ.get("GRAPHIFY_MAX_RETRIES", "").strip():
_retries = 0
client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout(),
max_retries=_resolve_max_retries())
max_retries=_retries)
kwargs: dict = {
"model": model,
"messages": [
+60
View File
@@ -0,0 +1,60 @@
"""#1686 - a wedged local Ollama request must not multiply --api-timeout by the
SDK's 6 transient-error retries into a ~20min block. Ollama defaults to 0 SDK
retries so the timeout is the effective wall-clock bound; an explicit
GRAPHIFY_MAX_RETRIES still wins.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import graphify.llm as llm
def _capture_client_kwargs(monkeypatch):
captured: dict = {}
def _factory(**kwargs):
captured.update(kwargs)
client = MagicMock()
resp = MagicMock()
resp.choices[0].message.content = '{"nodes": [], "edges": [], "hyperedges": []}'
resp.choices[0].finish_reason = "stop"
resp.usage.prompt_tokens = 1
resp.usage.completion_tokens = 1
client.chat.completions.create.return_value = resp
return client
monkeypatch.setattr("openai.OpenAI", _factory)
return captured
def test_ollama_defaults_to_zero_sdk_retries(monkeypatch):
monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False)
captured = _capture_client_kwargs(monkeypatch)
llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m",
"def f(): pass", backend="ollama")
assert captured.get("max_retries") == 0
def test_ollama_honors_explicit_max_retries(monkeypatch):
monkeypatch.setenv("GRAPHIFY_MAX_RETRIES", "3")
captured = _capture_client_kwargs(monkeypatch)
llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m",
"def f(): pass", backend="ollama")
assert captured.get("max_retries") == 3
def test_cloud_backend_keeps_default_retries(monkeypatch):
monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False)
captured = _capture_client_kwargs(monkeypatch)
llm._call_openai_compat("https://api.moonshot.cn/v1", "sk-x", "m",
"def f(): pass", backend="kimi")
assert captured.get("max_retries") == 6 # default retained for rate-limited clouds
def test_api_timeout_is_passed_to_client(monkeypatch):
monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "180")
captured = _capture_client_kwargs(monkeypatch)
llm._call_openai_compat("http://localhost:11434/v1", "ollama", "m",
"def f(): pass", backend="ollama")
assert captured.get("timeout") == 180.0