Files
searxng-use-cli/tests/test_error_codes.py
T
thzxx fb9b2af45f 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 同步更新
2026-08-01 19:02:44 +08:00

301 lines
11 KiB
Python

"""Tests for structured error classification (error code system).
Covers: classify_error() mapping exceptions to E_* codes, _emit_error()
outputting error_code in JSON mode, _run_single_query() propagating
error_code from caught exceptions.
"""
import json
import urllib.error
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from common import (
classify_error,
E_CONFIG, E_AUTH, E_NETWORK, E_RATE_LIMIT,
E_PARSE, E_EMPTY, E_INPUT, E_INTERNAL,
)
from search import _emit_error, _run_single_query
# ----- classify_error: HTTP errors -----
def test_classify_429_is_rate_limit():
err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None)
assert classify_error(err) == E_RATE_LIMIT
def test_classify_401_is_auth():
err = urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
assert classify_error(err) == E_AUTH
def test_classify_403_is_auth():
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
assert classify_error(err) == E_AUTH
def test_classify_404_is_input():
err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
assert classify_error(err) == E_INPUT
def test_classify_400_is_input():
err = urllib.error.HTTPError("url", 400, "Bad Request", {}, None)
assert classify_error(err) == E_INPUT
def test_classify_500_is_network():
err = urllib.error.HTTPError("url", 500, "Internal Server Error", {}, None)
assert classify_error(err) == E_NETWORK
def test_classify_503_is_network():
err = urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None)
assert classify_error(err) == E_NETWORK
# ----- classify_error: connection errors -----
def test_classify_urlerror_is_network():
err = urllib.error.URLError("connection refused")
assert classify_error(err) == E_NETWORK
def test_classify_timeout_is_network():
assert classify_error(TimeoutError("timed out")) == E_NETWORK
def test_classify_oserror_is_network():
assert classify_error(OSError("network unreachable")) == E_NETWORK
# ----- classify_error: parse errors -----
def test_classify_value_error_is_parse():
assert classify_error(ValueError("invalid JSON")) == E_PARSE
def test_classify_json_decode_error_is_parse():
import json as _json
try:
_json.loads("{bad}")
assert False
except _json.JSONDecodeError as e:
assert classify_error(e) == E_PARSE
# ----- classify_error: file/input errors -----
def test_classify_filenotfound_is_input():
assert classify_error(FileNotFoundError("no such file")) == E_INPUT
# ----- classify_error: RuntimeError message inference -----
def test_classify_runtime_all_instances_failed_is_network():
err = RuntimeError("All 3 instances failed. Last error: timeout")
assert classify_error(err) == E_NETWORK
def test_classify_runtime_auth_message_is_auth():
err = RuntimeError("Auth failed: 403 Forbidden")
assert classify_error(err) == E_AUTH
def test_classify_runtime_rate_limit_message():
err = RuntimeError("Rate limited: 429")
assert classify_error(err) == E_RATE_LIMIT
def test_classify_runtime_parse_message_is_parse():
err = RuntimeError("Failed to parse JSON response")
assert classify_error(err) == E_PARSE
def test_classify_runtime_no_instance_is_config():
err = RuntimeError("No instance found in config")
assert classify_error(err) == E_CONFIG
def test_classify_runtime_unknown_is_internal():
err = RuntimeError("Something unexpected happened")
assert classify_error(err) == E_INTERNAL
# ----- classify_error: fallback -----
def test_classify_unknown_exception_is_internal():
assert classify_error(Exception("unknown")) == E_INTERNAL
# ----- _emit_error: error_code in JSON output -----
def test_emit_error_json_includes_error_code():
"""JSON mode: error_code appears in the JSON output."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit) as exc_info:
_emit_error("connection refused", args, query="test",
error_code=E_NETWORK)
assert exc_info.value.code == 1
# Output already printed to stdout; we can't easily capture it here
# without capsys, so we test via capsys below.
def test_emit_error_json_with_code_and_query(capsys):
"""JSON mode: both error_code and query are in the output."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("rate limited", args, query="news",
error_code=E_RATE_LIMIT)
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["error"] == "rate limited"
assert data["error_code"] == E_RATE_LIMIT
assert data["query"] == "news"
assert data["exit_code"] == 1
def test_emit_error_json_without_error_code(capsys):
"""JSON mode: error_code field omitted when not provided (backwards compat)."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("some error", args)
out, _ = capsys.readouterr()
data = json.loads(out)
assert "error_code" not in data
def test_emit_error_brief_includes_code_prefix(caplog):
"""Non-JSON mode: error_code appears as [E_*] prefix in log output."""
args = SimpleNamespace(format="brief")
with pytest.raises(SystemExit):
with caplog.at_level("ERROR"):
_emit_error("not found", args, query="test", error_code=E_INPUT)
assert "[E_INPUT]" in caplog.text
# ----- _run_single_query: error_code propagation -----
def test_run_single_query_propagates_error_code():
"""When search_multi raises, _run_single_query returns the classified code."""
import search as search_mod
err = urllib.error.URLError("connection refused")
args = SimpleNamespace(
query="test", format="json", method="GET", timeout=15, retry=0,
serial=False, no_dedup=False, sort_by="none", max_results=None,
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
categories=None, language=None, pageno=1, time_range="year",
safesearch=0, engines="google,bing",
)
with patch.object(search_mod, "search_multi", side_effect=err):
results, err_str, err_code = _run_single_query(
"test", args, ["https://x.example.com"], {}, 0)
assert results is None
assert "connection refused" in err_str
assert err_code == E_NETWORK
def test_run_single_query_propagates_auth_error():
"""HTTP 403 → error_code = E_AUTH."""
import search as search_mod
err = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
args = SimpleNamespace(
query="test", format="json", method="GET", timeout=15, retry=0,
serial=False, no_dedup=False, sort_by="none", max_results=None,
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
categories=None, language=None, pageno=1, time_range="year",
safesearch=0, engines="google,bing",
)
with patch.object(search_mod, "search_multi", side_effect=err):
_, _, err_code = _run_single_query(
"test", args, ["https://x.example.com"], {}, 0)
assert err_code == E_AUTH
def test_run_single_query_propagates_rate_limit():
"""HTTP 429 → error_code = E_RATE_LIMIT."""
import search as search_mod
err = urllib.error.HTTPError("url", 429, "Too Many Requests", {}, None)
args = SimpleNamespace(
query="test", format="json", method="GET", timeout=15, retry=0,
serial=False, no_dedup=False, sort_by="none", max_results=None,
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
categories=None, language=None, pageno=1, time_range="year",
safesearch=0, engines="google,bing",
)
with patch.object(search_mod, "search_multi", side_effect=err):
_, _, 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