Files
searxng-use-cli/tests/test_error_codes.py
T
thzxx f983a9377e feat(v1.7.0): AI 友好度增强 + 测试补全 (155→309)
核心新增(面向 AI Agent 程序化使用):
- 结构化错误码体系:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL
  classify_error() 自动分类异常,JSON 错误输出含 error_code 字段
- JSON Lines 流式输出 (--stream):每条结果独立一行,AI 可增量处理
- 进度事件 (--progress):JSON Lines 事件流到 stderr(start/cache_hit/fetch_ok/done 等)

测试补全(+154 例,覆盖全部高风险盲区):
- HTML 回退搜索路径 (19)
- --fetch 自动抓取 (21)
- --verify 健康检查 (15)
- 输出格式化 (15)
- 实例解析链 (20)
- 并行多实例搜索 (10)
- CLI 入口与端到端 (17)
- 错误码分类 (27)
- 流式输出与进度事件 (10)

源码改进:
- search.py: h3 内 a 标签 href 作为 url fallback,提升 SearXNG 主题兼容性
- common.py: 新增 classify_error/emit_progress/set_progress_enabled

文档同步:SKILL.md 新增 AI Agent Integration Guide 章节,README.md 更新参数与错误码表
2026-08-01 17:40:14 +08:00

234 lines
8.0 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