test_crawapi_http.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """V3-M1A: shared crawapi HTTP base unit tests."""
  2. from __future__ import annotations
  3. import httpx
  4. import pytest
  5. from content_agent.errors import ContentAgentError, ErrorCode
  6. from content_agent.integrations.crawapi_http import (
  7. CrawapiBusinessError,
  8. RateLimiter,
  9. is_rate_limit_business_error,
  10. post_crawapi_json,
  11. )
  12. class FakeHttpClient:
  13. def __init__(self, responses):
  14. self.responses = list(responses)
  15. self.requests = []
  16. def post(self, url, json, headers, timeout):
  17. self.requests.append({"url": url, "json": json})
  18. response = self.responses.pop(0)
  19. if isinstance(response, Exception):
  20. raise response
  21. return response
  22. def _response(status_code, data, headers=None):
  23. return httpx.Response(
  24. status_code,
  25. json=data,
  26. headers=headers,
  27. request=httpx.Request("POST", "http://crawapi.test/x"),
  28. )
  29. def _text_response(status_code, text):
  30. return httpx.Response(
  31. status_code,
  32. text=text,
  33. headers={"content-type": "text/plain"},
  34. request=httpx.Request("POST", "http://crawapi.test/x"),
  35. )
  36. def _post(responses, **kwargs):
  37. return post_crawapi_json(
  38. http_client=FakeHttpClient(responses),
  39. base_url="http://crawapi.test/",
  40. path="x",
  41. payload={},
  42. operation="probe",
  43. timeout_seconds=60.0,
  44. business_codes=kwargs.get("business_codes", set()),
  45. rate_limiter=kwargs.get("rate_limiter"),
  46. rate_limit_bucket=kwargs.get("rate_limit_bucket"),
  47. )
  48. def test_rate_limiter_waits_min_interval_between_same_bucket():
  49. clock = {"now": 0.0}
  50. sleeps: list[float] = []
  51. limiter = RateLimiter(
  52. min_interval_seconds=30.0,
  53. now_fn=lambda: clock["now"],
  54. sleep_fn=lambda s: (sleeps.append(s), clock.__setitem__("now", clock["now"] + s)),
  55. )
  56. limiter.wait("b")
  57. limiter.wait("b")
  58. assert sleeps == [30.0]
  59. def test_rate_limiter_supports_random_interval_between_same_bucket():
  60. clock = {"now": 0.0}
  61. sleeps: list[float] = []
  62. limiter = RateLimiter(
  63. min_interval_seconds=10.0,
  64. max_interval_seconds=12.0,
  65. now_fn=lambda: clock["now"],
  66. sleep_fn=lambda s: (sleeps.append(s), clock.__setitem__("now", clock["now"] + s)),
  67. random_fn=lambda lo, hi: (lo + hi) / 2,
  68. )
  69. limiter.wait("b")
  70. limiter.wait("b")
  71. assert sleeps == [11.0]
  72. def test_http_429_maps_to_platform_rate_limited():
  73. with pytest.raises(ContentAgentError) as exc:
  74. _post([_response(429, {"msg": "slow down"})])
  75. assert exc.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  76. def test_message_token_maps_to_platform_rate_limited():
  77. with pytest.raises(ContentAgentError) as exc:
  78. _post([_response(200, {"code": 50000, "msg": "请求频繁"})])
  79. assert exc.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
  80. def test_bad_response_non_dict_raises_runtime_error():
  81. with pytest.raises(RuntimeError, match="bad_response"):
  82. _post([_response(200, ["not", "a", "dict"])])
  83. def test_http_error_includes_response_summary():
  84. with pytest.raises(RuntimeError) as exc:
  85. _post([_text_response(502, "Bad Gateway from crawapi")])
  86. message = str(exc.value)
  87. assert "HTTP 502" in message
  88. assert "Bad Gateway from crawapi" in message
  89. def test_business_error_includes_code_and_message():
  90. with pytest.raises(RuntimeError) as exc:
  91. _post([_response(200, {"code": 50001, "msg": "source url expired"})])
  92. message = str(exc.value)
  93. assert "business_error" in message
  94. assert "code=50001" in message
  95. assert "source url expired" in message
  96. def test_business_error_carries_full_response_detail():
  97. with pytest.raises(CrawapiBusinessError) as exc:
  98. post_crawapi_json(
  99. http_client=FakeHttpClient(
  100. [
  101. _response(
  102. 200,
  103. {
  104. "code": 10000,
  105. "msg": "未知错误",
  106. "request_id": "body-request",
  107. "data": {
  108. "trace_id": "body-trace",
  109. "reason": "upstream opaque failure",
  110. "retry_after": 30,
  111. },
  112. },
  113. headers={"x-request-id": "header-request"},
  114. )
  115. ]
  116. ),
  117. base_url="http://crawapi.test/",
  118. path="x",
  119. payload={
  120. "keyword": "历史人物评价",
  121. "cursor": "0",
  122. "account_id": "should-not-persist",
  123. },
  124. operation="keyword_search",
  125. timeout_seconds=60.0,
  126. business_codes=set(),
  127. )
  128. detail = exc.value.detail
  129. assert detail["operation"] == "keyword_search"
  130. assert detail["business_code"] == "10000"
  131. assert detail["business_message"] == "未知错误"
  132. assert detail["response_summary"]["code"] == "10000"
  133. assert detail["response_json_keys"] == ["code", "data", "msg", "request_id"]
  134. assert detail["business_data"] == {
  135. "trace_id": "body-trace",
  136. "reason": "upstream opaque failure",
  137. "retry_after": 30,
  138. }
  139. assert detail["trace_refs"]["request_id"] == "body-request"
  140. assert detail["trace_refs"]["trace_id"] == "body-trace"
  141. assert detail["trace_refs"]["x-request-id"] == "header-request"
  142. assert detail["request_payload_summary"] == {
  143. "keyword": "历史人物评价",
  144. "cursor": "0",
  145. "account_id": "<redacted>",
  146. }
  147. def test_business_codes_param_classifies_rate_limit():
  148. assert is_rate_limit_business_error("30005", {}, business_codes={"30005"}) is True
  149. assert is_rate_limit_business_error("30005", {}, business_codes=set()) is False