Skip to content
Merged
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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions polymarket_us/_retry.py
Original file line number Diff line number Diff line change
@@ -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
125 changes: 86 additions & 39 deletions polymarket_us/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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()
Expand All @@ -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."""
Expand Down
Loading
Loading