Skip to content

Commit 2f4dc93

Browse files
Mlaz-codeclaude
andauthored
feat: retry 502/503/504 with jittered backoff (#1)
* feat: retry 502/503/504 with jittered backoff Sync + async HTTP clients now retry up to 3 times on transient upstream failures (502/503/504 or connect/read errors) with full-jitter exponential backoff (500ms base → 4s cap). Shields SDK users from the ~3s cold-start gap when the SharpAPI server restarts during deploys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop Python 3.9 support Python 3.9 reached EOL 2025-10. The package declared 3.9 support but has never actually worked on 3.9 — models.py uses `int | float` union syntax which Pydantic evaluates at runtime and which fails on 3.9. Main-branch CI has been red on 3.9 since the initial release. Rather than adding a runtime dependency (eval_type_backport) or rewriting all PEP 604 unions, drop 3.9 from the test matrix and bump floors in pyproject.toml (requires-python, classifiers, ruff target-version, pyright pythonVersion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c22ce90 commit 2f4dc93

5 files changed

Lines changed: 66 additions & 9 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
1515

1616
steps:
1717
- uses: actions/checkout@v4

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,14 @@ version = "0.2.1"
88
description = "Official Python SDK for the SharpAPI real-time sports betting odds API"
99
readme = "README.md"
1010
license = "MIT"
11-
requires-python = ">=3.9"
11+
requires-python = ">=3.10"
1212
authors = [{ name = "SharpAPI", email = "support@sharpapi.io" }]
1313
keywords = ["sports-betting", "odds", "arbitrage", "ev", "api", "real-time", "pinnacle"]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",
1616
"Intended Audience :: Developers",
1717
"License :: OSI Approved :: MIT License",
1818
"Programming Language :: Python :: 3",
19-
"Programming Language :: Python :: 3.9",
2019
"Programming Language :: Python :: 3.10",
2120
"Programming Language :: Python :: 3.11",
2221
"Programming Language :: Python :: 3.12",
@@ -43,7 +42,7 @@ Changelog = "https://github.com/Sharp-API/sharpapi-python/releases"
4342
packages = ["src/sharpapi"]
4443

4544
[tool.ruff]
46-
target-version = "py39"
45+
target-version = "py310"
4746
line-length = 100
4847

4948
[tool.ruff.lint]
@@ -54,7 +53,7 @@ asyncio_mode = "auto"
5453
testpaths = ["tests"]
5554

5655
[tool.pyright]
57-
pythonVersion = "3.9"
56+
pythonVersion = "3.10"
5857
typeCheckingMode = "standard"
5958
venvPath = "."
6059
venv = ".venv"

src/sharpapi/_base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import random
6+
57
import httpx
68

79
from .exceptions import (
@@ -17,6 +19,24 @@
1719
DEFAULT_TIMEOUT = 30.0
1820
USER_AGENT = "sharpapi-python/0.2.0"
1921

22+
RETRY_STATUSES = frozenset({502, 503, 504})
23+
RETRY_MAX_ATTEMPTS = 3
24+
RETRY_BASE_DELAY = 0.5
25+
RETRY_MAX_DELAY = 4.0
26+
27+
28+
def should_retry(response: httpx.Response | None, exc: Exception | None) -> bool:
29+
"""True for transient upstream failures worth retrying."""
30+
if exc is not None:
31+
return isinstance(exc, (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError))
32+
return response is not None and response.status_code in RETRY_STATUSES
33+
34+
35+
def retry_delay(attempt: int) -> float:
36+
"""Exponential backoff with full jitter. attempt is 1-indexed."""
37+
ceiling = min(RETRY_BASE_DELAY * (2 ** (attempt - 1)), RETRY_MAX_DELAY)
38+
return random.uniform(0, ceiling)
39+
2040

2141
def parse_response(raw: dict, model_class: type) -> APIResponse:
2242
"""Parse raw API JSON into a typed APIResponse."""

src/sharpapi/async_client.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from typing import Any, Optional, Union
67

78
import httpx
89

910
from ._base import (
1011
DEFAULT_BASE_URL,
1112
DEFAULT_TIMEOUT,
13+
RETRY_MAX_ATTEMPTS,
1214
handle_errors,
1315
make_headers,
1416
parse_rate_limit,
1517
parse_response,
18+
retry_delay,
19+
should_retry,
1620
)
1721
from ._utils import _clean_params
1822
from .models import (
@@ -89,11 +93,26 @@ def rate_limit(self) -> RateLimitInfo:
8993
return self._last_rate_limit
9094

9195
async def _request(self, method: str, path: str, params: dict | None = None, **kwargs) -> Any:
92-
"""Make an async API request and return parsed JSON."""
96+
"""Make an async API request and return parsed JSON. Retries 502/503/504 with jittered backoff."""
9397
if params:
9498
params = _clean_params(params)
9599

96-
response = await self._http.request(method, path, params=params, **kwargs)
100+
response: httpx.Response | None = None
101+
for attempt in range(1, RETRY_MAX_ATTEMPTS + 1):
102+
exc: Exception | None = None
103+
try:
104+
response = await self._http.request(method, path, params=params, **kwargs)
105+
except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError) as e:
106+
exc = e
107+
108+
if attempt < RETRY_MAX_ATTEMPTS and should_retry(response, exc):
109+
await asyncio.sleep(retry_delay(attempt))
110+
continue
111+
if exc is not None:
112+
raise exc
113+
break
114+
115+
assert response is not None
97116
self._last_rate_limit = parse_rate_limit(response)
98117
handle_errors(response)
99118
return response.json()

src/sharpapi/client.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
from __future__ import annotations
44

5+
import time
56
from typing import Any, Optional, Union
67

78
import httpx
89

910
from ._base import (
1011
DEFAULT_BASE_URL,
1112
DEFAULT_TIMEOUT,
13+
RETRY_MAX_ATTEMPTS,
1214
handle_errors,
1315
make_headers,
1416
parse_rate_limit,
1517
parse_response,
18+
retry_delay,
19+
should_retry,
1620
)
1721
from ._utils import _clean_params
1822
from .models import (
@@ -99,11 +103,26 @@ def rate_limit(self) -> RateLimitInfo:
99103
return self._last_rate_limit
100104

101105
def _request(self, method: str, path: str, params: dict | None = None, **kwargs) -> Any:
102-
"""Make an API request and return parsed JSON."""
106+
"""Make an API request and return parsed JSON. Retries 502/503/504 with jittered backoff."""
103107
if params:
104108
params = _clean_params(params)
105109

106-
response = self._http.request(method, path, params=params, **kwargs)
110+
response: httpx.Response | None = None
111+
for attempt in range(1, RETRY_MAX_ATTEMPTS + 1):
112+
exc: Exception | None = None
113+
try:
114+
response = self._http.request(method, path, params=params, **kwargs)
115+
except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError) as e:
116+
exc = e
117+
118+
if attempt < RETRY_MAX_ATTEMPTS and should_retry(response, exc):
119+
time.sleep(retry_delay(attempt))
120+
continue
121+
if exc is not None:
122+
raise exc
123+
break
124+
125+
assert response is not None
107126
self._last_rate_limit = parse_rate_limit(response)
108127
handle_errors(response)
109128
return response.json()

0 commit comments

Comments
 (0)