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:
2026-08-01 19:02:44 +08:00
parent dea899143d
commit fb9b2af45f
12 changed files with 871 additions and 131 deletions
+49 -4
View File
@@ -60,8 +60,12 @@ def setup_logging(verbose: bool = False, quiet: bool = False) -> None:
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.5 # seconds; exponential backoff + jitter
# HTTP status codes that are worth retrying (rate limit + gateway errors)
RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
# HTTP status codes worth retrying. 403 is included so fetch_url's UA-fallback
# loop can kick in when a site blocks the default searxng-cli User-Agent
# (a fresh UA is tried on each retry attempt). search.py also retries 403 —
# it doesn't switch UAs, so true auth failures waste ~3 attempts, accepted
# as a trade-off for one shared retry policy across both scripts.
RETRYABLE_STATUS = frozenset({403, 429, 502, 503, 504})
# Browser-like UA strings for fallback when the searxng-cli UA is blocked
FALLBACK_UAS = [
@@ -302,6 +306,29 @@ E_EMPTY = "E_EMPTY"
E_INPUT = "E_INPUT"
E_INTERNAL = "E_INTERNAL"
# 每个 E_* 配套的可操作恢复建议,让 AI Agent 能自决策下一步动作,
# 而不是盲目重试或放弃。在 _emit_error 的 JSON 输出中作为 recovery_hint 字段。
RECOVERY_HINTS = {
E_CONFIG: "Provide -i/--instance, set SEARXNG_INSTANCE env var, or create "
"searxng.toml/instances.txt config file.",
E_AUTH: "Verify --auth-bearer/--auth-basic credentials or "
"SEARXNG_BEARER_TOKEN/SEARXNG_BASIC_AUTH env vars. Check token "
"expiry and instance access permissions.",
E_NETWORK: "Retry with backoff, or try a different SearXNG instance. "
"Check network connectivity, proxy settings, and instance uptime.",
E_RATE_LIMIT: "Wait before retrying (exponential backoff). Reduce query "
"frequency, narrow --time-range, or distribute load across "
"multiple instances.",
E_INPUT: "Check query syntax, --categories values, --time-range format, "
"and flag combinations. Use --help for valid options.",
E_PARSE: "The instance returned malformed data. Try a different instance, "
"switch --method, or check if the instance version is compatible.",
E_EMPTY: "Refine the query (more specific terms), broaden --time-range, "
"add --categories, or increase --pageno to find more results.",
E_INTERNAL: "This is likely a bug. Re-run with --verbose and report the "
"full output for diagnosis.",
}
def classify_error(exc: BaseException) -> str:
"""将异常分类为结构化错误码,供 AI Agent 程序化处理。
@@ -350,18 +377,36 @@ def classify_error(exc: BaseException) -> str:
if isinstance(exc, (ValueError, _json.JSONDecodeError)):
return E_PARSE
# RuntimeError:从消息中推断(search_multi 的 "All N instances failed" 等)
# RuntimeError:从消息中推断(search_multi 的 "All N instances failed.
# Last error: HTTP Error 403: Forbidden" 等)。并行路径现在会把 last_error
# 拼进消息,让这里能提取真实错误类型,而不是一律误判 E_NETWORK。
msg = str(exc).lower()
if isinstance(exc, RuntimeError):
# 先检查 auth/rate-limit 关键字(最常见,来自 last_error 详情)
if "auth" in msg or "403" in msg or "401" in msg:
return E_AUTH
if "rate" in msg or "429" in msg:
return E_RATE_LIMIT
# 从 "http error NNN" / "http NNN" 模式中提取状态码,
# 正确分类 "All instances failed. Last error: HTTP Error 403" 等
import re as _re
status_match = _re.search(r'http(?: error)? (\d{3})', msg)
if status_match:
status = int(status_match.group(1))
if status == 429:
return E_RATE_LIMIT
if status in (401, 403):
return E_AUTH
if 400 <= status < 500:
return E_INPUT
if 500 <= status < 600:
return E_NETWORK
# 所有实例失败的通用模式(无更具体的 HTTP 状态码时才判为网络错误)
if "all" in msg and "instance" in msg and "fail" in msg:
return E_NETWORK
if "parse" in msg or "json" in msg or "html" in msg:
return E_PARSE
if "not found" in msg or "no " in msg and "instance" in msg:
if "not found" in msg or ("no " in msg and "instance" in msg):
return E_CONFIG
return E_INTERNAL