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
+134
View File
@@ -0,0 +1,134 @@
"""Tests for --queries-file batch mode: exit code semantics and JSON schema.
Covers:
* Exit codes: all-empty (no error) -> 2, all-error -> 1, partial -> 0
* Batch JSON shape: {"schema_version": "1.0", "queries": [...]}
* Per-entry "status" field ("ok"/"error") and result/error payload shape
Uses in-process main() with search_multi mocked so no network is touched.
``--retry 0`` is passed to avoid backoff sleeps when search_multi raises.
"""
import json
import sys
import urllib.error
from unittest.mock import patch
import pytest
import search as search_mod
from search import main
def _batch_argv(queries_file_path, fmt="json"):
"""Build the sys.argv for a batch main() invocation.
``--retry 0`` keeps failing tests fast (no backoff sleeps).
"""
return ["search.py", "-i", "https://x.example.com",
"--queries-file", str(queries_file_path),
"--format", fmt, "--retry", "0"]
# ===== batch exit code semantics =====
def test_batch_all_empty_results_exits_2(tmp_path, capsys):
"""All queries return empty results (no error) -> exit 2."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\nq2\n", encoding="utf-8")
empty = {"results": []}
with patch.object(search_mod, "search_multi", return_value=empty):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 2
def test_batch_all_error_exits_1(tmp_path, capsys):
"""All queries error -> exit 1."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\nq2\n", encoding="utf-8")
err = urllib.error.URLError("connection refused")
with patch.object(search_mod, "search_multi", side_effect=err):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 1
def test_batch_partial_results_exits_0(tmp_path, capsys):
"""At least one query returns results -> exit 0."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\nq2\n", encoding="utf-8")
with_data = {"results": [{"url": "https://a.com/1", "title": "A"}]}
empty = {"results": []}
with patch.object(search_mod, "search_multi",
side_effect=[with_data, empty]):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 0
# ===== batch unified JSON schema =====
def test_batch_json_has_schema_version_and_queries(tmp_path, capsys):
"""Batch JSON output wraps entries in {schema_version, queries}."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\n", encoding="utf-8")
mock_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
with patch.object(search_mod, "search_multi", return_value=mock_results):
with pytest.raises(SystemExit):
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["schema_version"] == "1.0"
assert "queries" in data
assert isinstance(data["queries"], list)
assert len(data["queries"]) == 1
def test_batch_entry_has_status_field(tmp_path, capsys):
"""Each batch entry has a 'status' field of 'ok' or 'error'."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\nq2\n", encoding="utf-8")
ok_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
err = urllib.error.URLError("down")
with patch.object(search_mod, "search_multi",
side_effect=[ok_results, err]):
with pytest.raises(SystemExit):
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
out, _ = capsys.readouterr()
data = json.loads(out)
statuses = [e["status"] for e in data["queries"]]
assert "ok" in statuses
assert "error" in statuses
def test_batch_success_entry_has_results_error_entry_has_error_and_code(tmp_path, capsys):
"""ok entry has 'results'; error entry has 'error' and 'error_code'."""
qf = tmp_path / "queries.txt"
qf.write_text("q1\nq2\n", encoding="utf-8")
ok_results = {"results": [{"url": "https://a.com/1", "title": "A"}]}
err = urllib.error.URLError("connection refused")
with patch.object(search_mod, "search_multi",
side_effect=[ok_results, err]):
with pytest.raises(SystemExit):
with patch.object(sys, "argv", _batch_argv(qf)):
with patch.object(search_mod, "setup_logging"):
main()
out, _ = capsys.readouterr()
data = json.loads(out)
ok_entry = next(e for e in data["queries"] if e["status"] == "ok")
assert "results" in ok_entry
err_entry = next(e for e in data["queries"] if e["status"] == "error")
assert "error" in err_entry
assert "error_code" in err_entry
# URLError -> E_NETWORK per classify_error
assert err_entry["error_code"] == "E_NETWORK"
+116 -2
View File
@@ -23,7 +23,7 @@ from types import SimpleNamespace
import pytest
from search import _run_single_query, _emit_error, _build_params
from search import _run_single_query, _emit_error, _build_params, _format_results
import cache as cache_module
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -256,7 +256,7 @@ def test_cli_version_prints_version():
"""`--version` exits 0 and prints the version string."""
r = _run_cli("--version")
assert r.returncode == 0
assert "1.7.0" in r.stdout
assert "1.8.0" in r.stdout
assert "searxng-cli" in r.stdout
@@ -294,3 +294,117 @@ def test_cli_cache_stats_outputs_json(tmp_path):
data = json.loads(r.stdout)
assert "entries" in data
assert "path" in data
# ===== --dump-schema (v1.8.0) =====
def test_cli_dump_schema_outputs_valid_json():
"""`--dump-schema` prints valid JSON with schema_version/title/properties.
The schema dump is a non-search operation: it exits 0 and does NOT
require --query or -i (it short-circuits before instance resolution).
"""
r = _run_cli("--dump-schema")
assert r.returncode == 0
data = json.loads(r.stdout)
assert data["schema_version"] == "1.0"
assert data["title"] == "SearXNG CLI Search Result"
assert "properties" in data
assert isinstance(data["properties"], dict)
def test_cli_dump_schema_does_not_require_query():
"""`--dump-schema` exits 0 without --query (non-search operation)."""
r = _run_cli("--dump-schema")
assert r.returncode == 0
# The --query-required check must not fire for --dump-schema.
assert "required" not in r.stderr.lower()
# ===== --stream mutual exclusion (v1.8.0) =====
def test_cli_stream_with_queries_file_rejected(tmp_path):
"""--stream + --queries-file is rejected with E_INPUT on stdout.
Batch mode emits a JSON array, not JSON Lines; the combination is
explicitly rejected so AI agents don't silently get the wrong format.
"""
qf = tmp_path / "queries.txt"
qf.write_text("query\n", encoding="utf-8")
r = _run_cli("--stream", "--queries-file", str(qf),
"--format", "json", "-i", "https://x.example.com")
assert r.returncode == 1
data = json.loads(r.stdout)
assert data["error_code"] == "E_INPUT"
def test_cli_stream_with_csv_format_rejected():
"""--stream + --format csv is rejected with E_INPUT.
csv is a non-json format, so _emit_error routes the error to stderr
(as a ``[E_INPUT]`` prefixed log line) rather than stdout JSON.
"""
r = _run_cli("--stream", "--format", "csv", "-q", "test",
"-i", "https://x.example.com")
assert r.returncode == 1
assert "E_INPUT" in r.stderr
# ===== schema_version in single-query JSON (v1.8.0) =====
def test_format_results_json_includes_schema_version():
"""Single-query JSON output includes schema_version: '1.0'."""
results = {"results": [{"title": "T", "url": "https://example.com"}]}
args = SimpleNamespace(format="json")
out = _format_results(results, args)
parsed = json.loads(out)
assert parsed["schema_version"] == "1.0"
# ===== _fallback field must not leak to JSON output (v1.8.0) =====
def test_run_single_query_html_fallback_pops_underscore_fallback(monkeypatch):
"""HTML-fallback's internal _fallback field is removed before output.
_run_single_query pops _fallback at the end so it never appears in
the JSON payload; fetched_source (the public field) is set separately
on the --fetch path.
"""
args = _make_args(format="json")
fake = {"results": [{"url": "https://a.com/1", "title": "A"}],
"_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# _fallback must be popped so it doesn't leak into JSON output
assert "_fallback" not in results
# And the serialized JSON also omits it
out = _format_results(results, args)
parsed = json.loads(out)
assert "_fallback" not in parsed
def test_run_single_query_fetch_sets_fetched_source_from_html_fallback(monkeypatch):
"""--fetch path sets fetched_source from _fallback (or 'json').
When the search came via HTML fallback (_fallback='html'), the
fetched_source field reflects that origin, while _fallback itself is
cleaned up and never reaches JSON output.
"""
args = _make_args(fetch=1)
fake = {"results": [{"url": "https://a.com/1"}], "_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: [
{"url": "https://a.com/1", "status": "ok", "text": "page A",
"text_length": 6, "truncated": False},
])
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# fetched_source reflects the HTML fallback origin
assert results["fetched_source"] == "html"
# _fallback still cleaned up
assert "_fallback" not in results
+6 -3
View File
@@ -143,8 +143,10 @@ def test_non_retryable_404():
assert is_retryable_error(_http_error(404)) is False
def test_non_retryable_403():
assert is_retryable_error(_http_error(403)) is False
def test_retryable_403():
# 403 是可重试的:让 fetch_url 的 UA-fallback 循环有机会切换到浏览器 UA。
# 真正的认证错误会在重试耗尽后由 classify_error 归为 E_AUTH。
assert is_retryable_error(_http_error(403)) is True
def test_non_retryable_200():
@@ -160,7 +162,8 @@ def test_retryable_os_error():
def test_retryable_status_set_contents():
assert RETRYABLE_STATUS == frozenset({429, 502, 503, 504})
# 403 加入可重试集合,让 UA-fallback 在 UA 被屏蔽时有机会切换浏览器 UA
assert RETRYABLE_STATUS == frozenset({403, 429, 502, 503, 504})
# ----- apply_proxy -----
+67
View File
@@ -231,3 +231,70 @@ def test_run_single_query_propagates_rate_limit():
_, _, err_code = _run_single_query(
"test", args, ["https://x.example.com"], {}, 0)
assert err_code == E_RATE_LIMIT
# ----- classify_error: HTTP status extraction from RuntimeError messages -----
#
# search_multi wraps the last error into its RuntimeError message, e.g.
# "All 3 instances failed. Last error: HTTP Error 403: Forbidden"
# classify_error must extract the status code from that message rather than
# always falling back to E_NETWORK.
def test_classify_runtime_all_instances_failed_with_http_403_is_auth():
"""'...Last error: HTTP Error 403' -> E_AUTH (not E_NETWORK)."""
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 403: Forbidden")
assert classify_error(err) == E_AUTH
def test_classify_runtime_all_instances_failed_with_http_500_is_network():
"""'...Last error: HTTP Error 500' -> E_NETWORK (5xx server error)."""
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 500: Internal Server Error")
assert classify_error(err) == E_NETWORK
def test_classify_runtime_parallel_failed_with_http_429_is_rate_limit():
"""'...(parallel). Last error: HTTP Error 429' -> E_RATE_LIMIT."""
err = RuntimeError("All 3 instances failed (parallel). Last error: HTTP Error 429: Too Many Requests")
assert classify_error(err) == E_RATE_LIMIT
# ----- _emit_error: recovery_hint in JSON output -----
#
# recovery_hint gives AI agents an actionable suggestion per error_code.
# Only present when error_code is known; omitted otherwise (backwards compat).
def test_emit_error_json_includes_recovery_hint_for_config(capsys):
"""JSON error with E_CONFIG includes the config recovery_hint."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("no instance resolved", args, error_code=E_CONFIG)
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["error_code"] == E_CONFIG
assert "recovery_hint" in data
hint = data["recovery_hint"].lower()
assert "instance" in hint or "config" in hint
def test_emit_error_json_includes_recovery_hint_for_auth(capsys):
"""JSON error with E_AUTH includes the auth recovery_hint."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("forbidden", args, error_code=E_AUTH)
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["error_code"] == E_AUTH
assert "recovery_hint" in data
hint = data["recovery_hint"].lower()
assert "credential" in hint or "token" in hint
def test_emit_error_json_no_recovery_hint_without_error_code(capsys):
"""No error_code -> no recovery_hint field (backwards compat)."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("something broke", args)
out, _ = capsys.readouterr()
data = json.loads(out)
assert "error_code" not in data
assert "recovery_hint" not in data
+114 -1
View File
@@ -6,8 +6,9 @@ disabled by default, stream only works with --format json.
"""
import json
import sys
import urllib.error
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
import common
@@ -205,3 +206,115 @@ def test_stream_result_event_shape():
assert parsed[0]["result"]["url"] == "https://x.com"
assert parsed[-1]["type"] == "done"
assert parsed[-1]["count"] == 1
# ----- stream done/error: schema_version & recovery_hint (v1.8.0) -----
def test_stream_done_includes_schema_version(capsys):
"""stream done event includes the schema_version field."""
mock_results = {"results": [{"title": "t", "url": "https://x.com"}]}
with patch.object(search_mod, "search_multi", return_value=mock_results):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", ["search.py", "-q", "test",
"-i", "https://x.example.com",
"--stream", "--format", "json",
"--retry", "0"]):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 0
out, _ = capsys.readouterr()
lines = [json.loads(l) for l in out.strip().split("\n") if l]
done_events = [e for e in lines if e["type"] == "done"]
assert len(done_events) == 1
assert done_events[0]["schema_version"] == "1.0"
def test_stream_error_includes_recovery_hint(capsys):
"""stream error event includes recovery_hint for classified errors.
HTTP 403 -> E_AUTH, which has a recovery_hint in RECOVERY_HINTS.
--retry 0 avoids backoff sleeps on the (mocked) 403.
"""
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
with patch.object(search_mod, "search_multi", side_effect=err):
with pytest.raises(SystemExit) as exc_info:
with patch.object(sys, "argv", ["search.py", "-q", "test",
"-i", "https://x.example.com",
"--stream", "--format", "json",
"--retry", "0"]):
with patch.object(search_mod, "setup_logging"):
main()
assert exc_info.value.code == 1
out, _ = capsys.readouterr()
lines = [json.loads(l) for l in out.strip().split("\n") if l]
error_events = [e for e in lines if e["type"] == "error"]
assert len(error_events) == 1
assert error_events[0]["error_code"] == "E_AUTH"
assert "recovery_hint" in error_events[0]
# ----- instance_try / instance_ok / instance_fail progress events (v1.8.0) -----
#
# These events are emitted inside search_multi (not _run_single_query), so
# we must let the real search_multi run and only mock urllib.request.urlopen.
def _mock_urlopen_resp(data: bytes, content_type="application/json"):
"""Build a MagicMock that quacks like an urlopen context manager."""
resp = MagicMock()
resp.read.return_value = data
resp.headers = {"Content-Type": content_type}
resp.__enter__.return_value = resp
resp.__exit__.return_value = None
return resp
def _url_router(routing):
"""Build a side_effect that dispatches urlopen by request URL substring."""
def _side_effect(req, *args, **kwargs):
url = getattr(req, "full_url", str(req))
for key, resp in routing.items():
if key in url:
if isinstance(resp, BaseException):
raise resp
return resp
raise AssertionError(f"unexpected urlopen for {url!r}")
return _side_effect
def test_progress_emits_instance_try_ok_fail(capsys):
"""instance_try/instance_ok/instance_fail events are emitted to stderr.
Uses serial mode (parallel=False) so the events come out in a
deterministic order: try a -> fail a -> try b -> ok b.
"""
set_progress_enabled(True)
try:
payload = json.dumps(
{"results": [{"title": "b", "url": "https://b.com"}]}
).encode()
router = _url_router({
"a.example.com": urllib.error.URLError("down"),
"b.example.com": _mock_urlopen_resp(payload),
})
with patch("urllib.request.urlopen", side_effect=router):
search_mod.search_multi(
["https://a.example.com", "https://b.example.com"],
{"q": "test", "format": "json"},
parallel=False, retry_per=0,
)
_, err = capsys.readouterr()
lines = [json.loads(l) for l in err.strip().split("\n") if l]
events = [e["event"] for e in lines]
assert "instance_try" in events
assert "instance_fail" in events
assert "instance_ok" in events
# instance_fail carries error_code
fail_events = [e for e in lines if e["event"] == "instance_fail"]
assert len(fail_events) >= 1
assert "error_code" in fail_events[0]
# instance_ok carries results count
ok_events = [e for e in lines if e["event"] == "instance_ok"]
assert len(ok_events) >= 1
assert "results" in ok_events[0]
finally:
set_progress_enabled(False)