Skip to content

Commit 1e3e7ef

Browse files
stuff
1 parent 1ab4cb4 commit 1e3e7ef

File tree

4 files changed

+161
-6
lines changed

4 files changed

+161
-6
lines changed

tests/test_audit_logs.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ def test_sends_idempotency_key(
106106
assert request_kwargs["headers"]["idempotency-key"] == idempotency_key
107107
assert response is None
108108

109+
def test_auto_generates_idempotency_key(
110+
self, mock_audit_log_event, capture_and_mock_http_client_request
111+
):
112+
"""Test that idempotency key is auto-generated when not provided."""
113+
organization_id = "org_123456789"
114+
115+
request_kwargs = capture_and_mock_http_client_request(
116+
self.http_client, {"success": True}, 200
117+
)
118+
119+
response = self.audit_logs.create_event(
120+
organization_id=organization_id,
121+
event=mock_audit_log_event,
122+
# No idempotency_key provided
123+
)
124+
125+
# Assert header exists and is a valid UUID v4
126+
assert "idempotency-key" in request_kwargs["headers"]
127+
idempotency_key = request_kwargs["headers"]["idempotency-key"]
128+
assert len(idempotency_key) == 36 # UUID format: 8-4-4-4-12
129+
assert idempotency_key.count("-") == 4
130+
# Verify it's a valid UUID by checking the version field (4th section starts with '4')
131+
uuid_parts = idempotency_key.split("-")
132+
assert len(uuid_parts) == 5
133+
assert uuid_parts[2][0] == "4" # UUID v4 identifier
134+
assert response is None
135+
109136
def test_throws_unauthorized_exception(
110137
self, mock_audit_log_event, mock_http_client_with_response
111138
):

workos/audit_logs.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Optional, Protocol, Sequence
23

34
from workos.types.audit_logs import AuditLogExport
@@ -82,8 +83,11 @@ def create_event(
8283
json = {"organization_id": organization_id, "event": event}
8384

8485
headers = {}
85-
if idempotency_key:
86-
headers["idempotency-key"] = idempotency_key
86+
# Auto-generate UUID v4 if not provided
87+
if idempotency_key is None:
88+
idempotency_key = str(uuid.uuid4())
89+
90+
headers["idempotency-key"] = idempotency_key
8791

8892
self._http_client.request(
8993
EVENTS_PATH, method=REQUEST_METHOD_POST, json=json, headers=headers

workos/utils/_base_http_client.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import platform
2+
import random
3+
from dataclasses import dataclass
24
from typing import (
35
Any,
46
Mapping,
@@ -33,6 +35,15 @@
3335
DEFAULT_REQUEST_TIMEOUT = 25
3436

3537

38+
@dataclass
39+
class RetryConfig:
40+
"""Configuration for retry logic with exponential backoff."""
41+
max_retries: int = 3
42+
base_delay: float = 1.0 # seconds
43+
max_delay: float = 30.0 # seconds
44+
jitter: float = 0.25 # 25% jitter
45+
46+
3647
ParamsType = Optional[Mapping[str, Any]]
3748
HeadersType = Optional[Dict[str, str]]
3849
JsonType = Optional[Union[Mapping[str, Any], Sequence[Any]]]
@@ -56,6 +67,7 @@ class BaseHTTPClient(Generic[_HttpxClientT]):
5667
_base_url: str
5768
_version: str
5869
_timeout: int
70+
_retry_config: RetryConfig
5971

6072
def __init__(
6173
self,
@@ -65,12 +77,14 @@ def __init__(
6577
client_id: str,
6678
version: str,
6779
timeout: Optional[int] = DEFAULT_REQUEST_TIMEOUT,
80+
retry_config: Optional[RetryConfig] = None,
6881
) -> None:
6982
self._api_key = api_key
7083
self._base_url = base_url
7184
self._client_id = client_id
7285
self._version = version
7386
self._timeout = DEFAULT_REQUEST_TIMEOUT if timeout is None else timeout
87+
self._retry_config = retry_config if retry_config is not None else RetryConfig()
7488

7589
def _generate_api_url(self, path: str) -> str:
7690
return f"{self._base_url}{path}"
@@ -196,6 +210,49 @@ def _handle_response(self, response: httpx.Response) -> ResponseJson:
196210

197211
return cast(ResponseJson, response_json)
198212

213+
def _is_retryable_error(self, response: httpx.Response) -> bool:
214+
"""Determine if an error should be retried."""
215+
status_code = response.status_code
216+
217+
# Retry on 5xx server errors
218+
if 500 <= status_code < 600:
219+
return True
220+
221+
# Retry on 429 rate limit
222+
if status_code == 429:
223+
return True
224+
225+
# Do NOT retry 4xx client errors (except 429)
226+
return False
227+
228+
def _get_retry_delay(self, attempt: int, response: httpx.Response) -> float:
229+
"""Calculate delay with exponential backoff and jitter."""
230+
# Check for Retry-After header on 429 responses
231+
if response.status_code == 429:
232+
retry_after = response.headers.get("Retry-After")
233+
if retry_after:
234+
try:
235+
return float(retry_after)
236+
except ValueError:
237+
pass # Fall through to exponential backoff
238+
239+
# Exponential backoff: base_delay * 2^attempt
240+
delay = self._retry_config.base_delay * (2 ** attempt)
241+
242+
# Cap at max_delay
243+
delay = min(delay, self._retry_config.max_delay)
244+
245+
# Add jitter: random variation of 0-25% of delay
246+
jitter_amount = delay * self._retry_config.jitter * random.random()
247+
return delay + jitter_amount
248+
249+
def _should_retry_exception(self, exc: Exception) -> bool:
250+
"""Determine if an exception should trigger a retry."""
251+
# Retry on network errors (connection, timeout)
252+
if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)):
253+
return True
254+
return False
255+
199256
def build_request_url(
200257
self,
201258
url: str,

workos/utils/http_client.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import asyncio
2+
import random
3+
import time
24
from types import TracebackType
35
from typing import Optional, Type, Union
46

@@ -13,6 +15,7 @@
1315
JsonType,
1416
ParamsType,
1517
ResponseJson,
18+
RetryConfig,
1619
)
1720
from workos.utils.request_helper import REQUEST_METHOD_GET
1821

@@ -38,6 +41,7 @@ def __init__(
3841
client_id: str,
3942
version: str,
4043
timeout: Optional[int] = None,
44+
retry_config: Optional[RetryConfig] = None,
4145
# If no custom transport is provided, let httpx use the default
4246
# so we don't overwrite environment configurations like proxies
4347
transport: Optional[httpx.BaseTransport] = None,
@@ -48,6 +52,7 @@ def __init__(
4852
client_id=client_id,
4953
version=version,
5054
timeout=timeout,
55+
retry_config=retry_config,
5156
)
5257
self._client = SyncHttpxClientWrapper(
5358
base_url=base_url,
@@ -110,8 +115,38 @@ def request(
110115
headers=headers,
111116
exclude_default_auth_headers=exclude_default_auth_headers,
112117
)
113-
response = self._client.request(**prepared_request_parameters)
114-
return self._handle_response(response)
118+
119+
last_exception = None
120+
121+
for attempt in range(self._retry_config.max_retries + 1):
122+
try:
123+
response = self._client.request(**prepared_request_parameters)
124+
125+
# Check if we should retry based on status code
126+
if attempt < self._retry_config.max_retries and self._is_retryable_error(response):
127+
delay = self._get_retry_delay(attempt, response)
128+
time.sleep(delay)
129+
continue
130+
131+
# No retry needed or max retries reached
132+
return self._handle_response(response)
133+
134+
except Exception as exc:
135+
last_exception = exc
136+
if attempt < self._retry_config.max_retries and self._should_retry_exception(exc):
137+
delay = self._retry_config.base_delay * (2 ** attempt)
138+
delay = min(delay, self._retry_config.max_delay)
139+
jitter_amount = delay * self._retry_config.jitter * random.random()
140+
time.sleep(delay + jitter_amount)
141+
continue
142+
raise
143+
144+
# Should not reach here, but raise last exception if we do
145+
if last_exception is not None:
146+
raise last_exception
147+
148+
# Fallback: this should never happen
149+
raise RuntimeError("Unexpected state in retry logic")
115150

116151

117152
class AsyncHttpxClientWrapper(httpx.AsyncClient):
@@ -138,6 +173,7 @@ def __init__(
138173
client_id: str,
139174
version: str,
140175
timeout: Optional[int] = None,
176+
retry_config: Optional[RetryConfig] = None,
141177
# If no custom transport is provided, let httpx use the default
142178
# so we don't overwrite environment configurations like proxies
143179
transport: Optional[httpx.AsyncBaseTransport] = None,
@@ -148,6 +184,7 @@ def __init__(
148184
client_id=client_id,
149185
version=version,
150186
timeout=timeout,
187+
retry_config=retry_config,
151188
)
152189
self._client = AsyncHttpxClientWrapper(
153190
base_url=base_url,
@@ -207,8 +244,38 @@ async def request(
207244
headers=headers,
208245
exclude_default_auth_headers=exclude_default_auth_headers,
209246
)
210-
response = await self._client.request(**prepared_request_parameters)
211-
return self._handle_response(response)
247+
248+
last_exception = None
249+
250+
for attempt in range(self._retry_config.max_retries + 1):
251+
try:
252+
response = await self._client.request(**prepared_request_parameters)
253+
254+
# Check if we should retry based on status code
255+
if attempt < self._retry_config.max_retries and self._is_retryable_error(response):
256+
delay = self._get_retry_delay(attempt, response)
257+
await asyncio.sleep(delay)
258+
continue
259+
260+
# No retry needed or max retries reached
261+
return self._handle_response(response)
262+
263+
except Exception as exc:
264+
last_exception = exc
265+
if attempt < self._retry_config.max_retries and self._should_retry_exception(exc):
266+
delay = self._retry_config.base_delay * (2 ** attempt)
267+
delay = min(delay, self._retry_config.max_delay)
268+
jitter_amount = delay * self._retry_config.jitter * random.random()
269+
await asyncio.sleep(delay + jitter_amount)
270+
continue
271+
raise
272+
273+
# Should not reach here, but raise last exception if we do
274+
if last_exception is not None:
275+
raise last_exception
276+
277+
# Fallback: this should never happen
278+
raise RuntimeError("Unexpected state in retry logic")
212279

213280

214281
HTTPClient = Union[AsyncHTTPClient, SyncHTTPClient]

0 commit comments

Comments
 (0)