test_model_retries.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """使用真实模型客户端与离线 HTTP 响应验证限流恢复和重试边界。"""
  2. import asyncio
  3. from contextlib import asynccontextmanager
  4. import json
  5. import httpx
  6. import openai._base_client as openai_base
  7. import pytest
  8. from langchain_openai.chat_models.base import (
  9. OpenAIAuthenticationError, OpenAIConnectionError, OpenAIRateLimitError,
  10. )
  11. from agents import model_factory
  12. MODEL = "Qwen/Qwen3.5-35B-A3B-FP8"
  13. def limited():
  14. return httpx.Response(429, headers={"Retry-After": "11"}, json={
  15. "error": {"code": "concurrent_limit", "message": "并发数超限,请稍后重试",
  16. "type": "rate_limit_error"}})
  17. def sse():
  18. def chunk(delta, finish=None):
  19. return "data: " + json.dumps({
  20. "id": "chatcmpl-retry-test", "object": "chat.completion.chunk",
  21. "created": 0, "model": MODEL,
  22. "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
  23. }, ensure_ascii=False) + "\n\n"
  24. return (chunk({"role": "assistant", "content": "已恢复"})
  25. + chunk({}, "stop") + "data: [DONE]\n\n").encode()
  26. @asynccontextmanager
  27. async def mocked_model(monkeypatch, handler, retries=8):
  28. monkeypatch.setattr(model_factory.config, "OPENAI_MAX_RETRIES", retries)
  29. instantiate = model_factory._instantiate
  30. with httpx.Client(transport=httpx.MockTransport(handler)) as sync:
  31. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  32. monkeypatch.setattr(model_factory, "_instantiate", lambda name, options: instantiate(
  33. name, {**options, "http_client": sync, "http_async_client": client}))
  34. yield model_factory.build_model(
  35. MODEL, base_url="https://model.invalid/v1", api_key="sk-test")
  36. @pytest.mark.asyncio
  37. async def test_stream_recovers_after_three_concurrent_limits_and_honors_retry_after(monkeypatch):
  38. requests, waits = [], []
  39. def handler(request):
  40. requests.append(request.content)
  41. if len(requests) <= 3:
  42. return limited()
  43. return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=sse())
  44. async def sleep(delay):
  45. waits.append(delay)
  46. monkeypatch.setattr(openai_base.anyio, "sleep", sleep)
  47. async with mocked_model(monkeypatch, handler) as model:
  48. chunks = [chunk async for chunk in model.astream("测试")]
  49. assert "".join(chunk.content for chunk in chunks) == "已恢复"
  50. assert len(requests) == 4 and len(set(requests)) == 1
  51. assert waits == [11, 11, 11]
  52. @pytest.mark.asyncio
  53. @pytest.mark.parametrize("retries", [0, 2])
  54. async def test_retry_budget_is_bounded_and_zero_disables_it(monkeypatch, retries):
  55. calls, waits = [], []
  56. def handler(request):
  57. calls.append(request)
  58. return limited()
  59. async def sleep(delay):
  60. waits.append(delay)
  61. monkeypatch.setattr(openai_base.anyio, "sleep", sleep)
  62. async with mocked_model(monkeypatch, handler, retries) as model:
  63. with pytest.raises(OpenAIRateLimitError, match="concurrent_limit"):
  64. await model.ainvoke("测试")
  65. assert len(calls) == retries + 1
  66. assert waits == [11] * retries
  67. @pytest.mark.asyncio
  68. async def test_authentication_failure_is_not_retried(monkeypatch):
  69. calls = []
  70. def handler(request):
  71. calls.append(request)
  72. return httpx.Response(401, json={"error": {"message": "invalid key", "type": "authentication_error"}})
  73. async with mocked_model(monkeypatch, handler) as model:
  74. with pytest.raises(OpenAIAuthenticationError):
  75. await model.ainvoke("测试")
  76. assert len(calls) == 1
  77. @pytest.mark.asyncio
  78. async def test_cancelling_during_retry_wait_stops_further_requests(monkeypatch):
  79. calls = []
  80. waiting = asyncio.Event()
  81. def handler(request):
  82. calls.append(request)
  83. return limited()
  84. async def sleep(delay):
  85. waiting.set()
  86. await asyncio.Event().wait()
  87. monkeypatch.setattr(openai_base.anyio, "sleep", sleep)
  88. async with mocked_model(monkeypatch, handler) as model:
  89. task = asyncio.create_task(model.ainvoke("测试"))
  90. try:
  91. await asyncio.wait_for(waiting.wait(), timeout=5)
  92. finally:
  93. task.cancel()
  94. with pytest.raises(asyncio.CancelledError):
  95. await task
  96. assert len(calls) == 1
  97. @pytest.mark.asyncio
  98. async def test_broken_stream_is_not_replayed(monkeypatch):
  99. calls, received = [], []
  100. class BrokenStream(httpx.AsyncByteStream):
  101. async def __aiter__(self):
  102. yield sse().split(b"data: [DONE]")[0]
  103. raise httpx.ReadError("stream interrupted")
  104. def handler(request):
  105. calls.append(request)
  106. return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, stream=BrokenStream())
  107. async with mocked_model(monkeypatch, handler) as model:
  108. with pytest.raises(OpenAIConnectionError):
  109. async for chunk in model.astream("测试"):
  110. received.append(chunk.content)
  111. assert "".join(received) == "已恢复"
  112. assert len(calls) == 1
  113. def test_negative_retry_budget_is_rejected(monkeypatch):
  114. monkeypatch.setattr(model_factory.config, "OPENAI_MAX_RETRIES", -1)
  115. with pytest.raises(ValueError, match="OPENAI_MAX_RETRIES"):
  116. model_factory.build_model(MODEL)
  117. @pytest.mark.asyncio
  118. async def test_http_200_empty_sse_does_not_become_a_successful_execution(monkeypatch, tmp_path, pricing_ready):
  119. from langchain.agents import create_agent
  120. from langgraph.checkpoint.memory import InMemorySaver
  121. from test_execution import prepared, assert_released
  122. monkeypatch.setattr(model_factory.config, 'ROOT', tmp_path)
  123. empty = sse().replace('已恢复'.encode(), b'')
  124. calls = []
  125. def handler(request):
  126. calls.append(request)
  127. return httpx.Response(200, headers={'Content-Type': 'text/event-stream'}, content=empty)
  128. async with mocked_model(monkeypatch, handler) as model:
  129. graph = create_agent(model=model, checkpointer=InMemorySaver())
  130. task = await prepared(graph)
  131. result = await task.run()
  132. assert result.status == 'failed' and result.error == 'empty_model_response'
  133. assert result.output == '' and result.structured_output is None
  134. assert result.usage['usage_source'] == 'estimate'
  135. assert result.diagnostics['model_responses'] == 1
  136. assert result.diagnostics['last_response']['finish_reason'] == 'stop'
  137. assert len(calls) == 1, '已建立并正常结束的 HTTP 200 流不会触发 SDK 的 HTTP 重试'
  138. assert_released()