Skip to content

Commit 7fbc124

Browse files
Merge pull request #21 from Deadpool2000/fr-20
feat: implement built-in retry mechanism with exponential backoff and jitter
2 parents e62208e + 4d01147 commit 7fbc124

6 files changed

Lines changed: 503 additions & 17 deletions

File tree

‎openapi_python_sdk/async_client.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import asyncio
12
import json
3+
import random
24
from typing import Any, Dict
35

46
import httpx
@@ -10,8 +12,21 @@ class AsyncClient:
1012
Suitable for use with FastAPI, aiohttp, etc.
1113
"""
1214

13-
def __init__(self, token: str, client: Any = None, timeout: float = 30.0):
15+
def __init__(
16+
self,
17+
token: str,
18+
client: Any = None,
19+
timeout: float = 30.0,
20+
max_retries: int = 0,
21+
backoff_factor: float = 1.0,
22+
retry_on_status: list[int] = None,
23+
):
1424
self.client = client if client is not None else httpx.AsyncClient(timeout=timeout)
25+
self.max_retries = max_retries
26+
self.backoff_factor = backoff_factor
27+
self.retry_on_status = (
28+
retry_on_status if retry_on_status is not None else [429, 502, 503, 504]
29+
)
1530
self.auth_header: str = f"Bearer {token}"
1631
self.headers: Dict[str, str] = {
1732
"Authorization": self.auth_header,
@@ -30,6 +45,32 @@ async def aclose(self):
3045
"""Manually close the underlying HTTP client (async)."""
3146
await self.client.aclose()
3247

48+
async def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response:
49+
attempts = 0
50+
while True:
51+
try:
52+
resp = await request_fn(*args, **kwargs)
53+
if resp.status_code in self.retry_on_status and attempts < self.max_retries:
54+
attempts += 1
55+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
56+
if resp.status_code == 429:
57+
retry_after = resp.headers.get("Retry-After")
58+
if retry_after:
59+
try:
60+
sleep_time = float(retry_after)
61+
except ValueError:
62+
pass
63+
await asyncio.sleep(sleep_time)
64+
continue
65+
return resp
66+
except httpx.RequestError as exc:
67+
if attempts < self.max_retries:
68+
attempts += 1
69+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
70+
await asyncio.sleep(sleep_time)
71+
continue
72+
raise exc
73+
3374
async def request(
3475
self,
3576
method: str = "GET",
@@ -50,7 +91,8 @@ async def request(
5091
url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}"
5192
params = None
5293

53-
resp = await self.client.request(
94+
resp = await self._request_with_retry(
95+
self.client.request,
5496
method=method,
5597
url=url,
5698
headers=self.headers,

‎openapi_python_sdk/async_oauth_client.py‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import asyncio
12
import base64
3+
import random
24
from typing import Any, Dict, List
35

46
import httpx
@@ -12,8 +14,23 @@ class AsyncOauthClient:
1214
Suitable for use with FastAPI, aiohttp, etc.
1315
"""
1416

15-
def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0):
17+
def __init__(
18+
self,
19+
username: str,
20+
apikey: str,
21+
test: bool = False,
22+
client: Any = None,
23+
timeout: float = 30.0,
24+
max_retries: int = 0,
25+
backoff_factor: float = 1.0,
26+
retry_on_status: List[int] = None,
27+
):
1628
self.client = client if client is not None else httpx.AsyncClient(timeout=timeout)
29+
self.max_retries = max_retries
30+
self.backoff_factor = backoff_factor
31+
self.retry_on_status = (
32+
retry_on_status if retry_on_status is not None else [429, 502, 503, 504]
33+
)
1734
self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL
1835
self.auth_header: str = (
1936
"Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode()
@@ -35,35 +52,61 @@ async def aclose(self):
3552
"""Manually close the underlying HTTP client (async)."""
3653
await self.client.aclose()
3754

55+
async def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response:
56+
attempts = 0
57+
while True:
58+
try:
59+
resp = await request_fn(*args, **kwargs)
60+
if resp.status_code in self.retry_on_status and attempts < self.max_retries:
61+
attempts += 1
62+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
63+
if resp.status_code == 429:
64+
retry_after = resp.headers.get("Retry-After")
65+
if retry_after:
66+
try:
67+
sleep_time = float(retry_after)
68+
except ValueError:
69+
pass
70+
await asyncio.sleep(sleep_time)
71+
continue
72+
return resp
73+
except httpx.RequestError as exc:
74+
if attempts < self.max_retries:
75+
attempts += 1
76+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
77+
await asyncio.sleep(sleep_time)
78+
continue
79+
raise exc
80+
3881
async def get_scopes(self, limit: bool = False) -> Dict[str, Any]:
3982
"""Retrieve available scopes for the current user (async)."""
4083
params = {"limit": int(limit)}
4184
url = f"{self.url}/scopes"
42-
resp = await self.client.get(url=url, headers=self.headers, params=params)
85+
resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params)
4386
return resp.json()
4487

