Files
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

192 lines
5.6 KiB
Python

"""Tests for scripts/common.py — auth, charset, retry policy, proxy.
Covers: build_auth_headers (Bearer/Basic/precedence), resolve_auth_* priority
(CLI > file > env, file parsing, empty-file errors), detect_charset
(header/meta/fallback), is_retryable_error (429/5xx yes, 404/403 no,
connection errors yes), and apply_proxy (env vars + NO_PROXY preservation).
"""
import base64
import os
import urllib.error
import pytest
from common import (
RETRYABLE_STATUS,
apply_proxy,
build_auth_headers,
detect_charset,
is_retryable_error,
resolve_auth_basic,
resolve_auth_bearer,
)
# ----- build_auth_headers -----
def test_build_auth_bearer():
assert build_auth_headers(bearer_token="tok123") == \
{"Authorization": "Bearer tok123"}
def test_build_auth_basic():
h = build_auth_headers(basic_auth="user:pass")
assert h["Authorization"].startswith("Basic ")
decoded = base64.b64decode(h["Authorization"].split(" ", 1)[1]).decode()
assert decoded == "user:pass"
def test_build_auth_bearer_wins_over_basic():
"""Bearer takes precedence when both are provided."""
h = build_auth_headers(bearer_token="tok", basic_auth="u:p")
assert h == {"Authorization": "Bearer tok"}
def test_build_auth_none_returns_empty():
assert build_auth_headers() == {}
# ----- resolve_auth_basic -----
def test_resolve_auth_basic_cli_wins():
assert resolve_auth_basic(cli_value="cliu:clip") == "cliu:clip"
def test_resolve_auth_basic_file(tmp_path):
f = tmp_path / "auth.txt"
f.write_text("# header\nu:secret\n", encoding="utf-8")
assert resolve_auth_basic(file_path=str(f)) == "u:secret"
def test_resolve_auth_basic_env(monkeypatch):
monkeypatch.setenv("SEARXNG_BASIC_AUTH", "envu:envp")
assert resolve_auth_basic() == "envu:envp"
def test_resolve_auth_basic_priority_cli_over_file(tmp_path):
f = tmp_path / "auth.txt"
f.write_text("fileu:filep", encoding="utf-8")
assert resolve_auth_basic(cli_value="cliu:clip", file_path=str(f)) == "cliu:clip"
def test_resolve_auth_basic_empty_file_raises(tmp_path):
f = tmp_path / "empty.txt"
f.write_text("# only comment\n", encoding="utf-8")
with pytest.raises(RuntimeError):
resolve_auth_basic(file_path=str(f))
def test_resolve_auth_basic_missing_file_raises():
with pytest.raises(RuntimeError):
resolve_auth_basic(file_path="nonexistent_file.txt")
# ----- resolve_auth_bearer -----
def test_resolve_auth_bearer_cli():
assert resolve_auth_bearer(cli_value="tok") == "tok"
def test_resolve_auth_bearer_env(monkeypatch):
monkeypatch.setenv("SEARXNG_BEARER_TOKEN", "envtok")
assert resolve_auth_bearer() == "envtok"
def test_resolve_auth_bearer_file(tmp_path):
f = tmp_path / "tok.txt"
f.write_text("# header\ntoken123\n", encoding="utf-8")
assert resolve_auth_bearer(file_path=str(f)) == "token123"
# ----- detect_charset -----
def test_detect_charset_from_header():
assert detect_charset(b"hello", "text/html; charset=utf-8") == "utf-8"
def test_detect_charset_from_meta_tag():
html = b'<html><head><meta charset="gbk"></head><body>hello</body></html>'
assert detect_charset(html, "text/html") == "gbk"
def test_detect_charset_fallback_utf8():
assert detect_charset(b"plain", "text/html") == "utf-8"
def test_detect_charset_invalid_header_falls_back():
"""An invalid charset in the header should not raise; falls back to utf-8."""
assert detect_charset(b"hello", "text/html; charset=nonexistent_encoding") == "utf-8"
# ----- is_retryable_error -----
def _http_error(code):
return urllib.error.HTTPError("http://x", code, "msg", {}, None)
def test_retryable_429():
assert is_retryable_error(_http_error(429)) is True
def test_retryable_502():
assert is_retryable_error(_http_error(502)) is True
def test_retryable_503():
assert is_retryable_error(_http_error(503)) is True
def test_retryable_504():
assert is_retryable_error(_http_error(504)) is True
def test_non_retryable_404():
assert is_retryable_error(_http_error(404)) 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():
assert is_retryable_error(_http_error(200)) is False
def test_retryable_url_error():
assert is_retryable_error(urllib.error.URLError("refused")) is True
def test_retryable_os_error():
assert is_retryable_error(OSError("timeout")) is True
def test_retryable_status_set_contents():
# 403 加入可重试集合,让 UA-fallback 在 UA 被屏蔽时有机会切换浏览器 UA
assert RETRYABLE_STATUS == frozenset({403, 429, 502, 503, 504})
# ----- apply_proxy -----
def test_apply_proxy_sets_env_vars(monkeypatch):
monkeypatch.delenv("HTTP_PROXY", raising=False)
monkeypatch.delenv("HTTPS_PROXY", raising=False)
monkeypatch.delenv("NO_PROXY", raising=False)
apply_proxy("http://corp:8080")
assert os.environ["HTTP_PROXY"] == "http://corp:8080"
assert os.environ["HTTPS_PROXY"] == "http://corp:8080"
assert os.environ["NO_PROXY"] == "localhost,127.0.0.1,::1"
def test_apply_proxy_empty_is_noop(monkeypatch):
monkeypatch.delenv("HTTP_PROXY", raising=False)
apply_proxy("")
assert "HTTP_PROXY" not in os.environ
def test_apply_proxy_preserves_existing_no_proxy(monkeypatch):
"""setdefault must NOT overwrite a user-set NO_PROXY."""
monkeypatch.setenv("NO_PROXY", "custom.host")
apply_proxy("http://corp:8080")
assert os.environ["NO_PROXY"] == "custom.host"