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
2 changes: 1 addition & 1 deletion .fern/metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"cliVersion": "1.9.1",
"generatorName": "fernapi/fern-python-sdk",
"generatorVersion": "4.37.0"
"generatorVersion": "4.41.1"
}
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: 3.9
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
Expand All @@ -25,15 +25,15 @@ jobs:
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: 3.9
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
- name: Install dependencies
run: poetry install

- name: Test
run: poetry run pytest -rP .
run: poetry run pytest -rP -n auto .

publish:
needs: [compile, test]
Expand All @@ -45,7 +45,7 @@ jobs:
- name: Set up python
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: 3.9
- name: Bootstrap poetry
run: |
curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1
Expand Down
42 changes: 38 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "credal"

[tool.poetry]
name = "credal"
version = "0.1.12"
version = "0.1.13"
description = ""
readme = "README.md"
authors = []
Expand Down Expand Up @@ -44,6 +44,7 @@ typing_extensions = ">= 4.0.0"
mypy = "==1.13.0"
pytest = "^7.4.0"
pytest-asyncio = "^0.23.5"
pytest-xdist = "^3.6.1"
python-dateutil = "^2.9.0"
types-python-dateutil = "^2.9.0.20240316"
ruff = "==0.11.5"
Expand Down
4 changes: 2 additions & 2 deletions src/credal/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ def __init__(

def get_headers(self) -> typing.Dict[str, str]:
headers: typing.Dict[str, str] = {
"User-Agent": "credal/0.1.12",
"User-Agent": "credal/0.1.13",
"X-Fern-Language": "Python",
"X-Fern-SDK-Name": "credal",
"X-Fern-SDK-Version": "0.1.12",
"X-Fern-SDK-Version": "0.1.13",
**(self.get_custom_headers() or {}),
}
headers["Authorization"] = f"Bearer {self._get_api_key()}"
Expand Down
56 changes: 45 additions & 11 deletions src/credal/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
from .request_options import RequestOptions
from httpx._types import RequestFiles

INITIAL_RETRY_DELAY_SECONDS = 0.5
MAX_RETRY_DELAY_SECONDS = 10
MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30
INITIAL_RETRY_DELAY_SECONDS = 1.0
MAX_RETRY_DELAY_SECONDS = 60.0
JITTER_FACTOR = 0.2 # 20% random jitter


def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]:
Expand Down Expand Up @@ -64,24 +64,58 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float
return seconds


def _add_positive_jitter(delay: float) -> float:
"""Add positive jitter (0-20%) to prevent thundering herd."""
jitter_multiplier = 1 + random() * JITTER_FACTOR
return delay * jitter_multiplier


def _add_symmetric_jitter(delay: float) -> float:
"""Add symmetric jitter (±10%) for exponential backoff."""
jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR
return delay * jitter_multiplier


def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]:
"""
Parse the X-RateLimit-Reset header (Unix timestamp in seconds).
Returns seconds to wait, or None if header is missing/invalid.
"""
reset_time_str = response_headers.get("x-ratelimit-reset")
if reset_time_str is None:
return None

try:
reset_time = int(reset_time_str)
delay = reset_time - time.time()
if delay > 0:
return delay
except (ValueError, TypeError):
pass

return None


def _retry_timeout(response: httpx.Response, retries: int) -> float:
"""
Determine the amount of time to wait before retrying a request.
This function begins by trying to parse a retry-after header from the response, and then proceeds to use exponential backoff
with a jitter to determine the number of seconds to wait.
"""

# If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says.
# 1. Check Retry-After header first
retry_after = _parse_retry_after(response.headers)
if retry_after is not None and retry_after <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER:
return retry_after
if retry_after is not None and retry_after > 0:
return min(retry_after, MAX_RETRY_DELAY_SECONDS)

# Apply exponential backoff, capped at MAX_RETRY_DELAY_SECONDS.
retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
# 2. Check X-RateLimit-Reset header (with positive jitter)
ratelimit_reset = _parse_x_ratelimit_reset(response.headers)
if ratelimit_reset is not None:
return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS))

# Add a randomness / jitter to the retry delay to avoid overwhelming the server with retries.
timeout = retry_delay * (1 - 0.25 * random())
return timeout if timeout >= 0 else 0
# 3. Fall back to exponential backoff (with symmetric jitter)
backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
return _add_symmetric_jitter(backoff)


def _should_retry(response: httpx.Response) -> bool:
Expand Down