feat(v1.8.0): 稳定性修复 + AI Agent 体验增强
稳定性修复: - 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装) - 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close()) - RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑 - --cache-stats 移至实例解析前,无需实例即可查询 - classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT - --stream 与 --queries-file 互斥检查,违规报 E_INPUT - batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果) AI Agent 体验增强: - 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL - recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略 - stream 模式新增 error 事件类型(含 error_code + recovery_hint) - 进度事件扩展:instance_try/instance_ok/instance_fail - batch 模式统一 schema(status 字段区分 success/failed) - JSON 输出含 schema_version 字段确保版本兼容 测试与文档: - 测试覆盖:330 -> 352 - SKILL.md / README.md 同步更新
This commit is contained in:
+37
-30
@@ -513,35 +513,42 @@ def fetch_url(url: str, timeout: int = 15, user_agent: str = None,
|
||||
if _HAS_REQUESTS:
|
||||
resp = _requests.get(url, timeout=timeout, headers=headers,
|
||||
allow_redirects=allow_redirects, stream=True)
|
||||
resp.raise_for_status()
|
||||
# stream=True holds the socket open; must close explicitly,
|
||||
# including on raise_for_status() / max_size break / decode
|
||||
# errors — otherwise the connection leaks back to the pool
|
||||
# and long-running agents exhaust ports.
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
|
||||
# Read: unlimited if max_size is None, chunked with limit otherwise
|
||||
if max_size is None:
|
||||
raw = resp.content
|
||||
truncated = False
|
||||
else:
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in resp.iter_content(chunk_size=65536, decode_unicode=False):
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > max_size:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
truncated = total > max_size
|
||||
# Read: unlimited if max_size is None, chunked with limit otherwise
|
||||
if max_size is None:
|
||||
raw = resp.content
|
||||
truncated = False
|
||||
else:
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in resp.iter_content(chunk_size=65536, decode_unicode=False):
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > max_size:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
truncated = total > max_size
|
||||
|
||||
if encoding:
|
||||
content = raw.decode(encoding)
|
||||
else:
|
||||
charset = detect_charset(raw, resp.headers.get("Content-Type", ""))
|
||||
try:
|
||||
content = raw.decode(charset)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
content = raw.decode("utf-8", errors="replace")
|
||||
if encoding:
|
||||
content = raw.decode(encoding)
|
||||
else:
|
||||
charset = detect_charset(raw, resp.headers.get("Content-Type", ""))
|
||||
try:
|
||||
content = raw.decode(charset)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
content = raw.decode("utf-8", errors="replace")
|
||||
|
||||
return FetchResult(content, resp.headers.get("Content-Type", ""),
|
||||
resp.url, truncated, ua)
|
||||
return FetchResult(content, resp.headers.get("Content-Type", ""),
|
||||
resp.url, truncated, ua)
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
# stdlib fallback
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
@@ -588,7 +595,7 @@ def fetch_url(url: str, timeout: int = 15, user_agent: str = None,
|
||||
logger.info(f" Fetch retry {attempt+1}/{max_retries} ({e}) in {delay:.1f}s")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(f"HTTP {e.code} for {url}")
|
||||
raise RuntimeError(f"HTTP {e.code} for {url}") from e
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
||||
last_error = e
|
||||
if attempt < max_retries:
|
||||
@@ -596,7 +603,7 @@ def fetch_url(url: str, timeout: int = 15, user_agent: str = None,
|
||||
logger.info(f" Fetch retry {attempt+1}/{max_retries} ({e}) in {delay:.1f}s")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(f"Request failed for {url}: {e}")
|
||||
raise RuntimeError(f"Request failed for {url}: {e}") from e
|
||||
except Exception as e:
|
||||
# requests backend: retry only on connection errors (no response)
|
||||
# or transient 429/5xx; do NOT retry permanent errors like 404.
|
||||
@@ -608,9 +615,9 @@ def fetch_url(url: str, timeout: int = 15, user_agent: str = None,
|
||||
logger.info(f" Fetch retry {attempt+1}/{max_retries} ({e}) in {delay:.1f}s")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(f"Request failed for {url}: {e}")
|
||||
raise RuntimeError(f"Request failed for {url}: {e}") from e
|
||||
|
||||
raise RuntimeError(f"All {max_retries+1} attempts failed for {url}: {last_error}")
|
||||
raise RuntimeError(f"All {max_retries+1} attempts failed for {url}: {last_error}") from last_error
|
||||
|
||||
|
||||
# ----- Main -----
|
||||
|
||||
Reference in New Issue
Block a user