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:
co-authored by
Claude Opus 4.8
parent
0ff584f070
commit
b78248f22a
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user