Skip to content

Commit d61bcb9

Browse files
authored
[bug] Server console throws http status errors when running Web search (MacOS, MiniMax model) (#42)
* Worker loop error … httpx.ReadError.ReadError (connection dropped mid-response) not caught in URL skill -escaped to worker loop * fix(url-skill): robust SSL and network error handling with certifi CA bundle - Use certifi CA bundle via ssl.create_default_context to fix macOS python.org SSL failures - Catch httpx.ConnectError in URL skill to gracefully skip SSL errors (return empty content) and re-raise non-SSL ConnectErrors for orchestrator retry handling - Add httpx.ConnectError and httpx.ReadError to orchestrator warning-level retry handler so they are retried with backoff instead of triggering ERROR-level tracebacks - Add certifi>=2024.0 to project dependencies - Add test coverage: SSL graceful skip, ConnectTimeout propagation, ReadError propagation * feat(config): expose fetch_timeout_seconds for URL skill in [ingest] config Default remains 30s. Users can override via .synthadoc/config.toml: [ingest] fetch_timeout_seconds = 60 Threaded through IngestAgent → SkillAgent → UrlSkill. SkillAgent gains a skill_kwargs dict for passing constructor args to named skills. * docs: add fetch_timeout_seconds to configuration reference and quick-start guide * Add OpenAI to the supported list in README.
1 parent 2908daa commit d61bcb9

11 files changed

Lines changed: 102 additions & 26 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ As the wiki accumulates pages the `index.md` table of contents, domain scope (`p
132132
| Offline browsable artifact | **Yes** | No | No | No |
133133
| Multi-wiki isolation | **Yes** | No | No | No |
134134
| Web search → wiki pages | **Yes** | No | No | No |
135-
| Multiple LLMs support | **Yes** (MiniMax, Gemini, Groq, Anthropic, Ollama) | No | No | No |
135+
| Multiple LLMs support | **Yes** (MiniMax, Gemini, Groq, Anthropic, OpenAI, Ollama) | No | No | No |
136136
| Auto wiki overview page | **Yes** | No | No | No |
137137
| Resumable job queue + retry | **Yes** | No | No | No |
138138
| Query decomposition | **Yes** (parallel sub-queries) | No | No | No |

docs/design.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,9 +854,10 @@ hard_gate_usd = 2.00
854854
auto_resolve_confidence_threshold = 0.85
855855

856856
[ingest]
857-
max_pages_per_ingest = 15
858-
chunk_size = 1500
859-
chunk_overlap = 150
857+
max_pages_per_ingest = 15
858+
chunk_size = 1500
859+
chunk_overlap = 150
860+
fetch_timeout_seconds = 30 # seconds to wait for a URL response before retrying
860861

861862
[logs]
862863
level = "INFO"
@@ -900,6 +901,7 @@ cron = "0 3 * * 0" # every Sunday at 03:00
900901
| `ingest.max_pages_per_ingest` | int | `15` | Max pages one ingest may update |
901902
| `ingest.chunk_size` | int | `1500` | Text chunk size (characters) |
902903
| `ingest.chunk_overlap` | int | `150` | Overlap between chunks |
904+
| `ingest.fetch_timeout_seconds` | int | `30` | Seconds to wait for a URL response before retrying |
903905
| `logs.level` | str | `"INFO"` | Console log level |
904906
| `logs.max_file_mb` | int | `5` | Rotate `synthadoc.log` at this size |
905907
| `logs.backup_count` | int | `5` | Rotated files to keep |

docs/user-quick-start-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,9 @@ port = 7071 # required if running more than one wiki simultaneously
976976
soft_warn_usd = 0.50
977977
hard_gate_usd = 2.00
978978

979+
[ingest]
980+
fetch_timeout_seconds = 60 # increase if slow sites time out during web search
981+
979982
[web_search]
980983
provider = "tavily"
981984
max_results = 20

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"anthropic>=0.30",
1717
"openai>=1.40",
1818
"httpx>=0.27",
19+
"certifi>=2024.0",
1920
"beautifulsoup4>=4.12",
2021
"pypdf>=4.0",
2122
"pdfminer.six>=20221105",

synthadoc/agents/ingest_agent.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ class IngestAgent:
164164
def __init__(self, provider: LLMProvider, store: WikiStorage, search: HybridSearch,
165165
log_writer: LogWriter, audit_db: AuditDB, cache: CacheManager,
166166
max_pages: int = 15, wiki_root: Optional[Path] = None,
167-
cache_version: str = CACHE_VERSION) -> None:
167+
cache_version: str = CACHE_VERSION,
168+
fetch_timeout: int = 30) -> None:
168169
self._provider = provider
169170
self._store = store
170171
self._search = search
@@ -174,7 +175,7 @@ def __init__(self, provider: LLMProvider, store: WikiStorage, search: HybridSear
174175
self._max_pages = max_pages
175176
self._wiki_root = Path(wiki_root) if wiki_root is not None else None
176177
self._cache_version = cache_version
177-
self._skill_agent = SkillAgent()
178+
self._skill_agent = SkillAgent(skill_kwargs={"url": {"fetch_timeout": fetch_timeout}})
178179
self._purpose = self._load_purpose()
179180

180181
async def _analyse(self, text: str, bust_cache: bool = False) -> dict:

synthadoc/agents/skill_agent.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@ def __init__(
8484
self,
8585
wiki_root: Optional[Path] = None,
8686
extra_dirs: Optional[list[Path]] = None,
87+
skill_kwargs: Optional[dict[str, dict]] = None,
8788
) -> None:
8889
self._registry: dict[str, SkillMeta] = {}
8990
self._loaded: dict[str, type[BaseSkill]] = {}
91+
self._skill_kwargs: dict[str, dict] = skill_kwargs or {}
9092
self._build_registry(wiki_root, extra_dirs or [])
9193

9294
def _build_registry(self, wiki_root: Optional[Path], extra_dirs: list[Path]) -> None:
@@ -159,7 +161,8 @@ def get_skill(self, name: str) -> BaseSkill:
159161
self._check_requires(meta)
160162
cls = _import_class(meta.skill_dir / meta.entry_script, meta.entry_class)
161163
self._loaded[name] = cls
162-
instance = self._loaded[name]()
164+
kwargs = self._skill_kwargs.get(name, {})
165+
instance = self._loaded[name](**kwargs)
163166
instance.skill_dir = self._registry[name].skill_dir
164167
return instance
165168

synthadoc/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class IngestConfig:
7575
max_pages_per_ingest: int = 15
7676
chunk_size: int = 1500
7777
chunk_overlap: int = 150
78+
fetch_timeout_seconds: int = 30
7879

7980

8081
@dataclass
@@ -255,6 +256,7 @@ def _raw_to_config(raw: dict, source_has_agents: bool) -> Config:
255256
max_pages_per_ingest=ig.get("max_pages_per_ingest", 15),
256257
chunk_size=ig.get("chunk_size", 1500),
257258
chunk_overlap=ig.get("chunk_overlap", 150),
259+
fetch_timeout_seconds=ig.get("fetch_timeout_seconds", 30),
258260
)
259261

260262
# --- query ---

synthadoc/core/orchestrator.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ async def _run_ingest(self, job_id: str, source: str, auto_confirm: bool,
136136
log_writer=self._log, audit_db=self._audit,
137137
cache=self._cache, max_pages=self._cfg.ingest.max_pages_per_ingest,
138138
cache_version=self._cfg.cache.version,
139+
fetch_timeout=self._cfg.ingest.fetch_timeout_seconds,
139140
)
140141
result = await agent.ingest(source, force=force, bust_cache=force)
141142
_agent_cfg = self._cfg.agents.resolve("ingest")
@@ -206,12 +207,14 @@ async def _run_ingest(self, job_id: str, source: str, auto_confirm: bool,
206207
elif isinstance(e, DomainBlockedException):
207208
await self._auto_block_domain(e)
208209
await self._queue.skip(job_id, str(e))
209-
elif isinstance(e, (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.PoolTimeout)):
210-
# Transient network timeout — retry with backoff, no traceback.
210+
elif isinstance(e, (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.PoolTimeout,
211+
httpx.ConnectError, httpx.ReadError)):
212+
# Transient network error (timeout, connection refused, dropped) — retry with backoff.
211213
logging.getLogger(__name__).warning(
212-
"URL fetch timed out for job %s (%s) — will retry", job_id, source
214+
"URL fetch failed for job %s (%s: %s) — will retry", job_id, source,
215+
type(e).__name__
213216
)
214-
await self._queue.fail(job_id, f"ReadTimeout: {source}")
217+
await self._queue.fail(job_id, f"{type(e).__name__}: {source}")
215218
elif isinstance(e, httpx.HTTPStatusError):
216219
status = e.response.status_code
217220
if 400 <= status < 500:

synthadoc/integration/http_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ async def analyse_source(req: AnalyseRequest):
270270
cache=orch._cache, max_pages=orch._cfg.ingest.max_pages_per_ingest,
271271
wiki_root=orch._root,
272272
cache_version=orch._cfg.cache.version,
273+
fetch_timeout=orch._cfg.ingest.fetch_timeout_seconds,
273274
)
274275
skill = SkillAgent()
275276
extracted = await skill.extract(req.source)

synthadoc/skills/url/scripts/main.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# SPDX-License-Identifier: AGPL-3.0-or-later
22
# Copyright (C) 2026 Paul Chen / axoviq.com
3+
import logging
34
import tempfile
45
import httpx
56
from bs4 import BeautifulSoup
67
from urllib.parse import urlparse
78
from synthadoc.skills.base import BaseSkill, ExtractedContent, SkillMeta
89
from synthadoc.errors import DomainBlockedException
910

11+
logger = logging.getLogger(__name__)
1012

1113
_HEADERS = {
1214
"User-Agent": (
@@ -19,25 +21,49 @@
1921
# HTTP status codes that indicate bot/access blocking (not transient errors)
2022
_BLOCKED_STATUSES = {403, 401, 429}
2123

24+
# macOS Python (python.org installer) doesn't use the system keychain.
25+
# certifi ships its own CA bundle that covers the vast majority of public sites.
26+
import ssl as _ssl
27+
try:
28+
import certifi as _certifi
29+
_SSL_CONTEXT = _ssl.create_default_context(cafile=_certifi.where())
30+
except ImportError:
31+
_SSL_CONTEXT = _ssl.create_default_context() # fall back to system certs
32+
2233

2334
class UrlSkill(BaseSkill):
2435
meta = SkillMeta(name="url", description="Fetch and extract text from web URLs",
2536
extensions=["https://", "http://"])
2637

38+
def __init__(self, fetch_timeout: int = 30) -> None:
39+
super().__init__()
40+
self._fetch_timeout = fetch_timeout
41+
2742
async def extract(self, source: str) -> ExtractedContent:
28-
async with httpx.AsyncClient(follow_redirects=True, timeout=30, headers=_HEADERS) as client:
29-
resp = await client.get(source)
30-
if resp.status_code in _BLOCKED_STATUSES:
31-
domain = urlparse(source).hostname or source
32-
raise DomainBlockedException(
33-
domain=domain, url=source, status_code=resp.status_code
34-
)
35-
resp.raise_for_status()
36-
content_type = resp.headers.get("content-type", "")
37-
is_pdf = "application/pdf" in content_type or source.lower().endswith(".pdf")
38-
if is_pdf:
39-
return self._extract_pdf_response(resp.content, source)
40-
html = resp.text
43+
try:
44+
async with httpx.AsyncClient(
45+
follow_redirects=True, timeout=self._fetch_timeout, headers=_HEADERS, verify=_SSL_CONTEXT
46+
) as client:
47+
resp = await client.get(source)
48+
except httpx.ConnectError as exc:
49+
err_str = str(exc)
50+
if "CERTIFICATE_VERIFY_FAILED" in err_str or "SSL" in err_str.upper():
51+
# SSL errors won't resolve on retry — skip gracefully
52+
logger.warning("SSL verification failed for %s — skipping", source)
53+
return ExtractedContent(text="", source_path=source,
54+
metadata={"url": source, "ssl_error": True})
55+
raise # non-SSL ConnectError — let orchestrator handle (retry with backoff)
56+
if resp.status_code in _BLOCKED_STATUSES:
57+
domain = urlparse(source).hostname or source
58+
raise DomainBlockedException(
59+
domain=domain, url=source, status_code=resp.status_code
60+
)
61+
resp.raise_for_status()
62+
content_type = resp.headers.get("content-type", "")
63+
is_pdf = "application/pdf" in content_type or source.lower().endswith(".pdf")
64+
if is_pdf:
65+
return self._extract_pdf_response(resp.content, source)
66+
html = resp.text
4167

4268
soup = BeautifulSoup(html, "html.parser")
4369
for tag in soup(["script", "style", "nav", "footer"]):
@@ -53,12 +79,10 @@ def _extract_pdf_response(self, content: bytes, source: str) -> ExtractedContent
5379
an empty ExtractedContent is returned so the job completes as 'skipped'
5480
rather than dying after 3 retries.
5581
"""
56-
import logging
5782
import os
5883
import pypdf
5984

6085
logging.getLogger("pypdf").setLevel(logging.ERROR)
61-
logger = logging.getLogger(__name__)
6286

6387
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
6488
tmp.write(content)

0 commit comments

Comments
 (0)