diff --git a/README.md b/README.md index 88fadc3..bfad525 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,31 @@ except APIConnectionError as e: client = PolymarketUS( key_id="your-key-id", secret_key="your-secret-key", - timeout=30.0, # Request timeout in seconds (default: 30.0) + timeout=30.0, # Request timeout in seconds (default: 30.0) + max_retries=2, # Automatic retries for idempotent requests (default: 2) ) ``` +### Retries & reliability + +Idempotent requests (`GET`, `DELETE`) are retried automatically on transient +failures — connection errors, timeouts, and `408`/`409`/`429`/`5xx` responses — +using exponential backoff with jitter. Non-idempotent requests such as order +placement are **never** retried automatically, so a network blip cannot submit a +duplicate order. Set `max_retries=0` to disable retries. + +Every request sends a `User-Agent` and a generated `poly-correlation-id` so +failures can be traced. The correlation id is attached to raised errors: + +```python +from polymarket_us import APIError + +try: + client.account.balances() +except APIError as e: + print(e.status_code, e.message, e.request_id) +``` + ### WebSocket (Real-Time Data) > **Note**: WebSocket connections are async-only due to their event-driven nature. diff --git a/polymarket_us/_retry.py b/polymarket_us/_retry.py new file mode 100644 index 0000000..c403413 --- /dev/null +++ b/polymarket_us/_retry.py @@ -0,0 +1,57 @@ +"""Retry helpers shared by the sync and async clients. + +The Polymarket US API does not return ``Retry-After`` or ``X-RateLimit-*`` +headers, so backoff is computed client-side with exponential growth and jitter. +Only idempotent methods are retried automatically: order placement and other +``POST`` requests are never retried because the API has no idempotency key, so a +retry after a partial failure could submit a duplicate order. +""" + +from __future__ import annotations + +import random + +DEFAULT_MAX_RETRIES = 2 + +# Status codes that are safe to retry for idempotent requests. +RETRYABLE_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504}) + +# Methods without side effects, safe to retry on transient failures. +IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "DELETE"}) + +_BACKOFF_INITIAL_SECONDS = 0.5 +_BACKOFF_MAX_SECONDS = 8.0 + + +def is_retryable_status(status_code: int) -> bool: + """Return whether a response status code is safe to retry.""" + return status_code in RETRYABLE_STATUS_CODES + + +def can_retry_method(method: str) -> bool: + """Return whether a request method is safe to retry automatically.""" + return method.upper() in IDEMPOTENT_METHODS + + +def backoff_delay(attempt: int, retry_after: float | None = None) -> float: + """Compute the delay before the next retry attempt (0-indexed). + + Honors an explicit ``Retry-After`` value when present (clamped to + ``_BACKOFF_MAX_SECONDS`` so a hostile or malformed header cannot make the + client sleep indefinitely); otherwise applies exponential backoff with equal + jitter to avoid thundering-herd retries. + """ + if retry_after is not None and retry_after >= 0: + return min(retry_after, _BACKOFF_MAX_SECONDS) + capped = min(_BACKOFF_INITIAL_SECONDS * (2**attempt), _BACKOFF_MAX_SECONDS) + return capped / 2 + random.random() * (capped / 2) + + +def retry_after_seconds(header_value: str | None) -> float | None: + """Parse a ``Retry-After`` header expressed in seconds, if present.""" + if not header_value: + return None + try: + return float(header_value) + except ValueError: + return None diff --git a/polymarket_us/async_client.py b/polymarket_us/async_client.py index ed52d49..3f30f4f 100644 --- a/polymarket_us/async_client.py +++ b/polymarket_us/async_client.py @@ -2,11 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import asyncio +import uuid +from typing import TYPE_CHECKING, Any, NoReturn import httpx +from polymarket_us import _retry from polymarket_us.auth import create_auth_headers +from polymarket_us.client import CORRELATION_ID_HEADER, default_user_agent from polymarket_us.errors import ( APIConnectionError, APIStatusError, @@ -47,6 +51,7 @@ def __init__( gateway_base_url: str = GATEWAY_BASE_URL, api_base_url: str = API_BASE_URL, timeout: float = 30.0, + max_retries: int = _retry.DEFAULT_MAX_RETRIES, ) -> None: """Initialize the Polymarket US async client. @@ -56,13 +61,18 @@ def __init__( gateway_base_url: Base URL for public gateway API api_base_url: Base URL for authenticated API timeout: Request timeout in seconds + max_retries: Maximum automatic retries for idempotent requests on + transient failures (timeouts, connection errors, and 408/409/429/5xx). + Non-idempotent requests (e.g. order placement) are never retried. """ self.key_id = key_id self.secret_key = secret_key self.gateway_base_url = gateway_base_url self.api_base_url = api_base_url self.timeout = timeout + self.max_retries = max_retries + self._user_agent = default_user_agent() self._http = httpx.AsyncClient(timeout=timeout) # Resource instances @@ -118,43 +128,70 @@ async def _request( body: dict[str, Any] | None = None, authenticated: bool = False, ) -> Any: - """Make an HTTP request.""" + """Make an HTTP request, retrying idempotent requests on transient errors.""" base_url = self.api_base_url if authenticated else self.gateway_base_url url = f"{base_url}{path}" - headers = {"Content-Type": "application/json"} - - if authenticated: - if not self.key_id or not self.secret_key: - raise AuthenticationError( - "API key credentials required for authenticated endpoints. " - "Provide key_id and secret_key when initializing the client.", - response=_make_fake_response(401, url), - ) - auth_headers = create_auth_headers(self.key_id, self.secret_key, method, path) - headers.update(auth_headers) + if authenticated and (not self.key_id or not self.secret_key): + raise AuthenticationError( + "API key credentials required for authenticated endpoints. " + "Provide key_id and secret_key when initializing the client.", + response=_make_fake_response(401, url), + ) params = self._build_query_params(query) if query else None + correlation_id = str(uuid.uuid4()) + base_headers = { + "Content-Type": "application/json", + "User-Agent": self._user_agent, + CORRELATION_ID_HEADER: correlation_id, + } - try: - response = await self._http.request( - method, - url, - params=params, - json=body, - headers=headers, - ) - except httpx.TimeoutException as e: - raise APITimeoutError(request=getattr(e, "_request", None)) - except httpx.ConnectError as e: - raise APIConnectionError(message=str(e), request=getattr(e, "_request", None)) + attempt = 0 + while True: + headers = dict(base_headers) + if authenticated: + headers.update( + create_auth_headers(self.key_id, self.secret_key, method, path) # type: ignore[arg-type] + ) - if not response.is_success: - self._handle_error_response(response) + try: + response = await self._http.request( + method, url, params=params, json=body, headers=headers + ) + except httpx.TimeoutException as e: + if _retry.can_retry_method(method) and attempt < self.max_retries: + await asyncio.sleep(_retry.backoff_delay(attempt)) + attempt += 1 + continue + raise APITimeoutError( + request=getattr(e, "_request", None), request_id=correlation_id + ) + except httpx.TransportError as e: + if _retry.can_retry_method(method) and attempt < self.max_retries: + await asyncio.sleep(_retry.backoff_delay(attempt)) + attempt += 1 + continue + raise APIConnectionError( + message=str(e), request=getattr(e, "_request", None), request_id=correlation_id + ) + + if response.is_success: + if not response.text: + return {} + return response.json() + + if ( + _retry.is_retryable_status(response.status_code) + and _retry.can_retry_method(method) + and attempt < self.max_retries + ): + retry_after = _retry.retry_after_seconds(response.headers.get("Retry-After")) + await asyncio.sleep(_retry.backoff_delay(attempt, retry_after)) + attempt += 1 + continue - if not response.text: - return {} - return response.json() + self._handle_error_response(response, correlation_id) def _build_query_params( self, query: dict[str, Any] @@ -173,8 +210,10 @@ def _build_query_params( params.append((key, str(value))) return params - def _handle_error_response(self, response: httpx.Response) -> None: - """Handle error response from the API.""" + def _handle_error_response( + self, response: httpx.Response, correlation_id: str | None = None + ) -> NoReturn: + """Raise the appropriate typed error for an unsuccessful response.""" body: object | None = None try: body = response.json() @@ -185,21 +224,29 @@ def _handle_error_response(self, response: httpx.Response) -> None: except Exception: message = response.text or response.reason_phrase or "Unknown error" + request_id = ( + response.headers.get(CORRELATION_ID_HEADER) + or response.headers.get("x-request-id") + or correlation_id + ) + status = response.status_code if status == 400: - raise BadRequestError(message, response=response, body=body) + raise BadRequestError(message, response=response, body=body, request_id=request_id) elif status == 401: - raise AuthenticationError(message, response=response, body=body) + raise AuthenticationError(message, response=response, body=body, request_id=request_id) elif status == 403: - raise PermissionDeniedError(message, response=response, body=body) + raise PermissionDeniedError( + message, response=response, body=body, request_id=request_id + ) elif status == 404: - raise NotFoundError(message, response=response, body=body) + raise NotFoundError(message, response=response, body=body, request_id=request_id) elif status == 429: - raise RateLimitError(message, response=response, body=body) + raise RateLimitError(message, response=response, body=body, request_id=request_id) elif status >= 500: - raise InternalServerError(message, response=response, body=body) + raise InternalServerError(message, response=response, body=body, request_id=request_id) else: - raise APIStatusError(message, response=response, body=body) + raise APIStatusError(message, response=response, body=body, request_id=request_id) async def close(self) -> None: """Close the HTTP client.""" diff --git a/polymarket_us/client.py b/polymarket_us/client.py index c060393..d287dc3 100644 --- a/polymarket_us/client.py +++ b/polymarket_us/client.py @@ -2,10 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import time +import uuid +from typing import TYPE_CHECKING, Any, NoReturn import httpx +from polymarket_us import _retry from polymarket_us.auth import create_auth_headers from polymarket_us.errors import ( APIConnectionError, @@ -35,6 +38,19 @@ GATEWAY_BASE_URL = "https://gateway.polymarket.us" API_BASE_URL = "https://api.polymarket.us" +# Correlation id sent with every request so failures can be traced server-side. +CORRELATION_ID_HEADER = "poly-correlation-id" + + +def default_user_agent() -> str: + """Return the SDK User-Agent string, including the installed version.""" + try: + from importlib.metadata import version + + return f"polymarket-us-python/{version('polymarket-us')}" + except Exception: + return "polymarket-us-python/unknown" + class PolymarketUS: """Synchronous client for the Polymarket US API.""" @@ -47,6 +63,7 @@ def __init__( gateway_base_url: str = GATEWAY_BASE_URL, api_base_url: str = API_BASE_URL, timeout: float = 30.0, + max_retries: int = _retry.DEFAULT_MAX_RETRIES, ) -> None: """Initialize the Polymarket US client. @@ -56,13 +73,18 @@ def __init__( gateway_base_url: Base URL for public gateway API api_base_url: Base URL for authenticated API timeout: Request timeout in seconds + max_retries: Maximum automatic retries for idempotent requests on + transient failures (timeouts, connection errors, and 408/409/429/5xx). + Non-idempotent requests (e.g. order placement) are never retried. """ self.key_id = key_id self.secret_key = secret_key self.gateway_base_url = gateway_base_url self.api_base_url = api_base_url self.timeout = timeout + self.max_retries = max_retries + self._user_agent = default_user_agent() self._http = httpx.Client(timeout=timeout) # Resource instances @@ -116,43 +138,70 @@ def _request( body: dict[str, Any] | None = None, authenticated: bool = False, ) -> Any: - """Make an HTTP request.""" + """Make an HTTP request, retrying idempotent requests on transient errors.""" base_url = self.api_base_url if authenticated else self.gateway_base_url url = f"{base_url}{path}" - headers = {"Content-Type": "application/json"} - - if authenticated: - if not self.key_id or not self.secret_key: - raise AuthenticationError( - "API key credentials required for authenticated endpoints. " - "Provide key_id and secret_key when initializing the client.", - response=_make_fake_response(401, url), - ) - auth_headers = create_auth_headers(self.key_id, self.secret_key, method, path) - headers.update(auth_headers) + if authenticated and (not self.key_id or not self.secret_key): + raise AuthenticationError( + "API key credentials required for authenticated endpoints. " + "Provide key_id and secret_key when initializing the client.", + response=_make_fake_response(401, url), + ) params = self._build_query_params(query) if query else None + correlation_id = str(uuid.uuid4()) + base_headers = { + "Content-Type": "application/json", + "User-Agent": self._user_agent, + CORRELATION_ID_HEADER: correlation_id, + } - try: - response = self._http.request( - method, - url, - params=params, - json=body, - headers=headers, - ) - except httpx.TimeoutException as e: - raise APITimeoutError(request=getattr(e, "_request", None)) - except httpx.ConnectError as e: - raise APIConnectionError(message=str(e), request=getattr(e, "_request", None)) + attempt = 0 + while True: + headers = dict(base_headers) + if authenticated: + headers.update( + create_auth_headers(self.key_id, self.secret_key, method, path) # type: ignore[arg-type] + ) + + try: + response = self._http.request( + method, url, params=params, json=body, headers=headers + ) + except httpx.TimeoutException as e: + if _retry.can_retry_method(method) and attempt < self.max_retries: + time.sleep(_retry.backoff_delay(attempt)) + attempt += 1 + continue + raise APITimeoutError( + request=getattr(e, "_request", None), request_id=correlation_id + ) + except httpx.TransportError as e: + if _retry.can_retry_method(method) and attempt < self.max_retries: + time.sleep(_retry.backoff_delay(attempt)) + attempt += 1 + continue + raise APIConnectionError( + message=str(e), request=getattr(e, "_request", None), request_id=correlation_id + ) - if not response.is_success: - self._handle_error_response(response) + if response.is_success: + if not response.text: + return {} + return response.json() + + if ( + _retry.is_retryable_status(response.status_code) + and _retry.can_retry_method(method) + and attempt < self.max_retries + ): + retry_after = _retry.retry_after_seconds(response.headers.get("Retry-After")) + time.sleep(_retry.backoff_delay(attempt, retry_after)) + attempt += 1 + continue - if not response.text: - return {} - return response.json() + self._handle_error_response(response, correlation_id) def _build_query_params( self, query: dict[str, Any] @@ -171,8 +220,10 @@ def _build_query_params( params.append((key, str(value))) return params - def _handle_error_response(self, response: httpx.Response) -> None: - """Handle error response from the API.""" + def _handle_error_response( + self, response: httpx.Response, correlation_id: str | None = None + ) -> NoReturn: + """Raise the appropriate typed error for an unsuccessful response.""" body: object | None = None try: body = response.json() @@ -183,21 +234,29 @@ def _handle_error_response(self, response: httpx.Response) -> None: except Exception: message = response.text or response.reason_phrase or "Unknown error" + request_id = ( + response.headers.get(CORRELATION_ID_HEADER) + or response.headers.get("x-request-id") + or correlation_id + ) + status = response.status_code if status == 400: - raise BadRequestError(message, response=response, body=body) + raise BadRequestError(message, response=response, body=body, request_id=request_id) elif status == 401: - raise AuthenticationError(message, response=response, body=body) + raise AuthenticationError(message, response=response, body=body, request_id=request_id) elif status == 403: - raise PermissionDeniedError(message, response=response, body=body) + raise PermissionDeniedError( + message, response=response, body=body, request_id=request_id + ) elif status == 404: - raise NotFoundError(message, response=response, body=body) + raise NotFoundError(message, response=response, body=body, request_id=request_id) elif status == 429: - raise RateLimitError(message, response=response, body=body) + raise RateLimitError(message, response=response, body=body, request_id=request_id) elif status >= 500: - raise InternalServerError(message, response=response, body=body) + raise InternalServerError(message, response=response, body=body, request_id=request_id) else: - raise APIStatusError(message, response=response, body=body) + raise APIStatusError(message, response=response, body=body, request_id=request_id) def close(self) -> None: """Close the HTTP client.""" diff --git a/polymarket_us/errors.py b/polymarket_us/errors.py index fc6ce20..789891d 100644 --- a/polymarket_us/errors.py +++ b/polymarket_us/errors.py @@ -15,6 +15,7 @@ class APIError(PolymarketUSError): message: str request: httpx.Request | None body: object | None + request_id: str | None def __init__( self, @@ -22,14 +23,18 @@ def __init__( *, request: httpx.Request | None = None, body: object | None = None, + request_id: str | None = None, ) -> None: super().__init__(message) self.message = message self.request = request self.body = body + self.request_id = request_id def __repr__(self) -> str: - return f"{self.__class__.__name__}(message={self.message!r})" + return ( + f"{self.__class__.__name__}(message={self.message!r}, request_id={self.request_id!r})" + ) class APIConnectionError(APIError): @@ -40,15 +45,18 @@ def __init__( *, message: str = "Connection error.", request: httpx.Request | None = None, + request_id: str | None = None, ) -> None: - super().__init__(message, request=request, body=None) + super().__init__(message, request=request, body=None, request_id=request_id) class APITimeoutError(APIConnectionError): """Request timed out.""" - def __init__(self, *, request: httpx.Request | None = None) -> None: - super().__init__(message="Request timed out.", request=request) + def __init__( + self, *, request: httpx.Request | None = None, request_id: str | None = None + ) -> None: + super().__init__(message="Request timed out.", request=request, request_id=request_id) class APIStatusError(APIError): @@ -58,15 +66,21 @@ class APIStatusError(APIError): status_code: int def __init__( - self, message: str, *, response: httpx.Response, body: object | None = None + self, + message: str, + *, + response: httpx.Response, + body: object | None = None, + request_id: str | None = None, ) -> None: - super().__init__(message, request=response.request, body=body) + super().__init__(message, request=response.request, body=body, request_id=request_id) self.response = response self.status_code = response.status_code def __repr__(self) -> str: return ( - f"{self.__class__.__name__}(status_code={self.status_code}, message={self.message!r})" + f"{self.__class__.__name__}(status_code={self.status_code}, " + f"message={self.message!r}, request_id={self.request_id!r})" ) diff --git a/tests/test_auth.py b/tests/test_auth.py index eb70c09..6c16d15 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -64,12 +64,15 @@ def test_signature_is_base64(self) -> None: def test_handles_64_byte_key(self) -> None: """Should handle 64-byte keys (uses first 32 bytes).""" + import base64 + # 64-byte key (seed + public key), base64 encoded - secret_key_64 = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" * 2 + seed = base64.b64decode("nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=") + secret_key_64 = base64.b64encode(seed + seed).decode() # Should not raise headers = create_auth_headers( key_id="test", - secret_key=secret_key_64[:88], # 64 bytes in base64 + secret_key=secret_key_64, method="GET", path="/v1/test", ) diff --git a/tests/test_http_errors.py b/tests/test_http_errors.py index 4f7f7ad..f5cf936 100644 --- a/tests/test_http_errors.py +++ b/tests/test_http_errors.py @@ -23,8 +23,8 @@ class TestHTTPErrors: @pytest.fixture def client(self) -> PolymarketUS: - """Create a client.""" - return PolymarketUS() + """Create a client with retries disabled to isolate status-code mapping.""" + return PolymarketUS(max_retries=0) def _make_mock_response(self, status_code: int, message: str, reason: str) -> MagicMock: """Create a mock response.""" @@ -34,6 +34,7 @@ def _make_mock_response(self, status_code: int, message: str, reason: str) -> Ma mock_response.text = f'{{"message": "{message}"}}' mock_response.json.return_value = {"message": message} mock_response.reason_phrase = reason + mock_response.headers = {} mock_response.request = httpx.Request("GET", "http://test") return mock_response @@ -120,6 +121,7 @@ def test_502_raises_internal_server_error( mock_response.status_code = 502 mock_response.text = "" mock_response.reason_phrase = "Bad Gateway" + mock_response.headers = {} mock_response.request = httpx.Request("GET", "http://test") mock_request.return_value = mock_response diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 0000000..ff2a249 --- /dev/null +++ b/tests/test_retries.py @@ -0,0 +1,144 @@ +"""Tests for automatic retries, User-Agent, and correlation-id behavior.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from polymarket_us import ( + AsyncPolymarketUS, + BadRequestError, + InternalServerError, + PolymarketUS, + _retry, +) + +TEST_SECRET_KEY = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" + + +def _response(status_code: int, payload: dict | None = None) -> httpx.Response: + return httpx.Response( + status_code, + json=payload if payload is not None else {"message": "error"}, + request=httpx.Request("GET", "http://test"), + ) + + +class TestBackoff: + """Backoff delay computation.""" + + def test_clamps_retry_after_to_ceiling(self) -> None: + assert _retry.backoff_delay(0, retry_after=3600) == _retry._BACKOFF_MAX_SECONDS + assert _retry.backoff_delay(0, retry_after=float("inf")) == _retry._BACKOFF_MAX_SECONDS + + def test_honors_small_retry_after(self) -> None: + assert _retry.backoff_delay(5, retry_after=1.5) == 1.5 + + +class TestSyncRetries: + """Retry behavior for the synchronous client.""" + + @patch("time.sleep") + @patch.object(httpx.Client, "request") + def test_retries_get_on_500_then_succeeds( + self, mock_request: MagicMock, mock_sleep: MagicMock + ) -> None: + mock_request.side_effect = [_response(500), _response(200, {"events": []})] + client = PolymarketUS() + + result = client.events.list() + + assert result == {"events": []} + assert mock_request.call_count == 2 + assert mock_sleep.call_count == 1 + + @patch("time.sleep") + @patch.object(httpx.Client, "request") + def test_get_exhausts_retries(self, mock_request: MagicMock, mock_sleep: MagicMock) -> None: + mock_request.return_value = _response(503) + client = PolymarketUS(max_retries=2) + + with pytest.raises(InternalServerError): + client.events.list() + + assert mock_request.call_count == 3 # initial + 2 retries + assert mock_sleep.call_count == 2 + + @patch("time.sleep") + @patch.object(httpx.Client, "request") + def test_retries_get_on_connect_error_then_succeeds( + self, mock_request: MagicMock, mock_sleep: MagicMock + ) -> None: + mock_request.side_effect = [ + httpx.ConnectError("boom"), + _response(200, {"events": []}), + ] + client = PolymarketUS() + + result = client.events.list() + + assert result == {"events": []} + assert mock_request.call_count == 2 + assert mock_sleep.call_count == 1 + + @patch("time.sleep") + @patch.object(httpx.Client, "request") + def test_post_is_never_retried(self, mock_request: MagicMock, mock_sleep: MagicMock) -> None: + mock_request.return_value = _response(500) + client = PolymarketUS(key_id="test-key", secret_key=TEST_SECRET_KEY) + + with pytest.raises(InternalServerError): + client.orders.create({"marketSlug": "m", "intent": "ORDER_INTENT_BUY_LONG"}) + + assert mock_request.call_count == 1 + assert mock_sleep.call_count == 0 + + @patch.object(httpx.Client, "request") + def test_4xx_is_not_retried(self, mock_request: MagicMock) -> None: + mock_request.return_value = _response(400) + client = PolymarketUS() + + with pytest.raises(BadRequestError): + client.events.list() + + assert mock_request.call_count == 1 + + @patch.object(httpx.Client, "request") + def test_error_carries_request_id(self, mock_request: MagicMock) -> None: + mock_request.return_value = _response(400) + client = PolymarketUS(max_retries=0) + + with pytest.raises(BadRequestError) as exc_info: + client.events.list() + + assert exc_info.value.request_id is not None + + @patch.object(httpx.Client, "request") + def test_sends_user_agent_and_correlation_headers(self, mock_request: MagicMock) -> None: + mock_request.return_value = _response(200, {"events": []}) + client = PolymarketUS() + + client.events.list() + + headers = mock_request.call_args.kwargs["headers"] + assert headers["User-Agent"].startswith("polymarket-us-python/") + assert headers["poly-correlation-id"] + + +class TestAsyncRetries: + """Retry behavior for the asynchronous client.""" + + @patch("asyncio.sleep", new_callable=AsyncMock) + @patch.object(httpx.AsyncClient, "request", new_callable=AsyncMock) + async def test_async_retries_get_on_500_then_succeeds( + self, mock_request: AsyncMock, mock_sleep: AsyncMock + ) -> None: + mock_request.side_effect = [_response(500), _response(200, {"events": []})] + client = AsyncPolymarketUS() + + result = await client.events.list() + + assert result == {"events": []} + assert mock_request.call_count == 2 + assert mock_sleep.await_count == 1 + await client.close()