test_openrouter_bad_json_retry.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. # -*- coding: utf-8 -*-
  2. """复现 + 验证:OpenRouter 返回 HTTP 200 但 body 非合法 JSON 时,
  3. _openrouter_anthropic_call 应像 429/5xx 一样退避重试,而非让 json.JSONDecodeError
  4. 穿透到上层被误记成 "LLM 调用异常"。
  5. 根因见 agent/llm/openrouter.py:648 (response.json() 未保护)。
  6. 标准库 test,直接 `python tests/test_openrouter_bad_json_retry.py` 跑。
  7. """
  8. import asyncio
  9. import json
  10. import sys
  11. from pathlib import Path
  12. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  13. import agent.llm.openrouter as orm
  14. VALID_RESULT = {
  15. "content": [{"type": "text", "text": '{"ok": 1}'}],
  16. "stop_reason": "end_turn",
  17. "usage": {"input_tokens": 1, "output_tokens": 1},
  18. }
  19. class _FakeResp:
  20. def __init__(self, raise_times: int):
  21. # .json() 前 raise_times 次抛 JSONDecodeError,之后返回合法响应
  22. self._raise_times = raise_times
  23. self._calls = 0
  24. self.status_code = 200
  25. self.text = " " # 模拟非 JSON 残片
  26. def raise_for_status(self):
  27. return None
  28. def json(self):
  29. self._calls += 1
  30. if self._calls <= self._raise_times:
  31. raise json.JSONDecodeError("Expecting value", " ", 3)
  32. return VALID_RESULT
  33. class _FakeClient:
  34. """每个 attempt 新建一个;post 返回的 resp.json() 行为由外部计数器决定。"""
  35. def __init__(self, *a, **k):
  36. pass
  37. async def __aenter__(self):
  38. return self
  39. async def __aexit__(self, *a):
  40. return False
  41. async def post(self, *a, **k):
  42. return _FakeClient._resp_factory()
  43. def _run(raise_times: int):
  44. # 每次调用都新建一个 resp(模拟每个 attempt 是一次全新 HTTP 请求)
  45. resp_holder = {"n": 0}
  46. def factory():
  47. # 前 raise_times 次请求的 resp.json() 抛错,之后成功
  48. idx = resp_holder["n"]
  49. resp_holder["n"] += 1
  50. return _FakeResp(raise_times=1 if idx < raise_times else 0)
  51. _FakeClient._resp_factory = staticmethod(factory)
  52. async def _nosleep(*_a, **_k): # 退避不真等(注意不能再调用被替换的 asyncio.sleep)
  53. return None
  54. orig_client = orm.httpx.AsyncClient
  55. orig_sleep = orm.asyncio.sleep
  56. orm.httpx.AsyncClient = _FakeClient
  57. orm.asyncio.sleep = _nosleep
  58. try:
  59. return asyncio.run(orm._openrouter_anthropic_call(
  60. messages=[{"role": "user", "content": "hi"}],
  61. model="anthropic/claude-sonnet-4-6",
  62. tools=None,
  63. api_key="dummy",
  64. ))
  65. finally:
  66. orm.httpx.AsyncClient = orig_client
  67. orm.asyncio.sleep = orig_sleep
  68. def main():
  69. failures = []
  70. # 用例 1:前 2 次 body 非 JSON,第 3 次成功 → 应退避重试后成功
  71. try:
  72. out = _run(raise_times=2)
  73. assert out["content"] == '{"ok": 1}', out
  74. print("✅ case1 一过性 bad-JSON:退避重试后成功")
  75. except Exception as e:
  76. failures.append(f"case1 FAILED: {type(e).__name__}: {e}")
  77. print(f"❌ case1 FAILED: {type(e).__name__}: {e}")
  78. # 用例 2:3 次全是 bad-JSON → 应抛清晰错误(重试耗尽),而非裸 JSONDecodeError
  79. try:
  80. _run(raise_times=3)
  81. failures.append("case2 FAILED: 预期重试耗尽抛错,但没有抛")
  82. print("❌ case2 FAILED: 预期抛错但没抛")
  83. except json.JSONDecodeError as e:
  84. failures.append(f"case2 FAILED: 仍是裸 JSONDecodeError 穿透: {e}")
  85. print(f"❌ case2 FAILED: 裸 JSONDecodeError 穿透 → {e}")
  86. except Exception as e:
  87. print(f"✅ case2 持续 bad-JSON:重试耗尽抛清晰错误 [{type(e).__name__}] {str(e)[:80]}")
  88. print("-" * 50)
  89. if failures:
  90. print(f"FAIL ({len(failures)})")
  91. sys.exit(1)
  92. print("ALL PASS")
  93. if __name__ == "__main__":
  94. main()