fix(retry): classify 429 as WAIT vs STOP using semantic signals

This commit is contained in:
pikaxinge
2026-04-04 05:23:21 +00:00
parent cabf093915
commit 31d3061a0a
5 changed files with 194 additions and 4 deletions
@@ -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
+53 -1
View File
@@ -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]