4588
async def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]:
4689
"""Create a new bearer token with specified scopes and TTL (async)."""
4790
payload = {"scopes": scopes, "ttl": ttl}
4891
url = f"{self.url}/token"
49-
resp = await self.client.post(url=url, headers=self.headers, json=payload)
92+
resp = await self._request_with_retry(self.client.post, url=url, headers=self.headers, json=payload)
5093
return resp.json()
5194

5295
async def get_token(self, scope: str = None) -> Dict[str, Any]:
5396
"""Retrieve an existing token, optionally filtered by scope (async)."""
5497
params = {"scope": scope or ""}
5598
url = f"{self.url}/token"
56-
resp = await self.client.get(url=url, headers=self.headers, params=params)
99+
resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params)
57100
return resp.json()
58101

59102
async def delete_token(self, id: str) -> Dict[str, Any]:
60103
"""Revoke/Delete a specific token by ID (async)."""
61104
url = f"{self.url}/token/{id}"
62-
resp = await self.client.delete(url=url, headers=self.headers)
105+
resp = await self._request_with_retry(self.client.delete, url=url, headers=self.headers)
63106
return resp.json()
64107

65108
async def get_counters(self, period: str, date: str) -> Dict[str, Any]:
66109
"""Retrieve usage counters for a specific period and date (async)."""
67110
url = f"{self.url}/counters/{period}/{date}"
68-
resp = await self.client.get(url=url, headers=self.headers)
111+
resp = await self._request_with_retry(self.client.get, url=url, headers=self.headers)
69112
return resp.json()

‎openapi_python_sdk/client.py‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
2+
import random
23
import threading
4+
import time
35
from typing import Any, Dict
46

57
import httpx
@@ -15,10 +17,23 @@ class Client:
1517
Synchronous client for making authenticated requests to Openapi endpoints.
1618
"""
1719

18-
def __init__(self, token: str, client: Any = None, timeout: float = 30.0):
20+
def __init__(
21+
self,
22+
token: str,
23+
client: Any = None,
24+
timeout: float = 30.0,
25+
max_retries: int = 0,
26+
backoff_factor: float = 1.0,
27+
retry_on_status: list[int] = None,
28+
):
1929
self._client = client
2030
self._thread_local = threading.local()
2131
self.timeout = timeout
32+
self.max_retries = max_retries
33+
self.backoff_factor = backoff_factor
34+
self.retry_on_status = (
35+
retry_on_status if retry_on_status is not None else [429, 502, 503, 504]
36+
)
2237
self.auth_header: str = f"Bearer {token}"
2338
self.headers: Dict[str, str] = {
2439
"Authorization": self.auth_header,
@@ -55,6 +70,32 @@ def close(self):
5570
"""Manually close the underlying HTTP client."""
5671
self.client.close()
5772

73+
def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response:
74+
attempts = 0
75+
while True:
76+
try:
77+
resp = request_fn(*args, **kwargs)
78+
if resp.status_code in self.retry_on_status and attempts < self.max_retries:
79+
attempts += 1
80+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
81+
if resp.status_code == 429:
82+
retry_after = resp.headers.get("Retry-After")
83+
if retry_after:
84+
try:
85+
sleep_time = float(retry_after)
86+
except ValueError:
87+
pass
88+
time.sleep(sleep_time)
89+
continue
90+
return resp
91+
except httpx.RequestError as exc:
92+
if attempts < self.max_retries:
93+
attempts += 1
94+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
95+
time.sleep(sleep_time)
96+
continue
97+
raise exc
98+
5899
def request(
59100
self,
60101
method: str = "GET",
@@ -75,13 +116,15 @@ def request(
75116
url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}"
76117
params = None
77118

78-
data = self.client.request(
119+
resp = self._request_with_retry(
120+
self.client.request,
79121
method=method,
80122
url=url,
81123
headers=self.headers,
82124
json=payload,
83125
params=params,
84-
).json()
126+
)
127+
data = resp.json()
85128

86129
# Handle cases where the API might return a JSON-encoded string instead of an object
87130
if isinstance(data, str):

‎openapi_python_sdk/oauth_client.py‎

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import base64
2+
import random
23
import threading
4+
import time
35
from typing import Any, Dict, List
46

57
import httpx
@@ -13,10 +15,25 @@ class OauthClient:
1315
Synchronous client for handling Openapi authentication and token management.
1416
"""
1517

