fix: bisect extraction chunks on timeout (#2866)

A timeout on a multi-file extraction chunk failed the whole chunk. Route recognized
timeouts (subprocess.TimeoutExpired, SDK APITimeoutError, botocore read/connect
timeouts) through the same bounded bisection/merge path already used for
context-window-exceeded errors, so a single slow file no longer takes its chunk-mates
down. A single unsplittable file that times out is left unstamped and retried next run,
as before.
This commit is contained in:
hopstreax
2026-08-19 14:05:58 +01:00
committed by safishamsi
parent dfdcf9e408
commit 26092ce5b1
2 changed files with 188 additions and 10 deletions
+43 -10
View File
@@ -9,6 +9,7 @@ import hashlib
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Callable
@@ -1997,6 +1998,27 @@ def _looks_like_context_exceeded(exc: BaseException) -> bool:
return any(marker in msg for marker in _CONTEXT_EXCEEDED_MARKERS)
def _looks_like_timeout(exc: BaseException) -> bool:
"""Classify an exception as a recognized subprocess or SDK timeout."""
types: list[type[BaseException]] = [subprocess.TimeoutExpired]
try:
import openai
types.append(openai.APITimeoutError)
except ImportError:
pass
try:
import anthropic
types.append(anthropic.APITimeoutError)
except ImportError:
pass
try:
import botocore.exceptions
types.extend([botocore.exceptions.ReadTimeoutError, botocore.exceptions.ConnectTimeoutError])
except ImportError:
pass
return isinstance(exc, tuple(types))
def _mark_partial(result: dict) -> None:
"""Tag every node/edge/hyperedge in a truncated chunk result with an internal
``_partial`` marker.
@@ -2071,11 +2093,11 @@ def _extract_with_adaptive_retry(
*,
deep_mode: bool = False,
) -> dict:
"""Extract a chunk; if the response is truncated (`finish_reason="length"`)
or the API rejects the prompt as too large for the model's context window,
split the chunk in half and recurse.
"""Extract a chunk; if the response is truncated (`finish_reason="length"`),
the API rejects the prompt as too large for the model's context window, or
the call times out, split the chunk in half and recurse.
Three signals drive the retry, all funnelled through the same code:
Four signals drive the retry, all funnelled through the same code:
- `finish_reason == "length"` — the model accepted the input but ran out of
`max_completion_tokens` mid-output. The truncated JSON is unparseable, so
@@ -2094,6 +2116,13 @@ def _extract_with_adaptive_retry(
take the same recovery path; without that the chunk would be silently
dropped from the corpus.
- recognized timeout exceptions — dense chunks can take long enough to hit
`GRAPHIFY_API_TIMEOUT` before returning output. For `claude-cli`,
`subprocess.TimeoutExpired` is raised; for SDK backends, concrete timeout
classes (e.g. `openai.APITimeoutError`, `anthropic.APITimeoutError`,
`botocore.exceptions.ReadTimeoutError` / `ConnectTimeoutError`) are raised.
Adaptive bisection splits the chunk so smaller pieces finish within the timeout.
Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N
files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If
still failing at the cap, we surface the (likely empty) result with a
@@ -2133,33 +2162,37 @@ def _extract_with_adaptive_retry(
result = extract_files_direct(
chunk, backend=backend, api_key=api_key, model=model, root=root, deep_mode=deep_mode
)
except Exception as exc: # noqa: BLE001 — re-raise unless it's a known context overflow
if not _looks_like_context_exceeded(exc):
except Exception as exc: # noqa: BLE001 — re-raise unless it's a known context overflow or timeout
is_timeout = _looks_like_timeout(exc)
if not (_looks_like_context_exceeded(exc) or is_timeout):
raise
reason = "timed out" if is_timeout else "exceeded context"
if len(chunk) <= 1:
halves = _split_lone_slice()
if halves is not None:
print(
f"[graphify] slice of {unit_path(chunk[0])} exceeded context at "
f"[graphify] slice of {unit_path(chunk[0])} {reason} at "
f"depth {_depth}; splitting the slice and retrying",
file=sys.stderr,
)
return _merge_two([halves[0]], [halves[1]])
fail_desc = "timed out" if is_timeout else "exceeds model context"
print(
f"[graphify] single-file chunk {unit_path(chunk[0])} exceeds model context "
f"[graphify] single-file chunk {unit_path(chunk[0])} {fail_desc} "
f"and cannot be split further: {exc}",
file=sys.stderr,
)
return {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, "model": model, "finish_reason": "stop"}
if _depth >= max_depth:
persist_desc = "still times out" if is_timeout else "still overflows context"
print(
f"[graphify] chunk of {len(chunk)} still overflows context at "
f"[graphify] chunk of {len(chunk)} {persist_desc} at "
f"recursion depth {_depth} (max {max_depth}) — dropping",
file=sys.stderr,
)
return {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, "model": model, "finish_reason": "stop"}
print(
f"[graphify] chunk of {len(chunk)} exceeded context at depth "
f"[graphify] chunk of {len(chunk)} {reason} at depth "
f"{_depth} ({type(exc).__name__}); splitting in half and retrying",
file=sys.stderr,
)
+145
View File
@@ -324,6 +324,151 @@ def test_adaptive_retry_re_raises_unrelated_errors(tmp_path):
)
# ---------------------------------------------------------------------------
# Adaptive retry: timeout recovery (#2866)
# ---------------------------------------------------------------------------
def test_looks_like_timeout_matches_concrete_classes():
import subprocess
from unittest.mock import MagicMock
assert llm._looks_like_timeout(subprocess.TimeoutExpired(["cmd"], 30))
try:
import openai
assert llm._looks_like_timeout(openai.APITimeoutError(request=MagicMock()))
except ImportError:
pass
try:
import anthropic
assert llm._looks_like_timeout(anthropic.APITimeoutError(request=MagicMock()))
except ImportError:
pass
try:
import botocore.exceptions
assert llm._looks_like_timeout(botocore.exceptions.ReadTimeoutError(endpoint_url="http://test"))
assert llm._looks_like_timeout(botocore.exceptions.ConnectTimeoutError(endpoint_url="http://test"))
except ImportError:
pass
def test_looks_like_timeout_ignores_unrelated_errors():
for exc in [
TimeoutError("timed out"),
RuntimeError("timeout"),
ValueError("timed out"),
RuntimeError("rate limit hit"),
Exception("connection refused"),
KeyError("missing key"),
]:
assert not llm._looks_like_timeout(exc), exc
def test_adaptive_retry_splits_on_subprocess_timeout(tmp_path, capsys):
import subprocess
files = [tmp_path / f"f{i}.md" for i in range(4)]
for f in files:
f.write_text("hello")
calls = {"n": 0}
def fake_extract(chunk, *_, **__):
calls["n"] += 1
if len(chunk) == 4:
raise subprocess.TimeoutExpired(["claude", "-p"], 600)
return _ok(nodes=[{"id": f.stem} for f in chunk])
with patch("graphify.llm.extract_files_direct", side_effect=fake_extract):
result = llm._extract_with_adaptive_retry(
files, backend="claude-cli", api_key=None, model=None, root=tmp_path, max_depth=3
)
assert len(result["nodes"]) == 4
assert calls["n"] == 3 # 1 timeout on initial chunk + 2 successful halves
err = capsys.readouterr().err
assert "timed out at depth 0" in err
assert "exceeded context" not in err
def test_adaptive_retry_gives_up_on_single_file_timeout(tmp_path, capsys):
import subprocess
f = tmp_path / "huge.md"
f.write_text("x")
def fake_extract(*_, **__):
raise subprocess.TimeoutExpired(["claude", "-p"], 600)
with patch("graphify.llm.extract_files_direct", side_effect=fake_extract):
result = llm._extract_with_adaptive_retry(
[f], backend="claude-cli", api_key=None, model=None, root=tmp_path, max_depth=3
)
# Single-file timeout gives up and returns empty result without infinite recursion
assert result["nodes"] == []
assert result["edges"] == []
assert result["finish_reason"] == "stop"
err = capsys.readouterr().err
assert "single-file chunk" in err and "timed out and cannot be split further" in err
assert "exceeds model context" not in err
def test_adaptive_retry_splits_single_slice_on_timeout(tmp_path, capsys):
import subprocess
from graphify.file_slice import FileSlice
f = tmp_path / "doc.md"
f.write_text("line 1\nline 2\nline 3\nline 4\nline 5\n")
fs = FileSlice(path=f, start=0, end=len(f.read_text()), index=0, total=1)
calls = {"n": 0}
def fake_extract(chunk, *_, **__):
calls["n"] += 1
if calls["n"] == 1:
raise subprocess.TimeoutExpired(["claude", "-p"], 600)
return _ok(nodes=[{"id": f"node_{calls['n']}"}])
with patch("graphify.llm.extract_files_direct", side_effect=fake_extract):
result = llm._extract_with_adaptive_retry(
[fs], backend="claude-cli", api_key=None, model=None, root=tmp_path, max_depth=3
)
assert len(result["nodes"]) == 2
assert calls["n"] == 3 # 1 timeout + 2 bisected slice halves
err = capsys.readouterr().err
assert "slice of" in err and "timed out at depth 0" in err
assert "exceeded context" not in err
def test_adaptive_retry_timeout_caps_at_max_depth(tmp_path, capsys):
import subprocess
files = [tmp_path / f"f{i}.md" for i in range(8)]
for f in files:
f.write_text("hello")
calls = {"n": 0}
def always_timeout(chunk, *_, **__):
calls["n"] += 1
raise subprocess.TimeoutExpired(["claude", "-p"], 600)
with patch("graphify.llm.extract_files_direct", side_effect=always_timeout):
result = llm._extract_with_adaptive_retry(
files, backend="claude-cli", api_key=None, model=None, root=tmp_path, max_depth=1
)
assert result["nodes"] == []
err = capsys.readouterr().err
assert "still times out at recursion depth" in err
assert "overflows context" not in err
# ---------------------------------------------------------------------------
# Hollow-response detection: empty / null / unparseable content from a
# successful HTTP call must route into the same bisection path as a true