1010from __future__ import annotations
1111
1212import json
13+ import logging
1314import math
15+ import random
1416import time
1517import urllib .error
1618import urllib .parse
1719import urllib .request
1820from typing import Any , Dict , Optional , Tuple
1921
20- from .errors import HTTPError , RateLimitError
22+ from .errors import (
23+ AuthenticationError ,
24+ HTTPError ,
25+ InvalidRequestError ,
26+ NetworkError ,
27+ NotFoundError ,
28+ RateLimitError ,
29+ )
30+
31+ logger = logging .getLogger (__name__ )
32+
33+ try : # pragma: no cover - optional dependency
34+ import httpx
35+ except ImportError : # pragma: no cover - optional dependency
36+ httpx = None
2137
2238# ---------------------------------------------------------------------------
2339# Constants
@@ -62,6 +78,72 @@ def _backoff_seconds(attempt: int) -> float:
6278 return min (_BASE_BACKOFF * math .pow (2 , attempt ), _MAX_BACKOFF )
6379
6480
81+ def _retry_delay (attempt : int , base_delay : float ) -> float :
82+ """Return a capped exponential delay with randomized jitter."""
83+ return min (base_delay * (2 ** attempt ) + random .uniform (0 , 0.5 ), _MAX_BACKOFF )
84+
85+
86+ def _is_retryable_transport_error (exc : Exception ) -> bool :
87+ """Return True for transient network failures that should be retried."""
88+ if httpx is not None and isinstance (exc , (httpx .ConnectError , httpx .TimeoutException )):
89+ return True
90+
91+ try :
92+ import aiohttp
93+ except ImportError :
94+ aiohttp = None
95+
96+ if aiohttp is not None and isinstance (
97+ exc ,
98+ (
99+ aiohttp .ClientConnectionError ,
100+ aiohttp .ClientConnectorError ,
101+ aiohttp .ClientOSError ,
102+ aiohttp .ServerDisconnectedError ,
103+ ),
104+ ):
105+ return True
106+
107+ if isinstance (exc , (ConnectionResetError , TimeoutError , urllib .error .URLError )):
108+ return True
109+ return False
110+
111+
112+ def _is_retryable_status (status : int ) -> bool :
113+ return status in {502 , 503 , 504 }
114+
115+
116+ def _retry_with_backoff (fn , max_retries : int , base_delay : float ):
117+ """Execute *fn* and retry transient failures with exponential back-off."""
118+ for attempt in range (max_retries + 1 ):
119+ try :
120+ return fn ()
121+ except Exception as exc :
122+ if attempt >= max_retries or not _is_retryable_error (exc ):
123+ raise
124+ delay = _retry_delay (attempt , base_delay )
125+ logger .debug (
126+ "Retrying request after transient failure (attempt %s/%s) in %.3fs" ,
127+ attempt + 1 ,
128+ max_retries + 1 ,
129+ delay ,
130+ )
131+ time .sleep (delay )
132+
133+
134+ def _is_retryable_error (exc : Exception ) -> bool :
135+ if _is_retryable_transport_error (exc ):
136+ return True
137+
138+ if httpx is not None and isinstance (exc , httpx .HTTPStatusError ):
139+ return _is_retryable_status (exc .response .status_code )
140+
141+ if isinstance (exc , HTTPError ):
142+ return _is_retryable_status (exc .status_code or 0 )
143+
144+ return False
145+
146+
65147def _raise_for_status (
66148 status : int ,
67149 headers : Any ,
@@ -81,6 +163,14 @@ def _raise_for_status(
81163 ------
82164 RateLimitError
83165 If HTTP 429 and retries are exhausted (or auto-retry is off).
166+ InvalidRequestError
167+ For HTTP 400 responses.
168+ AuthenticationError
169+ For HTTP 401/403 responses.
170+ NotFoundError
171+ For HTTP 404 responses.
172+ NetworkError
173+ For transient 502/503/504 responses after retries are exhausted.
84174 HTTPError
85175 For any other non-2xx status.
86176 """
@@ -100,6 +190,32 @@ def _raise_for_status(
100190 msg = f"Rate limit exceeded. { detail } " .strip ()
101191 raise RateLimitError (msg , retry_after = retry_after )
102192
193+ if status == 400 :
194+ raise InvalidRequestError ("Invalid request" , status_code = status )
195+
196+ if status in {401 , 403 }:
197+ raise AuthenticationError ("Authentication failed" , status_code = status )
198+
199+ if status == 404 :
200+ response_body = body .decode ("utf-8" , errors = "replace" )
201+ raise NotFoundError (
202+ "Resource not found" ,
203+ status_code = status ,
204+ response_body = response_body ,
205+ )
206+
207+ if status in {502 , 503 , 504 }:
208+ if attempt < max_retries :
209+ wait = _retry_delay (attempt , _BASE_BACKOFF )
210+ logger .debug (
211+ "Retrying request after server error (attempt %s/%s) in %.3fs" ,
212+ attempt + 1 ,
213+ max_retries + 1 ,
214+ wait ,
215+ )
216+ return wait
217+ raise NetworkError (f"Request failed with transient server error: { status } " , status_code = status )
218+
103219 try :
104220 detail = json .loads (body ).get ("error" , {}).get ("message" , "" )
105221 except Exception :
@@ -176,12 +292,29 @@ def request(
176292 attempt = 0
177293 while True :
178294 req = self ._build_request (method , path , payload )
179- status , headers , body = self ._execute (req )
295+ try :
296+ status , headers , body = self ._execute (req )
297+ except Exception as exc :
298+ if _is_retryable_transport_error (exc ):
299+ if attempt >= self .max_retries :
300+ raise NetworkError (
301+ "Request failed after exhausting retries" ,
302+ status_code = None ,
303+ ) from exc
304+ delay = _retry_delay (attempt , _BASE_BACKOFF )
305+ logger .debug (
306+ "Retrying request after transient failure (attempt %s/%s) in %.3fs" ,
307+ attempt + 1 ,
308+ self .max_retries + 1 ,
309+ delay ,
310+ )
311+ time .sleep (delay )
312+ attempt += 1
313+ continue
314+ raise
180315 wait = _raise_for_status (status , headers , body , attempt , self .max_retries )
181316 if wait is None :
182- # success
183317 return json .loads (body ) if body else {}
184- # 429 — sleep and retry
185318 time .sleep (wait )
186319 attempt += 1
187320
@@ -269,18 +402,36 @@ async def request(
269402 ) as session :
270403 while True :
271404 url = f"{ url_base } /{ path .lstrip ('/' )} "
272- resp = await session .request (
273- method .upper (),
274- url ,
275- json = payload ,
276- headers = headers ,
277- )
278- body = await resp .read ()
405+ try :
406+ resp = await session .request (
407+ method .upper (),
408+ url ,
409+ json = payload ,
410+ headers = headers ,
411+ )
412+ body = await resp .read ()
413+ except Exception as exc :
414+ if _is_retryable_transport_error (exc ):
415+ if attempt >= self .max_retries :
416+ raise NetworkError (
417+ "Request failed after exhausting retries" ,
418+ status_code = None ,
419+ ) from exc
420+ delay = _retry_delay (attempt , _BASE_BACKOFF )
421+ logger .debug (
422+ "Retrying request after transient failure (attempt %s/%s) in %.3fs" ,
423+ attempt + 1 ,
424+ self .max_retries + 1 ,
425+ delay ,
426+ )
427+ await asyncio .sleep (delay )
428+ attempt += 1
429+ continue
430+ raise
279431 wait = _raise_for_status (
280432 resp .status , resp .headers , body , attempt , self .max_retries
281433 )
282434 if wait is None :
283435 return json .loads (body ) if body else {}
284- # 429 — non-blocking sleep
285436 await asyncio .sleep (wait )
286437 attempt += 1
0 commit comments