fix(retry): classify 429 as WAIT vs STOP using semantic signals
This commit is contained in:
@@ -26,14 +26,16 @@ def test_openai_handle_error_extracts_structured_metadata() -> None:
|
||||
err.response = _fake_response(
|
||||
status_code=409,
|
||||
headers={"retry-after-ms": "250", "x-should-retry": "false"},
|
||||
text='{"error":"conflict"}',
|
||||
text='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}',
|
||||
)
|
||||
err.body = {"error": "conflict"}
|
||||
err.body = {"error": {"type": "rate_limit_exceeded", "code": "rate_limit_exceeded"}}
|
||||
|
||||
response = OpenAICompatProvider._handle_error(err)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_status_code == 409
|
||||
assert response.error_type == "rate_limit_exceeded"
|
||||
assert response.error_code == "rate_limit_exceeded"
|
||||
assert response.error_retry_after_s == 0.25
|
||||
assert response.error_should_retry is False
|
||||
|
||||
@@ -58,11 +60,13 @@ def test_anthropic_error_response_extracts_structured_metadata() -> None:
|
||||
status_code=408,
|
||||
headers={"retry-after": "1.5", "x-should-retry": "true"},
|
||||
)
|
||||
err.body = {"type": "error", "error": {"type": "rate_limit_error"}}
|
||||
|
||||
response = AnthropicProvider._error_response(err)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_status_code == 408
|
||||
assert response.error_type == "rate_limit_error"
|
||||
assert response.error_retry_after_s == 1.5
|
||||
assert response.error_should_retry is True
|
||||
|
||||
|
||||
@@ -297,6 +297,59 @@ async def test_chat_with_retry_retries_structured_status_code_without_keyword(mo
|
||||
assert delays == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_stops_on_429_quota_exhausted(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content='{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}',
|
||||
finish_reason="error",
|
||||
error_status_code=429,
|
||||
error_type="insufficient_quota",
|
||||
error_code="insufficient_quota",
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert provider.calls == 1
|
||||
assert delays == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_retries_429_transient_rate_limit(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
LLMResponse(
|
||||
content='{"error":{"type":"rate_limit_exceeded","code":"rate_limit_exceeded"}}',
|
||||
finish_reason="error",
|
||||
error_status_code=429,
|
||||
error_type="rate_limit_exceeded",
|
||||
error_code="rate_limit_exceeded",
|
||||
error_retry_after_s=0.2,
|
||||
),
|
||||
LLMResponse(content="ok"),
|
||||
])
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
|
||||
|
||||
response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.content == "ok"
|
||||
assert provider.calls == 2
|
||||
assert delays == [0.2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_retry_retries_structured_timeout_kind(monkeypatch) -> None:
|
||||
provider = ScriptedProvider([
|
||||
@@ -389,4 +442,3 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk
|
||||
assert response.content == "429 rate limit"
|
||||
assert provider.calls == 10
|
||||
assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user