diff --git a/src/anthropic/_base_client.py b/src/anthropic/_base_client.py index dc90c8b5f..a09f65cf0 100644 --- a/src/anthropic/_base_client.py +++ b/src/anthropic/_base_client.py @@ -117,6 +117,16 @@ log: logging.Logger = logging.getLogger(__name__) + +def _is_celery_cancellation(err: BaseException) -> bool: + cls = type(err) + return cls.__module__ == "billiard.exceptions" and cls.__name__ in { + "SoftTimeLimitExceeded", + "Terminated", + "TimeLimitExceeded", + } + + # TODO: make base page type vars covariant SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") @@ -1323,6 +1333,8 @@ def _attempt_request( log.debug("Encountered httpx2.TimeoutException", exc_info=True) raise APITimeoutError(request=request) from err except Exception as err: + if _is_celery_cancellation(err): + raise if isinstance(err, AnthropicError): # SDK-originated errors already carry their own type; don't wrap. raise @@ -2054,6 +2066,8 @@ async def _attempt_request( log.debug("Encountered httpx2.TimeoutException", exc_info=True) raise APITimeoutError(request=request) from err except Exception as err: + if _is_celery_cancellation(err): + raise if isinstance(err, AnthropicError): # SDK-originated errors already carry their own type; don't wrap. raise diff --git a/tests/test_client.py b/tests/test_client.py index 04714522e..84d9840d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -91,6 +91,10 @@ class Counter: value: int = 0 +class SoftTimeLimitExceeded(Exception): + __module__ = "billiard.exceptions" + + def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: for item in iterable: if counter: @@ -1209,6 +1213,15 @@ def test_retrying_timeout_errors_doesnt_leak( assert _get_open_connections(client) == 0 + @pytest.mark.respx(base_url=base_url) + def test_celery_cancellation_is_not_retried(self, respx_mock: MockRouter, client: Anthropic) -> None: + respx_mock.post("/foo").mock(side_effect=SoftTimeLimitExceeded()) + + with pytest.raises(SoftTimeLimitExceeded): + client.post("/foo", cast_to=httpx2.Response) + + assert len(respx_mock.calls) == 1 + @pytest.mark.respx(base_url=base_url) def test_retrying_status_errors_doesnt_leak( self, respx_mock: MockRouter, client: Anthropic, monkeypatch: pytest.MonkeyPatch @@ -2523,6 +2536,17 @@ async def test_retrying_timeout_errors_doesnt_leak( assert _get_open_connections(async_client) == 0 + @pytest.mark.respx(base_url=base_url) + async def test_celery_cancellation_is_not_retried( + self, respx_mock: MockRouter, async_client: AsyncAnthropic + ) -> None: + respx_mock.post("/foo").mock(side_effect=SoftTimeLimitExceeded()) + + with pytest.raises(SoftTimeLimitExceeded): + await async_client.post("/foo", cast_to=httpx2.Response) + + assert len(respx_mock.calls) == 1 + @pytest.mark.respx(base_url=base_url) async def test_retrying_status_errors_doesnt_leak( self, respx_mock: MockRouter, async_client: AsyncAnthropic, monkeypatch: pytest.MonkeyPatch