mirror of
https://github.com/wassname/openrouter-python-sdk-retry-errors.git
synced 2026-07-29 11:23:49 +08:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
334 lines
9.1 KiB
Python
334 lines
9.1 KiB
Python
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
|
|
|
|
import asyncio
|
|
import json
|
|
import random
|
|
import time
|
|
from datetime import datetime
|
|
from email.utils import parsedate_to_datetime
|
|
from typing import Any, List, Optional
|
|
|
|
import httpx
|
|
|
|
|
|
class BackoffStrategy:
|
|
initial_interval: int
|
|
max_interval: int
|
|
exponent: float
|
|
max_elapsed_time: int
|
|
|
|
def __init__(
|
|
self,
|
|
initial_interval: int,
|
|
max_interval: int,
|
|
exponent: float,
|
|
max_elapsed_time: int,
|
|
):
|
|
self.initial_interval = initial_interval
|
|
self.max_interval = max_interval
|
|
self.exponent = exponent
|
|
self.max_elapsed_time = max_elapsed_time
|
|
|
|
|
|
class RetryConfig:
|
|
strategy: str
|
|
backoff: BackoffStrategy
|
|
retry_connection_errors: bool
|
|
|
|
def __init__(
|
|
self, strategy: str, backoff: BackoffStrategy, retry_connection_errors: bool
|
|
):
|
|
self.strategy = strategy
|
|
self.backoff = backoff
|
|
self.retry_connection_errors = retry_connection_errors
|
|
|
|
|
|
class Retries:
|
|
config: RetryConfig
|
|
status_codes: List[str]
|
|
|
|
def __init__(self, config: RetryConfig, status_codes: List[str]):
|
|
self.config = config
|
|
self.status_codes = status_codes
|
|
|
|
|
|
class TemporaryError(Exception):
|
|
response: httpx.Response
|
|
retry_after: Optional[int]
|
|
|
|
def __init__(self, response: httpx.Response):
|
|
self.response = response
|
|
self.retry_after = _parse_retry_after_header(response)
|
|
|
|
|
|
class PermanentError(Exception):
|
|
inner: Exception
|
|
|
|
def __init__(self, inner: Exception):
|
|
self.inner = inner
|
|
|
|
|
|
def _parse_retry_after_header(response: httpx.Response) -> Optional[int]:
|
|
"""Parse Retry-After header from response.
|
|
|
|
Returns:
|
|
Retry interval in milliseconds, or None if header is missing or invalid.
|
|
"""
|
|
retry_after_header = response.headers.get("retry-after")
|
|
if not retry_after_header:
|
|
return None
|
|
|
|
try:
|
|
seconds = float(retry_after_header)
|
|
return round(seconds * 1000)
|
|
except ValueError:
|
|
pass
|
|
|
|
try:
|
|
retry_date = parsedate_to_datetime(retry_after_header)
|
|
delta = (retry_date - datetime.now(retry_date.tzinfo)).total_seconds()
|
|
return round(max(0, delta) * 1000)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
TRANSIENT_STATUS_CODES = {408, 429}
|
|
TRANSIENT_ERROR_CODES = {408, 429, 500, 502, 503, 504, 524, 529}
|
|
|
|
|
|
def _matches_retry_status_code(response: httpx.Response, status_code: str) -> bool:
|
|
if "X" in status_code.upper():
|
|
code_range = int(status_code[0])
|
|
status_major = response.status_code // 100
|
|
return code_range == status_major
|
|
|
|
return response.status_code == int(status_code)
|
|
|
|
|
|
def _error_code_from_json_body(response: httpx.Response) -> Optional[int]:
|
|
try:
|
|
body: Any = response.json()
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
if not isinstance(body, dict):
|
|
return None
|
|
|
|
error = body.get("error")
|
|
if not isinstance(error, dict):
|
|
return None
|
|
|
|
code = error.get("code")
|
|
if isinstance(code, int):
|
|
return code
|
|
if isinstance(code, str) and code.isdigit():
|
|
return int(code)
|
|
|
|
return None
|
|
|
|
|
|
def _is_retryable_response(response: httpx.Response, status_codes: List[str]) -> bool:
|
|
if response.status_code in TRANSIENT_STATUS_CODES:
|
|
return True
|
|
|
|
for status_code in status_codes:
|
|
if _matches_retry_status_code(response, status_code):
|
|
return True
|
|
|
|
if response.status_code != 400:
|
|
return False
|
|
|
|
if not response.is_closed:
|
|
response.read()
|
|
|
|
inner_code = _error_code_from_json_body(response)
|
|
return inner_code in TRANSIENT_ERROR_CODES
|
|
|
|
|
|
async def _is_retryable_response_async(
|
|
response: httpx.Response, status_codes: List[str]
|
|
) -> bool:
|
|
if response.status_code in TRANSIENT_STATUS_CODES:
|
|
return True
|
|
|
|
for status_code in status_codes:
|
|
if _matches_retry_status_code(response, status_code):
|
|
return True
|
|
|
|
if response.status_code != 400:
|
|
return False
|
|
|
|
if not response.is_closed:
|
|
await response.aread()
|
|
|
|
inner_code = _error_code_from_json_body(response)
|
|
return inner_code in TRANSIENT_ERROR_CODES
|
|
|
|
|
|
def _get_sleep_interval(
|
|
exception: Exception,
|
|
initial_interval: int,
|
|
max_interval: int,
|
|
exponent: float,
|
|
retries: int,
|
|
) -> float:
|
|
"""Get sleep interval for retry with exponential backoff.
|
|
|
|
Args:
|
|
exception: The exception that triggered the retry.
|
|
initial_interval: Initial retry interval in milliseconds.
|
|
max_interval: Maximum retry interval in milliseconds.
|
|
exponent: Base for exponential backoff calculation.
|
|
retries: Current retry attempt count.
|
|
|
|
Returns:
|
|
Sleep interval in seconds.
|
|
"""
|
|
if (
|
|
isinstance(exception, TemporaryError)
|
|
and exception.retry_after is not None
|
|
and exception.retry_after > 0
|
|
):
|
|
return exception.retry_after / 1000
|
|
|
|
sleep = (initial_interval / 1000) * exponent**retries + random.uniform(0, 1)
|
|
return min(sleep, max_interval / 1000)
|
|
|
|
|
|
def retry(func, retries: Retries):
|
|
if retries.config.strategy == "backoff":
|
|
|
|
def do_request() -> httpx.Response:
|
|
res: httpx.Response
|
|
try:
|
|
res = func()
|
|
|
|
if _is_retryable_response(res, retries.status_codes):
|
|
raise TemporaryError(res)
|
|
except httpx.ConnectError as exception:
|
|
if retries.config.retry_connection_errors:
|
|
raise
|
|
|
|
raise PermanentError(exception) from exception
|
|
except httpx.TimeoutException as exception:
|
|
if retries.config.retry_connection_errors:
|
|
raise
|
|
|
|
raise PermanentError(exception) from exception
|
|
except TemporaryError:
|
|
raise
|
|
except Exception as exception:
|
|
raise PermanentError(exception) from exception
|
|
|
|
return res
|
|
|
|
return retry_with_backoff(
|
|
do_request,
|
|
retries.config.backoff.initial_interval,
|
|
retries.config.backoff.max_interval,
|
|
retries.config.backoff.exponent,
|
|
retries.config.backoff.max_elapsed_time,
|
|
)
|
|
|
|
return func()
|
|
|
|
|
|
async def retry_async(func, retries: Retries):
|
|
if retries.config.strategy == "backoff":
|
|
|
|
async def do_request() -> httpx.Response:
|
|
res: httpx.Response
|
|
try:
|
|
res = await func()
|
|
|
|
if await _is_retryable_response_async(res, retries.status_codes):
|
|
raise TemporaryError(res)
|
|
except httpx.ConnectError as exception:
|
|
if retries.config.retry_connection_errors:
|
|
raise
|
|
|
|
raise PermanentError(exception) from exception
|
|
except httpx.TimeoutException as exception:
|
|
if retries.config.retry_connection_errors:
|
|
raise
|
|
|
|
raise PermanentError(exception) from exception
|
|
except TemporaryError:
|
|
raise
|
|
except Exception as exception:
|
|
raise PermanentError(exception) from exception
|
|
|
|
return res
|
|
|
|
return await retry_with_backoff_async(
|
|
do_request,
|
|
retries.config.backoff.initial_interval,
|
|
retries.config.backoff.max_interval,
|
|
retries.config.backoff.exponent,
|
|
retries.config.backoff.max_elapsed_time,
|
|
)
|
|
|
|
return await func()
|
|
|
|
|
|
def retry_with_backoff(
|
|
func,
|
|
initial_interval=500,
|
|
max_interval=60000,
|
|
exponent=1.5,
|
|
max_elapsed_time=3600000,
|
|
):
|
|
start = round(time.time() * 1000)
|
|
retries = 0
|
|
|
|
while True:
|
|
try:
|
|
return func()
|
|
except PermanentError as exception:
|
|
raise exception.inner
|
|
except Exception as exception: # pylint: disable=broad-exception-caught
|
|
now = round(time.time() * 1000)
|
|
if now - start > max_elapsed_time:
|
|
if isinstance(exception, TemporaryError):
|
|
return exception.response
|
|
|
|
raise
|
|
|
|
sleep = _get_sleep_interval(
|
|
exception, initial_interval, max_interval, exponent, retries
|
|
)
|
|
time.sleep(sleep)
|
|
retries += 1
|
|
|
|
|
|
async def retry_with_backoff_async(
|
|
func,
|
|
initial_interval=500,
|
|
max_interval=60000,
|
|
exponent=1.5,
|
|
max_elapsed_time=3600000,
|
|
):
|
|
start = round(time.time() * 1000)
|
|
retries = 0
|
|
|
|
while True:
|
|
try:
|
|
return await func()
|
|
except PermanentError as exception:
|
|
raise exception.inner
|
|
except Exception as exception: # pylint: disable=broad-exception-caught
|
|
now = round(time.time() * 1000)
|
|
if now - start > max_elapsed_time:
|
|
if isinstance(exception, TemporaryError):
|
|
return exception.response
|
|
|
|
raise
|
|
|
|
sleep = _get_sleep_interval(
|
|
exception, initial_interval, max_interval, exponent, retries
|
|
)
|
|
await asyncio.sleep(sleep)
|
|
retries += 1
|