diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index 390536f8..cfaa41ca 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -106,6 +106,32 @@ def test_sends_idempotency_key( assert request_kwargs["headers"]["idempotency-key"] == idempotency_key assert response is None + def test_auto_generates_idempotency_key( + self, mock_audit_log_event, capture_and_mock_http_client_request + ): + """Test that idempotency key is auto-generated when not provided.""" + organization_id = "org_123456789" + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, {"success": True}, 200 + ) + + response = self.audit_logs.create_event( + organization_id=organization_id, + event=mock_audit_log_event, + # No idempotency_key provided + ) + + # Assert header exists and has a non-empty value + assert "idempotency-key" in request_kwargs["headers"] + idempotency_key = request_kwargs["headers"]["idempotency-key"] + assert idempotency_key and idempotency_key.strip() + # Assert the auto-generated key has the correct prefix + assert idempotency_key.startswith("workos-python-") + # Assert the key has the expected UUID format after the prefix + assert len(idempotency_key) > len("workos-python-") + assert response is None + def test_throws_unauthorized_exception( self, mock_audit_log_event, mock_http_client_with_response ): diff --git a/tests/test_http_client_retry.py b/tests/test_http_client_retry.py new file mode 100644 index 00000000..7eee6acb --- /dev/null +++ b/tests/test_http_client_retry.py @@ -0,0 +1,554 @@ +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +import httpx + +from workos.utils._base_http_client import RetryConfig +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.exceptions import ServerException, BadRequestException + + +class TestSyncRetryLogic: + """Test retry logic for synchronous HTTP client.""" + + @pytest.fixture + def sync_http_client(self): + """Create a SyncHTTPClient for testing.""" + return SyncHTTPClient( + api_key="sk_test", + base_url="https://api.workos.test/", + client_id="client_test", + version="test", + ) + + @pytest.fixture + def retry_config(self): + """Create a RetryConfig for testing.""" + return RetryConfig( + max_retries=3, + base_delay=0.1, # Use shorter delays for faster tests + max_delay=1.0, + jitter=0.0, # No jitter for predictable tests + ) + + @staticmethod + def create_mock_request_with_retries( + failure_count: int, + failure_response=None, + failure_exception=None, + success_response=None, + ): + """ + Create a mock request function that fails N times before succeeding. + + Args: + failure_count: Number of times to fail before success + failure_response: Response to return on failure (status_code, json, headers) + failure_exception: Exception to raise on failure (instead of response) + success_response: Response to return on success (default: 200 with {"success": True}) + + Returns: + A tuple of (mock_function, call_count_tracker) where call_count_tracker + is a list that tracks the number of calls + """ + call_count = [0] # Use list to allow modification in nested function + + def mock_request(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= failure_count: + if failure_exception: + raise failure_exception + if failure_response: + return httpx.Response(**failure_response) + + if success_response: + return httpx.Response(**success_response) + return httpx.Response(status_code=200, json={"success": True}) + + return mock_request, call_count + + def assert_request_with_retries( + self, + http_client, + call_count, + expected_call_count: int, + expected_sleep_count: int, + retry_config=None, + expected_response=None, + ): + """ + Helper to execute a request with mocked sleep and assert the results. + + Args: + http_client: The HTTP client to test with + call_count: The call count tracker from create_mock_request_with_retries + expected_call_count: Expected number of request attempts + expected_sleep_count: Expected number of sleep calls + retry_config: Optional retry configuration + expected_response: Expected response dict (default: {"success": True}) + """ + if expected_response is None: + expected_response = {"success": True} + + with patch("time.sleep") as mock_sleep: + if retry_config: + response = http_client.request("test/path", retry_config=retry_config) + else: + response = http_client.request("test/path") + + assert call_count[0] == expected_call_count + assert response == expected_response + assert mock_sleep.call_count == expected_sleep_count + + def test_retries_on_408_error(self, sync_http_client, retry_config, monkeypatch): + """Test that 408 (Request Timeout) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 408, "json": {"error": "Request timeout"}}, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + self.assert_request_with_retries( + sync_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + def test_retries_on_500_error(self, sync_http_client, retry_config, monkeypatch): + """Test that 500 errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 500, "json": {"error": "Server error"}}, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + self.assert_request_with_retries( + sync_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + def test_retries_on_502_error(self, sync_http_client, retry_config, monkeypatch): + """Test that 502 (Bad Gateway) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 502, "json": {"error": "Bad gateway"}}, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + self.assert_request_with_retries( + sync_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + def test_retries_on_504_error(self, sync_http_client, retry_config, monkeypatch): + """Test that 504 (Gateway Timeout) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 504, "json": {"error": "Gateway timeout"}}, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + self.assert_request_with_retries( + sync_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + def test_no_retry_on_non_retryable_error( + self, sync_http_client, retry_config, monkeypatch + ): + """Test that a non retryable error errors do NOT trigger retry (not in RETRY_STATUS_CODES).""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=100, # Always fail + failure_response={ + "status_code": 503, + "json": {"error": "Service unavailable"}, + }, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + with pytest.raises(ServerException): + sync_http_client.request("test/path", retry_config=retry_config) + + # Should only be called once (no retries on 503) + assert call_count[0] == 1 + + def test_respects_max_retries(self, sync_http_client, retry_config, monkeypatch): + """Test that max retries limit is respected.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=100, # Always fail + failure_response={"status_code": 500, "json": {"error": "Server error"}}, + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + with patch("time.sleep"): + with pytest.raises(ServerException): + sync_http_client.request("test/path", retry_config=retry_config) + + # Should be called max_retries + 1 times (initial + 3 retries) + assert call_count[0] == 4 + + def test_retries_on_network_error( + self, sync_http_client, retry_config, monkeypatch + ): + """Test that network errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=2, failure_exception=httpx.ConnectError("Connection failed") + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + with patch("time.sleep"): + response = sync_http_client.request("test/path", retry_config=retry_config) + + assert call_count[0] == 3 + assert response == {"success": True} + + def test_retries_on_timeout_error( + self, sync_http_client, retry_config, monkeypatch + ): + """Test that timeout errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=1, + failure_exception=httpx.TimeoutException("Request timed out"), + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + with patch("time.sleep"): + response = sync_http_client.request("test/path", retry_config=retry_config) + + assert call_count[0] == 2 + assert response == {"success": True} + + def test_no_retry_on_success(self, sync_http_client, monkeypatch): + """Test that successful requests don't retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=0 # Success immediately + ) + + monkeypatch.setattr( + sync_http_client._client, "request", MagicMock(side_effect=mock_request) + ) + + response = sync_http_client.request("test/path") + + assert call_count[0] == 1 + assert response == {"success": True} + + +class TestAsyncRetryLogic: + """Test retry logic for asynchronous HTTP client.""" + + @pytest.fixture + def async_http_client(self): + """Create an AsyncHTTPClient for testing.""" + return AsyncHTTPClient( + api_key="sk_test", + base_url="https://api.workos.test/", + client_id="client_test", + version="test", + ) + + @pytest.fixture + def retry_config(self): + """Create a RetryConfig for testing.""" + return RetryConfig( + max_retries=3, + base_delay=0.1, # Use shorter delays for faster tests + max_delay=1.0, + jitter=0.0, # No jitter for predictable tests + ) + + @staticmethod + def create_mock_request_with_retries( + failure_count: int, + failure_response=None, + failure_exception=None, + success_response=None, + ): + """ + Create an async mock request function that fails N times before succeeding. + + Args: + failure_count: Number of times to fail before success + failure_response: Response to return on failure (status_code, json, headers) + failure_exception: Exception to raise on failure (instead of response) + success_response: Response to return on success (default: 200 with {"success": True}) + + Returns: + A tuple of (mock_function, call_count_tracker) where call_count_tracker + is a list that tracks the number of calls + """ + call_count = [0] # Use list to allow modification in nested function + + async def mock_request(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= failure_count: + if failure_exception: + raise failure_exception + if failure_response: + return httpx.Response(**failure_response) + + if success_response: + return httpx.Response(**success_response) + return httpx.Response(status_code=200, json={"success": True}) + + return mock_request, call_count + + async def assert_async_request_with_retries( + self, + http_client, + call_count, + expected_call_count: int, + expected_sleep_count: int, + retry_config=None, + expected_response=None, + ): + """ + Helper to execute an async request with mocked sleep and assert the results. + + Args: + http_client: The HTTP client to test with + call_count: The call count tracker from create_mock_request_with_retries + expected_call_count: Expected number of request attempts + expected_sleep_count: Expected number of sleep calls + retry_config: Optional retry configuration + expected_response: Expected response dict (default: {"success": True}) + """ + if expected_response is None: + expected_response = {"success": True} + + with patch("asyncio.sleep") as mock_sleep: + if retry_config: + response = await http_client.request( + "test/path", retry_config=retry_config + ) + else: + response = await http_client.request("test/path") + + assert call_count[0] == expected_call_count + assert response == expected_response + assert mock_sleep.call_count == expected_sleep_count + + @pytest.mark.asyncio + async def test_retries_on_408_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that 408 (Request Timeout) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 408, "json": {"error": "Request timeout"}}, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + await self.assert_async_request_with_retries( + async_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + @pytest.mark.asyncio + async def test_retries_on_500_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that 500 errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 500, "json": {"error": "Server error"}}, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + await self.assert_async_request_with_retries( + async_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + @pytest.mark.asyncio + async def test_retries_on_502_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that 502 (Bad Gateway) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 502, "json": {"error": "Bad gateway"}}, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + await self.assert_async_request_with_retries( + async_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + @pytest.mark.asyncio + async def test_retries_on_504_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that 504 (Gateway Timeout) errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=3, + failure_response={"status_code": 504, "json": {"error": "Gateway timeout"}}, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + await self.assert_async_request_with_retries( + async_http_client, + call_count, + expected_call_count=4, + expected_sleep_count=3, + retry_config=retry_config, + ) + + @pytest.mark.asyncio + async def test_no_retry_on_non_retryable_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that a non retryable error errors do NOT trigger retry (not in RETRY_STATUS_CODES).""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=100, # Always fail + failure_response={ + "status_code": 503, + "json": {"error": "Service unavailable"}, + }, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + with pytest.raises(ServerException): + await async_http_client.request("test/path", retry_config=retry_config) + + # Should only be called once (no retries on 503) + assert call_count[0] == 1 + + @pytest.mark.asyncio + async def test_respects_max_retries( + self, async_http_client, retry_config, monkeypatch + ): + """Test that max retries limit is respected.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=100, # Always fail + failure_response={"status_code": 500, "json": {"error": "Server error"}}, + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + with patch("asyncio.sleep"): + with pytest.raises(ServerException): + await async_http_client.request("test/path", retry_config=retry_config) + + # Should be called max_retries + 1 times (initial + 3 retries) + assert call_count[0] == 4 + + @pytest.mark.asyncio + async def test_retries_on_network_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that network errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=2, failure_exception=httpx.ConnectError("Connection failed") + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + with patch("asyncio.sleep"): + response = await async_http_client.request( + "test/path", retry_config=retry_config + ) + + assert call_count[0] == 3 + assert response == {"success": True} + + @pytest.mark.asyncio + async def test_retries_on_timeout_error( + self, async_http_client, retry_config, monkeypatch + ): + """Test that timeout errors trigger retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=1, + failure_exception=httpx.TimeoutException("Request timed out"), + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + with patch("asyncio.sleep"): + response = await async_http_client.request( + "test/path", retry_config=retry_config + ) + + assert call_count[0] == 2 + assert response == {"success": True} + + @pytest.mark.asyncio + async def test_no_retry_on_success(self, async_http_client, monkeypatch): + """Test that successful requests don't retry.""" + mock_request, call_count = self.create_mock_request_with_retries( + failure_count=0 # Success immediately + ) + + monkeypatch.setattr( + async_http_client._client, "request", AsyncMock(side_effect=mock_request) + ) + + response = await async_http_client.request("test/path") + + assert call_count[0] == 1 + assert response == {"success": True} diff --git a/workos/audit_logs.py b/workos/audit_logs.py index 73bacff8..83fb883f 100644 --- a/workos/audit_logs.py +++ b/workos/audit_logs.py @@ -1,7 +1,9 @@ +import uuid from typing import Optional, Protocol, Sequence from workos.types.audit_logs import AuditLogExport from workos.types.audit_logs.audit_log_event import AuditLogEvent +from workos.utils._base_http_client import RetryConfig from workos.utils.http_client import SyncHTTPClient from workos.utils.request_helper import REQUEST_METHOD_GET, REQUEST_METHOD_POST @@ -82,11 +84,19 @@ def create_event( json = {"organization_id": organization_id, "event": event} headers = {} - if idempotency_key: - headers["idempotency-key"] = idempotency_key + # Auto-generate UUID v4 if not provided + if idempotency_key is None: + idempotency_key = f"workos-python-{uuid.uuid4()}" + headers["idempotency-key"] = idempotency_key + + # Enable retries for audit log event creation with default retryConfig self._http_client.request( - EVENTS_PATH, method=REQUEST_METHOD_POST, json=json, headers=headers + EVENTS_PATH, + method=REQUEST_METHOD_POST, + json=json, + headers=headers, + retry_config=RetryConfig(), ) def create_export( diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index 49dcbcf5..905bbbb5 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -1,4 +1,6 @@ import platform +import random +from dataclasses import dataclass from typing import ( Any, Mapping, @@ -32,6 +34,19 @@ DEFAULT_REQUEST_TIMEOUT = 25 +# Status codes that should trigger a retry (consistent with workos-node) +RETRY_STATUS_CODES = [408, 500, 502, 504] + + +@dataclass +class RetryConfig: + """Configuration for retry logic with exponential backoff.""" + + max_retries: int = 3 + base_delay: float = 1.0 # seconds + max_delay: float = 30.0 # seconds + jitter: float = 0.25 # 25% jitter + ParamsType = Optional[Mapping[str, Any]] HeadersType = Optional[Dict[str, str]] @@ -56,6 +71,7 @@ class BaseHTTPClient(Generic[_HttpxClientT]): _base_url: str _version: str _timeout: int + _retry_config: Optional[RetryConfig] def __init__( self, @@ -65,12 +81,14 @@ def __init__( client_id: str, version: str, timeout: Optional[int] = DEFAULT_REQUEST_TIMEOUT, + retry_config: Optional[RetryConfig] = None, ) -> None: self._api_key = api_key self._base_url = base_url self._client_id = client_id self._version = version self._timeout = DEFAULT_REQUEST_TIMEOUT if timeout is None else timeout + self._retry_config = retry_config # Store as-is, None means no retries def _generate_api_url(self, path: str) -> str: return f"{self._base_url}{path}" @@ -196,6 +214,37 @@ def _handle_response(self, response: httpx.Response) -> ResponseJson: return cast(ResponseJson, response_json) + def _is_retryable_error(self, response: httpx.Response) -> bool: + """Determine if an error should be retried.""" + return response.status_code in RETRY_STATUS_CODES + + def _is_retryable_exception(self, exc: Exception) -> bool: + """Determine if an exception should trigger a retry.""" + # Retry on network [connection, timeout] exceptions + if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)): + return True + return False + + def _get_backoff_delay(self, attempt: int, retry_config: RetryConfig) -> float: + """Calculate delay with exponential backoff and jitter. + + Args: + attempt: The current retry attempt number (0-indexed) + retry_config: The retry configuration + + Returns: + The delay, in seconds, to wait before the next retry + """ + # Exponential backoff: base_delay * 2^attempt + delay: float = retry_config.base_delay * (2**attempt) + + # Cap at max_delay + delay = min(delay, retry_config.max_delay) + + # Add jitter: random variation of 0-25% of delay + jitter_amount: float = delay * retry_config.jitter * random.random() + return delay + jitter_amount + def build_request_url( self, url: str, diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py index 203c7df0..94022874 100644 --- a/workos/utils/http_client.py +++ b/workos/utils/http_client.py @@ -1,4 +1,5 @@ import asyncio +import time from types import TracebackType from typing import Optional, Type, Union @@ -13,6 +14,7 @@ JsonType, ParamsType, ResponseJson, + RetryConfig, ) from workos.utils.request_helper import REQUEST_METHOD_GET @@ -38,6 +40,7 @@ def __init__( client_id: str, version: str, timeout: Optional[int] = None, + retry_config: Optional[RetryConfig] = None, # If no custom transport is provided, let httpx use the default # so we don't overwrite environment configurations like proxies transport: Optional[httpx.BaseTransport] = None, @@ -48,6 +51,7 @@ def __init__( client_id=client_id, version=version, timeout=timeout, + retry_config=retry_config, ) self._client = SyncHttpxClientWrapper( base_url=base_url, @@ -88,6 +92,7 @@ def request( json: JsonType = None, headers: HeadersType = None, exclude_default_auth_headers: bool = False, + retry_config: Optional[RetryConfig] = None, ) -> ResponseJson: """Executes a request against the WorkOS API. @@ -98,6 +103,7 @@ def request( method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants params (ParamsType): Query params to be added to the request json (JsonType): Body payload to be added to the request + retry_config (RetryConfig): Optional retry configuration. If None, no retries. Returns: ResponseJson: Response from WorkOS @@ -110,8 +116,44 @@ def request( headers=headers, exclude_default_auth_headers=exclude_default_auth_headers, ) - response = self._client.request(**prepared_request_parameters) - return self._handle_response(response) + + # If no retry config provided, just make the request without retry logic + if retry_config is None: + response = self._client.request(**prepared_request_parameters) + return self._handle_response(response) + + # Retry logic enabled + last_exception = None + + for attempt in range(retry_config.max_retries + 1): + try: + response = self._client.request(**prepared_request_parameters) + + # Check if we should retry based on status code + if attempt < retry_config.max_retries and self._is_retryable_error( + response + ): + delay = self._get_backoff_delay(attempt, retry_config) + time.sleep(delay) + continue + + # No retry needed or max retries reached + return self._handle_response(response) + + except Exception as exc: + last_exception = exc + if attempt < retry_config.max_retries and self._is_retryable_exception( + exc + ): + delay = self._get_backoff_delay(attempt, retry_config) + time.sleep(delay) + continue + raise + + if last_exception is not None: + raise last_exception + + raise RuntimeError("Unexpected state in retry logic") class AsyncHttpxClientWrapper(httpx.AsyncClient): @@ -138,6 +180,7 @@ def __init__( client_id: str, version: str, timeout: Optional[int] = None, + retry_config: Optional[RetryConfig] = None, # If no custom transport is provided, let httpx use the default # so we don't overwrite environment configurations like proxies transport: Optional[httpx.AsyncBaseTransport] = None, @@ -148,6 +191,7 @@ def __init__( client_id=client_id, version=version, timeout=timeout, + retry_config=retry_config, ) self._client = AsyncHttpxClientWrapper( base_url=base_url, @@ -185,6 +229,7 @@ async def request( json: JsonType = None, headers: HeadersType = None, exclude_default_auth_headers: bool = False, + retry_config: Optional[RetryConfig] = None, ) -> ResponseJson: """Executes a request against the WorkOS API. @@ -195,6 +240,7 @@ async def request( method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants params (ParamsType): Query params to be added to the request json (JsonType): Body payload to be added to the request + retry_config (RetryConfig): Optional retry configuration. If None, no retries. Returns: ResponseJson: Response from WorkOS @@ -207,8 +253,46 @@ async def request( headers=headers, exclude_default_auth_headers=exclude_default_auth_headers, ) - response = await self._client.request(**prepared_request_parameters) - return self._handle_response(response) + + # If no retry config provided, just make the request without retry logic + if retry_config is None: + response = await self._client.request(**prepared_request_parameters) + return self._handle_response(response) + + # Retry logic enabled + last_exception = None + + for attempt in range(retry_config.max_retries + 1): + try: + response = await self._client.request(**prepared_request_parameters) + + # Check if we should retry based on status code + if attempt < retry_config.max_retries and self._is_retryable_error( + response + ): + delay = self._get_backoff_delay(attempt, retry_config) + await asyncio.sleep(delay) + continue + + # No retry needed or max retries reached + return self._handle_response(response) + + except Exception as exc: + last_exception = exc + if attempt < retry_config.max_retries and self._is_retryable_exception( + exc + ): + delay = self._get_backoff_delay(attempt, retry_config) + await asyncio.sleep(delay) + continue + raise + + # Should not reach here, but raise last exception if we do + if last_exception is not None: + raise last_exception + + # Fallback: this should never happen + raise RuntimeError("Unexpected state in retry logic") HTTPClient = Union[AsyncHTTPClient, SyncHTTPClient]