Harden Ollama calls: retry transient disconnects; don't abort batch on one failure

A single "Server disconnected" from Ollama (model reload) raised through and
killed the summarize run at 651/1202. Now: _post_with_retry retries transient
disconnects (3 attempts, backoff); summarize/embed loops catch per-paper errors
and continue. Resumable, so re-running picks up the remaining papers.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-03 21:07:00 -05:00
co-authored by Claude Opus 4.8
parent 766e8f84dc
commit ee123af6f8
3 changed files with 176 additions and 44 deletions
+19 -8
View File
@@ -192,20 +192,27 @@ def cmd_summarize(args: argparse.Namespace) -> int:
if args.limit: if args.limit:
pdfs = pdfs[: args.limit] pdfs = pdfs[: args.limit]
done = skipped = 0 done = skipped = failed = 0
total = len(pdfs) total = len(pdfs)
for i, pdf in enumerate(pdfs, 1): for i, pdf in enumerate(pdfs, 1):
side = pdf.with_suffix(".json") side = pdf.with_suffix(".json")
if not args.force and has_summary(side): if not args.force and has_summary(side):
skipped += 1 skipped += 1
continue continue
summary = summarize_one(pdf, oc) try:
summary = summarize_one(pdf, oc)
except Exception as e: # noqa: BLE001 — one paper must not abort the batch
failed += 1
console.print(f"[red]✗[/red] {pdf.name}: {e}")
continue
if summary is not None: if summary is not None:
append_insight(insights, _json.loads(side.read_text())) append_insight(insights, _json.loads(side.read_text()))
done += 1 done += 1
if i % 10 == 0: if i % 10 == 0:
console.print(f" summarize {i}/{total} (done {done}, skipped {skipped})") console.print(f" summarize {i}/{total} (done {done}, skipped {skipped}, failed {failed})")
console.print(f"[green]Summaries complete[/green] done={done} skipped={skipped}") console.print(
f"[green]Summaries complete[/green] done={done} skipped={skipped} failed={failed}"
)
console.print(f"Insight digest: {insights}") console.print(f"Insight digest: {insights}")
return 0 return 0
@@ -237,10 +244,14 @@ def cmd_embed(args: argparse.Namespace) -> int:
skipped += 1 skipped += 1
continue continue
text = pdf.with_suffix(".txt").read_text() text = pdf.with_suffix(".txt").read_text()
vecs, rows = add_document( try:
oc, text=text, doi=doi, title=data.get("title", ""), vecs, rows = add_document(
topic=data.get("topic", ""), text_path=str(pdf.with_suffix(".txt")), oc, text=text, doi=doi, title=data.get("title", ""),
) topic=data.get("topic", ""), text_path=str(pdf.with_suffix(".txt")),
)
except Exception as e: # noqa: BLE001 — checkpoint already saved; skip & continue
console.print(f"[red]✗[/red] {pdf.name}: {e}")
continue
store = merge(store, vecs, rows) store = merge(store, vecs, rows)
seen.add(doi) seen.add(doi)
added += 1 added += 1
+21 -15
View File
@@ -12,6 +12,7 @@ for best retrieval quality; embed_documents/embed_query apply them.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import time
import httpx import httpx
@@ -90,12 +91,8 @@ class OllamaClient:
payload["format"] = "json" payload["format"] = "json"
if images: if images:
payload["images"] = [base64.b64encode(img).decode("ascii") for img in images] payload["images"] = [base64.b64encode(img).decode("ascii") for img in images]
try: data = self._post_with_retry("/api/generate", payload)
r = self._client.post(f"{self.host}/api/generate", json=payload) return data.get("response", "")
r.raise_for_status()
return r.json().get("response", "")
except httpx.HTTPError as e:
raise OllamaError(f"generate failed: {e}") from e
def caption_image(self, image: bytes, prompt: str) -> str: def caption_image(self, image: bytes, prompt: str) -> str:
"""OCR/describe a single page image with the vision model.""" """OCR/describe a single page image with the vision model."""
@@ -104,15 +101,24 @@ class OllamaClient:
# ---- embeddings --------------------------------------------------------- # ---- embeddings ---------------------------------------------------------
def _embed(self, inputs: list[str]) -> list[list[float]]: def _embed(self, inputs: list[str]) -> list[list[float]]:
try: data = self._post_with_retry("/api/embed", {"model": self.embed_model, "input": inputs})
r = self._client.post( return data.get("embeddings", [])
f"{self.host}/api/embed",
json={"model": self.embed_model, "input": inputs}, # ---- transport with retry ----------------------------------------------
)
r.raise_for_status() def _post_with_retry(self, path: str, payload: dict, *, attempts: int = 3) -> dict:
return r.json().get("embeddings", []) """POST with retry on transient disconnects (Ollama reloads models, etc.)."""
except httpx.HTTPError as e: last: Exception | None = None
raise OllamaError(f"embed failed: {e}") from e for attempt in range(attempts):
try:
r = self._client.post(f"{self.host}{path}", json=payload)
r.raise_for_status()
return r.json()
except httpx.HTTPError as e:
last = e
if attempt < attempts - 1:
time.sleep(2.0 * (attempt + 1))
raise OllamaError(f"POST {path} failed after {attempts} attempts: {last}") from last
def embed_documents(self, texts: list[str]) -> list[list[float]]: def embed_documents(self, texts: list[str]) -> list[list[float]]:
return self._embed([f"search_document: {t}" for t in texts]) return self._embed([f"search_document: {t}" for t in texts])
+136 -21
View File
@@ -1,21 +1,136 @@
summarize 20/1202 (done 5, skipped 15) summarize 230/1202 (done 4, skipped 226)
summarize 30/1202 (done 15, skipped 15) summarize 240/1202 (done 14, skipped 226)
summarize 40/1202 (done 25, skipped 15) summarize 250/1202 (done 24, skipped 226)
summarize 50/1202 (done 35, skipped 15) summarize 260/1202 (done 34, skipped 226)
summarize 60/1202 (done 45, skipped 15) summarize 270/1202 (done 44, skipped 226)
summarize 70/1202 (done 55, skipped 15) summarize 280/1202 (done 54, skipped 226)
summarize 80/1202 (done 65, skipped 15) summarize 290/1202 (done 64, skipped 226)
summarize 90/1202 (done 75, skipped 15) summarize 300/1202 (done 74, skipped 226)
summarize 100/1202 (done 85, skipped 15) summarize 310/1202 (done 84, skipped 226)
summarize 110/1202 (done 95, skipped 15) summarize 320/1202 (done 94, skipped 226)
summarize 120/1202 (done 105, skipped 15) summarize 330/1202 (done 104, skipped 226)
summarize 130/1202 (done 115, skipped 15) summarize 340/1202 (done 114, skipped 226)
summarize 140/1202 (done 125, skipped 15) summarize 350/1202 (done 124, skipped 226)
summarize 150/1202 (done 135, skipped 15) summarize 360/1202 (done 134, skipped 226)
summarize 160/1202 (done 145, skipped 15) summarize 370/1202 (done 144, skipped 226)
summarize 170/1202 (done 155, skipped 15) summarize 380/1202 (done 154, skipped 226)
summarize 180/1202 (done 165, skipped 15) summarize 390/1202 (done 164, skipped 226)
summarize 190/1202 (done 175, skipped 15) summarize 400/1202 (done 174, skipped 226)
summarize 200/1202 (done 185, skipped 15) summarize 410/1202 (done 184, skipped 226)
summarize 210/1202 (done 195, skipped 15) summarize 420/1202 (done 194, skipped 226)
summarize 220/1202 (done 205, skipped 15) summarize 430/1202 (done 204, skipped 226)
summarize 440/1202 (done 214, skipped 226)
summarize 450/1202 (done 224, skipped 226)
summarize 460/1202 (done 234, skipped 226)
summarize 470/1202 (done 244, skipped 226)
summarize 480/1202 (done 254, skipped 226)
summarize 490/1202 (done 264, skipped 226)
summarize 500/1202 (done 274, skipped 226)
summarize 510/1202 (done 284, skipped 226)
summarize 520/1202 (done 294, skipped 226)
summarize 530/1202 (done 304, skipped 226)
summarize 540/1202 (done 314, skipped 226)
summarize 550/1202 (done 324, skipped 226)
summarize 560/1202 (done 334, skipped 226)
summarize 570/1202 (done 344, skipped 226)
summarize 580/1202 (done 354, skipped 226)
summarize 590/1202 (done 364, skipped 226)
summarize 600/1202 (done 374, skipped 226)
summarize 610/1202 (done 384, skipped 226)
summarize 620/1202 (done 394, skipped 226)
summarize 630/1202 (done 404, skipped 226)
summarize 640/1202 (done 414, skipped 226)
summarize 650/1202 (done 424, skipped 226)
Traceback (most recent call last):
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_transports/default.py", line 250, in handle_request
resp = self._pool.handle_request(req)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/connection_pool.py", line 256, in handle_request
raise exc from None
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/connection_pool.py", line 236, in handle_request
response = connection.handle_request(
pool_request.request
)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/connection.py", line 103, in handle_request
return self._connection.handle_request(request)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/http11.py", line 136, in handle_request
raise exc
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/http11.py", line 106, in handle_request
) = self._receive_response_headers(**kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/http11.py", line 177, in _receive_response_headers
event = self._receive_event(timeout=timeout)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpcore/_sync/http11.py", line 231, in _receive_event
raise RemoteProtocolError(msg)
httpcore.RemoteProtocolError: Server disconnected without sending a response.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/quantum/projects/StacksScraper/clawlibrary/ollama_client.py", line 94, in generate
r = self._client.post(f"{self.host}/api/generate", json=payload)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 1144, in post
return self.request(
~~~~~~~~~~~~^
"POST",
^^^^^^^
...<11 lines>...
extensions=extensions,
^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 825, in request
return self.send(request, auth=auth, follow_redirects=follow_redirects)
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 914, in send
response = self._send_handling_auth(
request,
...<2 lines>...
history=[],
)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 942, in _send_handling_auth
response = self._send_handling_redirects(
request,
follow_redirects=follow_redirects,
history=history,
)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 979, in _send_handling_redirects
response = self._send_single_request(request)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_client.py", line 1014, in _send_single_request
response = transport.handle_request(request)
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_transports/default.py", line 249, in handle_request
with map_httpcore_exceptions():
~~~~~~~~~~~~~~~~~~~~~~~^^
File "/opt/homebrew/Cellar/[email protected]/3.14.5/Frameworks/Python.framework/Versions/3.14/lib/python3.14/contextlib.py", line 162, in __exit__
self.gen.throw(value)
~~~~~~~~~~~~~~^^^^^^^
File "/Users/quantum/projects/StacksScraper/.venv/lib/python3.14/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.RemoteProtocolError: Server disconnected without sending a response.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "<frozen runpy>", line 203, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "/Users/quantum/projects/StacksScraper/clawlibrary/__main__.py", line 374, in <module>
sys.exit(main())
~~~~^^
File "/Users/quantum/projects/StacksScraper/clawlibrary/__main__.py", line 367, in main
return args.func(args)
~~~~~~~~~^^^^^^
File "/Users/quantum/projects/StacksScraper/clawlibrary/__main__.py", line 202, in cmd_summarize
summary = summarize_one(pdf, oc)
File "/Users/quantum/projects/StacksScraper/clawlibrary/summarize.py", line 86, in summarize_one
summary = summarize_paper(
client,
...<2 lines>...
body=body,
)
File "/Users/quantum/projects/StacksScraper/clawlibrary/summarize.py", line 56, in summarize_paper
raw = client.generate(build_prompt(title, abstract, body), json_mode=True, num_predict=500)
File "/Users/quantum/projects/StacksScraper/clawlibrary/ollama_client.py", line 98, in generate
raise OllamaError(f"generate failed: {e}") from e
clawlibrary.ollama_client.OllamaError: generate failed: Server disconnected without sending a response.