16-
def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0):
18+
def __init__(
19+
self,
20+
username: str,
21+
apikey: str,
22+
test: bool = False,
23+
client: Any = None,
24+
timeout: float = 30.0,
25+
max_retries: int = 0,
26+
backoff_factor: float = 1.0,
27+
retry_on_status: List[int] = None,
28+
):
1729
self._client = client
1830
self._thread_local = threading.local()
1931
self.timeout = timeout
32+
self.max_retries = max_retries
33+
self.backoff_factor = backoff_factor
34+
self.retry_on_status = (
35+
retry_on_status if retry_on_status is not None else [429, 502, 503, 504]
36+
)
2037
self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL
2138
self.auth_header: str = (
2239
"Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode()
@@ -55,30 +72,61 @@ def close(self):
5572
"""Manually close the underlying HTTP client."""
5673
self.client.close()
5774

75+
def _request_with_retry(self, request_fn, *args, **kwargs) -> httpx.Response:
76+
attempts = 0
77+
while True:
78+
try:
79+
resp = request_fn(*args, **kwargs)
80+
if resp.status_code in self.retry_on_status and attempts < self.max_retries:
81+
attempts += 1
82+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
83+
if resp.status_code == 429:
84+
retry_after = resp.headers.get("Retry-After")
85+
if retry_after:
86+
try:
87+
sleep_time = float(retry_after)
88+
except ValueError:
89+
pass
90+
time.sleep(sleep_time)
91+
continue
92+
return resp
93+
except httpx.RequestError as exc:
94+
if attempts < self.max_retries:
95+
attempts += 1
96+
sleep_time = self.backoff_factor * (2 ** attempts) + random.uniform(0, 0.5)
97+
time.sleep(sleep_time)
98+
continue
99+
raise exc
100+
58101
def get_scopes(self, limit: bool = False) -> Dict[str, Any]:
59102
"""Retrieve available scopes for the current user."""
60103
params = {"limit": int(limit)}
61104
url = f"{self.url}/scopes"
62-
return self.client.get(url=url, headers=self.headers, params=params).json()
105+
resp = self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params)
106+
return resp.json()
63107

64108
def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]:
65109
"""Create a new bearer token with specified scopes and TTL."""
66110
payload = {"scopes": scopes, "ttl": ttl}
67111
url = f"{self.url}/token"
68-
return self.client.post(url=url, headers=self.headers, json=payload).json()
112+
resp = self._request_with_retry(self.client.post, url=url, headers=self.headers, json=payload)
113+
return resp.json()
69114

70115
def get_token(self, scope: str = None) -> Dict[str, Any]:
71116
"""Retrieve an existing token, optionally filtered by scope."""
72117
params = {"scope": scope or ""}
73118
url = f"{self.url}/token"
74-
return self.client.get(url=url, headers=self.headers, params=params).json()
119+
resp = self._request_with_retry(self.client.get, url=url, headers=self.headers, params=params)
120+
return resp.json()
75121

76122
def delete_token(self, id: str) -> Dict[str, Any]:
77123
"""Revoke/Delete a specific token by ID."""
78124
url = f"{self.url}/token/{id}"
79-
return self.client.delete(url=url, headers=self.headers).json()
125+
resp = self._request_with_retry(self.client.delete, url=url, headers=self.headers)
126+
return resp.json()
80127

81128
def get_counters(self, period: str, date: str) -> Dict[str, Any]:
82129
"""Retrieve usage counters for a specific period and date."""
83130
url = f"{self.url}/counters/{period}/{date}"
84-
return self.client.get(url=url, headers=self.headers).json()
131+
resp = self._request_with_retry(self.client.get, url=url, headers=self.headers)
132+
return resp.json()

0 commit comments

Comments
 (0)