Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/anthropic/lib/_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,22 @@ def jitter(low: float, high: float) -> float:


def is_fatal_status_error(err: Exception) -> bool:
"""True for a 4xx that retrying will not fix (bad key, missing resource).
"""True for a status error that retrying will not fix (bad key, missing resource).

Aligns with the core client's ``_should_retry`` policy: 408 / 409 / 429 are
transient and worth retrying; every other 4xx is fatal.
Aligns with the core client's ``_should_retry`` policy:

* the server's explicit ``x-should-retry`` header wins — ``true`` is never
fatal, ``false`` is always fatal, regardless of the status code;
* otherwise 408 / 409 / 429 are transient and worth retrying, and every
other 4xx is fatal.
"""
return isinstance(err, APIStatusError) and 400 <= err.status_code < 500 and err.status_code not in _RETRYABLE_4XX
if not isinstance(err, APIStatusError):
return False

should_retry_header = err.response.headers.get("x-should-retry")
if should_retry_header == "true":
return False
if should_retry_header == "false":
return True

return 400 <= err.status_code < 500 and err.status_code not in _RETRYABLE_4XX
48 changes: 48 additions & 0 deletions tests/lib/test_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

import httpx
import pytest

from anthropic.lib._retry import is_fatal_status_error
from anthropic._exceptions import APIError, APIStatusError, APIConnectionError


def _status_error(status_code: int, headers: dict[str, str] | None = None) -> APIStatusError:
response = httpx.Response(
status_code,
headers=headers or {},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
return APIStatusError("boom", response=response, body=None)


class TestIsFatalStatusError:
@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 422])
def test_plain_4xx_is_fatal(self, status_code: int) -> None:
assert is_fatal_status_error(_status_error(status_code)) is True

@pytest.mark.parametrize("status_code", [408, 409, 429])
def test_transient_4xx_is_not_fatal(self, status_code: int) -> None:
assert is_fatal_status_error(_status_error(status_code)) is False

@pytest.mark.parametrize("status_code", [500, 502, 503])
def test_5xx_is_not_fatal(self, status_code: int) -> None:
assert is_fatal_status_error(_status_error(status_code)) is False

def test_x_should_retry_true_overrides_fatal_4xx(self) -> None:
# the server explicitly asks to retry a status that would otherwise be fatal
assert is_fatal_status_error(_status_error(400, {"x-should-retry": "true"})) is False

def test_x_should_retry_false_overrides_transient_4xx(self) -> None:
# the server explicitly asks NOT to retry a status that would otherwise be transient
assert is_fatal_status_error(_status_error(429, {"x-should-retry": "false"})) is True

def test_x_should_retry_false_makes_5xx_fatal(self) -> None:
assert is_fatal_status_error(_status_error(503, {"x-should-retry": "false"})) is True

def test_non_status_errors_are_not_fatal(self) -> None:
# transport-level errors are retryable, not fatal
request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
assert is_fatal_status_error(APIConnectionError(request=request)) is False
assert is_fatal_status_error(APIError("x", request=request, body=None)) is False
assert is_fatal_status_error(ValueError("not an api error")) is False