From 9bf08d13f70a50e0153e48343c9cff5d432b1df3 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 30 Mar 2026 19:12:03 +0100 Subject: [PATCH] Add retry logic with exponential backoff and jitter for rate limits and server errors - Introduced a new `_calculate_retry_delay` function to handle retry delays based on the Retry-After header and exponential backoff with jitter. - Updated `SyncHTTPClient` and `AsyncHTTPClientImpl` to utilize the new retry delay calculation for handling rate limits (429) and server errors (502, 503, 504). - Enhanced retry mechanism to improve resilience against temporary issues. --- src/omophub/_http.py | 62 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/src/omophub/_http.py b/src/omophub/_http.py index 04cee9e..31384c2 100644 --- a/src/omophub/_http.py +++ b/src/omophub/_http.py @@ -2,6 +2,7 @@ from __future__ import annotations +import random import time from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -12,6 +13,12 @@ from ._exceptions import ConnectionError, TimeoutError from ._version import get_version +# Retry constants (OpenAI-style exponential backoff with jitter) +INITIAL_RETRY_DELAY = 0.5 # seconds +MAX_RETRY_DELAY = 8.0 # seconds +MAX_RETRY_AFTER = 60 # max seconds to respect from Retry-After header +RETRYABLE_STATUS_CODES = (429, 502, 503, 504) + if TYPE_CHECKING: from collections.abc import Mapping @@ -24,6 +31,37 @@ HTTP2_AVAILABLE = False +def _calculate_retry_delay( + attempt: int, + max_retries: int, + response_headers: Mapping[str, str] | None = None, +) -> float: + """Calculate retry delay with Retry-After support and exponential backoff + jitter. + + Follows the OpenAI pattern: + 1. If Retry-After header present and <= 60s, use it + 2. Otherwise, exponential backoff (0.5s * 2^attempt) with 25% jitter, capped at 8s + """ + # Check Retry-After header first + if response_headers: + retry_after = response_headers.get("retry-after") or response_headers.get( + "Retry-After" + ) + if retry_after: + try: + retry_after_seconds = float(retry_after) + if 0 < retry_after_seconds <= MAX_RETRY_AFTER: + return retry_after_seconds + except ValueError: + pass + + # Exponential backoff with jitter + retries_done = min(max_retries - (max_retries - attempt), 1000) + sleep_seconds = min(INITIAL_RETRY_DELAY * (2.0**retries_done), MAX_RETRY_DELAY) + jitter = 1 - 0.25 * random.random() + return sleep_seconds * jitter + + class HTTPClient(ABC): """Abstract base class for HTTP clients.""" @@ -137,12 +175,15 @@ def request( params=filtered_params if filtered_params else None, json=json, ) - # Retry on server errors (502, 503, 504) + # Retry on rate limits (429) and server errors (502, 503, 504) if ( - response.status_code in (502, 503, 504) + response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries ): - time.sleep(2**attempt * 0.5) + delay = _calculate_retry_delay( + attempt, self._max_retries, response.headers + ) + time.sleep(delay) continue return response.content, response.status_code, response.headers @@ -155,7 +196,8 @@ def request( # Exponential backoff before retry if attempt < self._max_retries: - time.sleep(2**attempt * 0.1) + delay = _calculate_retry_delay(attempt, self._max_retries) + time.sleep(delay) raise last_exception or ConnectionError("Request failed after retries") @@ -229,12 +271,15 @@ async def request( params=filtered_params if filtered_params else None, json=json, ) - # Retry on server errors (502, 503, 504) + # Retry on rate limits (429) and server errors (502, 503, 504) if ( - response.status_code in (502, 503, 504) + response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries ): - await asyncio.sleep(2**attempt * 0.5) + delay = _calculate_retry_delay( + attempt, self._max_retries, response.headers + ) + await asyncio.sleep(delay) continue return response.content, response.status_code, response.headers @@ -247,7 +292,8 @@ async def request( # Exponential backoff before retry if attempt < self._max_retries: - await asyncio.sleep(2**attempt * 0.1) + delay = _calculate_retry_delay(attempt, self._max_retries) + await asyncio.sleep(delay) raise last_exception or ConnectionError("Request failed after retries")