⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1e3e7ef
stuff
swaroopAkkineniWorkos Nov 10, 2025
7823022
retry
swaroopAkkineniWorkos Nov 10, 2025
e3a12d7
more cleanup
swaroopAkkineniWorkos Nov 10, 2025
9d2dae2
moar cleanup
swaroopAkkineniWorkos Nov 11, 2025
311d4ca
moar tests
swaroopAkkineniWorkos Nov 11, 2025
922ffe7
stuff
swaroopAkkineniWorkos Nov 11, 2025
35a2706
cleanup
swaroopAkkineniWorkos Nov 11, 2025
5611ce7
stuff
swaroopAkkineniWorkos Nov 11, 2025
145d24c
lol
swaroopAkkineniWorkos Nov 11, 2025
a8854b5
lol
swaroopAkkineniWorkos Nov 11, 2025
8fd6a5d
moar
swaroopAkkineniWorkos Nov 11, 2025
53a706b
Refactor retry logic method naming for clarity
swaroopAkkineniWorkos Nov 11, 2025
479ced1
Fix Black formatting for HTTP client retry implementation
swaroopAkkineniWorkos Nov 11, 2025
2bb414f
Align async retry tests with sync test suite
swaroopAkkineniWorkos Nov 11, 2025
b1695ec
Apply black formatting to test_http_client_retry.py
swaroopAkkineniWorkos Nov 11, 2025
81bab32
remove
swaroopAkkineniWorkos Nov 11, 2025
7aa9142
remove
swaroopAkkineniWorkos Nov 11, 2025
c647324
moar
swaroopAkkineniWorkos Nov 13, 2025
adfcb80
cleanup
swaroopAkkineniWorkos Nov 17, 2025
6df9b7a
lint
swaroopAkkineniWorkos Nov 17, 2025
b10c8f0
sup
swaroopAkkineniWorkos Nov 17, 2025
9b06353
moar tests
swaroopAkkineniWorkos Nov 17, 2025
f8745ca
moar tests
swaroopAkkineniWorkos Nov 17, 2025
99e915f
sup
swaroopAkkineniWorkos Nov 17, 2025
5f1d921
sup
swaroopAkkineniWorkos Nov 17, 2025
434fe2f
Merge main into ENT-3983-python-idempotency-retry
gjtorikian Jan 13, 2026
8c0fae0
lint
gjtorikian Jan 13, 2026
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
27 changes: 20 additions & 7 deletions src/workos/audit_logs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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.types.audit_logs.audit_log_event_response import AuditLogEventResponse
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

Expand All @@ -18,15 +21,15 @@ def create_event(
organization_id: str,
event: AuditLogEvent,
idempotency_key: Optional[str] = None,
) -> None:
) -> AuditLogEventResponse:
"""Create an Audit Logs event.

Kwargs:
organization_id (str): Organization's unique identifier.
event (AuditLogEvent): An AuditLogEvent object.
idempotency_key (str): Idempotency key. (Optional)
Returns:
None
AuditLogEventResponse: Response indicating success
"""
...

Expand Down Expand Up @@ -78,17 +81,27 @@ def create_event(
organization_id: str,
event: AuditLogEvent,
idempotency_key: Optional[str] = None,
) -> None:
) -> AuditLogEventResponse:
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()}"

self._http_client.request(
EVENTS_PATH, method=REQUEST_METHOD_POST, json=json, headers=headers
headers["idempotency-key"] = idempotency_key

# Enable retries for audit log event creation with default retryConfig
response = self._http_client.request(
EVENTS_PATH,
method=REQUEST_METHOD_POST,
json=json,
headers=headers,
retry_config=RetryConfig(),
)

return AuditLogEventResponse.model_validate(response)

def create_export(
self,
*,
Expand Down
1 change: 1 addition & 0 deletions src/workos/types/audit_logs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
from .audit_log_event_context import *
from .audit_log_event_target import *
from .audit_log_event import *
from .audit_log_event_response import *
from .audit_log_export import *
from .audit_log_metadata import *
7 changes: 7 additions & 0 deletions src/workos/types/audit_logs/audit_log_event_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from workos.types.workos_model import WorkOSModel


class AuditLogEventResponse(WorkOSModel):
"""Response from creating an audit log event."""

success: bool
49 changes: 49 additions & 0 deletions src/workos/utils/_base_http_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import platform
import random
from dataclasses import dataclass
from typing import (
Any,
Mapping,
Expand Down Expand Up @@ -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:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 for using dataclasses, this keeps the retry config abstracted away 👌

"""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]]
Expand All @@ -56,6 +71,7 @@ class BaseHTTPClient(Generic[_HttpxClientT]):
_base_url: str
_version: str
_timeout: int
_retry_config: Optional[RetryConfig]

def __init__(
self,
Expand All @@ -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}"
Expand Down Expand Up @@ -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,
Expand Down
92 changes: 88 additions & 4 deletions src/workos/utils/http_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import time
from types import TracebackType
from typing import Optional, Type, Union

Expand All @@ -13,6 +14,7 @@
JsonType,
ParamsType,
ResponseJson,
RetryConfig,
)
from workos.utils.request_helper import REQUEST_METHOD_GET

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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]
Loading