fix: use structured error metadata for app-layer retry

This commit is contained in:
pikaxinge
2026-04-02 18:42:20 +00:00
parent 7113ad34f4
commit b951b37c97
5 changed files with 355 additions and 8 deletions
@@ -0,0 +1,77 @@
from types import SimpleNamespace
from nanobot.providers.anthropic_provider import AnthropicProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
def _fake_response(
*,
status_code: int,
headers: dict[str, str] | None = None,
text: str = "",
) -> SimpleNamespace:
return SimpleNamespace(
status_code=status_code,
headers=headers or {},
text=text,
)
def test_openai_handle_error_extracts_structured_metadata() -> None:
class FakeStatusError(Exception):
pass
err = FakeStatusError("boom")
err.status_code = 409
err.response = _fake_response(
status_code=409,
headers={"retry-after-ms": "250", "x-should-retry": "false"},
text='{"error":"conflict"}',
)
err.body = {"error": "conflict"}
response = OpenAICompatProvider._handle_error(err)
assert response.finish_reason == "error"
assert response.error_status_code == 409
assert response.error_retry_after_s == 0.25
assert response.error_should_retry is False
def test_openai_handle_error_marks_timeout_kind() -> None:
class FakeTimeoutError(Exception):
pass
response = OpenAICompatProvider._handle_error(FakeTimeoutError("timeout"))
assert response.finish_reason == "error"
assert response.error_kind == "timeout"
def test_anthropic_error_response_extracts_structured_metadata() -> None:
class FakeStatusError(Exception):
pass
err = FakeStatusError("boom")
err.status_code = 408
err.response = _fake_response(
status_code=408,
headers={"retry-after": "1.5", "x-should-retry": "true"},
)
response = AnthropicProvider._error_response(err)
assert response.finish_reason == "error"
assert response.error_status_code == 408
assert response.error_retry_after_s == 1.5
assert response.error_should_retry is True
def test_anthropic_error_response_marks_connection_kind() -> None:
class FakeConnectionError(Exception):
pass
response = AnthropicProvider._error_response(FakeConnectionError("connection"))
assert response.finish_reason == "error"
assert response.error_kind == "connection"
+94 -1
View File
@@ -240,6 +240,100 @@ async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypa
assert progress and "7s" in progress[0]
@pytest.mark.asyncio
async def test_chat_with_retry_retries_structured_status_code_without_keyword(monkeypatch) -> None:
provider = ScriptedProvider([
LLMResponse(
content="request failed",
finish_reason="error",
error_status_code=409,
),
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 == [1]
@pytest.mark.asyncio
async def test_chat_with_retry_retries_structured_timeout_kind(monkeypatch) -> None:
provider = ScriptedProvider([
LLMResponse(
content="request failed",
finish_reason="error",
error_kind="timeout",
),
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 == [1]
@pytest.mark.asyncio
async def test_chat_with_retry_structured_should_retry_false_disables_retry(monkeypatch) -> None:
provider = ScriptedProvider([
LLMResponse(
content="429 rate limit",
finish_reason="error",
error_should_retry=False,
),
])
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_prefers_structured_retry_after(monkeypatch) -> None:
provider = ScriptedProvider([
LLMResponse(
content="429 rate limit, retry after 99s",
finish_reason="error",
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 delays == [0.2]
@pytest.mark.asyncio
async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monkeypatch) -> None:
provider = ScriptedProvider([
@@ -263,4 +357,3 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk
assert provider.calls == 10
assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4]