4040)
4141
4242
43+ class _RetryBudget :
44+ """Shared attempt/deadline budget for one logical request.
45+
46+ The transient-5xx, 429 rate-limit, and 401 auth-refresh retry layers all
47+ draw from a single instance so a composed outage cannot multiply their
48+ per-layer limits into a large physical request count. ``remaining`` counts
49+ the physical requests still permitted; ``deadline`` is a ``time.monotonic``
50+ value past which no further retry is attempted.
51+ """
52+
53+ __slots__ = ("remaining" , "deadline" )
54+
55+ def __init__ (self , max_attempts : int , deadline : float ) -> None :
56+ self .remaining = max_attempts
57+ self .deadline = deadline
58+
59+ def consume (self ) -> None :
60+ self .remaining -= 1
61+
62+ def can_retry (self ) -> bool :
63+ return self .remaining > 0 and time .monotonic () < self .deadline
64+
65+
4366class TangleApiClient (GeneratedTangleApiOperations ):
4467 """Single public API wrapper for Tangle backends.
4568
@@ -50,9 +73,19 @@ class TangleApiClient(GeneratedTangleApiOperations):
5073
5174 _REDIRECT_STATUSES = {301 , 302 , 303 , 307 , 308 }
5275 _MAX_REDIRECTS = 5
53- _MAX_RATE_LIMIT_RETRIES = 3
5476 _RATE_LIMIT_BACKOFF_SECONDS = 1.0
5577 _MAX_RETRY_AFTER_SECONDS = 60.0
78+ _RETRYABLE_GET_STATUSES = frozenset ({500 , 502 , 503 , 504 })
79+ _MAX_GET_RETRIES = 6
80+ _GET_RETRY_BACKOFF_SECONDS = 1.0
81+ _MAX_GET_RETRY_BACKOFF_SECONDS = 30.0
82+ # A single logical request may issue at most ``_MAX_GET_RETRIES + 1``
83+ # physical requests total, shared across the transient-5xx, 429 rate-limit,
84+ # and 401 auth-refresh layers, and must not spend more than
85+ # ``_MAX_RETRY_ELAPSED_SECONDS`` retrying. One shared budget prevents the
86+ # layers from multiplying into a large physical request count during an
87+ # outage (e.g. interleaved 503/429 responses, or a 401 mid-sequence).
88+ _MAX_RETRY_ELAPSED_SECONDS = 120.0
5689
5790 def __init__ (
5891 self ,
@@ -121,6 +154,11 @@ def _make_request(
121154 clean_params = self ._clean_mapping (params )
122155 request_method = method .upper ()
123156
157+ budget = _RetryBudget (
158+ self ._MAX_GET_RETRIES + 1 ,
159+ time .monotonic () + self ._MAX_RETRY_ELAPSED_SECONDS ,
160+ )
161+
124162 self ._refresh_auth ()
125163 response = self ._request_with_rate_limit_retries (
126164 request_method ,
@@ -130,8 +168,11 @@ def _make_request(
130168 extra_headers = extra_headers ,
131169 timeout = timeout ,
132170 request_kwargs = kwargs ,
171+ budget = budget ,
133172 )
134- if response .status_code == 401 :
173+ # The auth-refresh retry draws from the same budget, so a 401 late in a
174+ # transient/rate-limit sequence cannot start a fresh round of retries.
175+ if response .status_code == 401 and budget .can_retry ():
135176 self ._refresh_auth ()
136177 response = self ._request_with_rate_limit_retries (
137178 request_method ,
@@ -141,6 +182,7 @@ def _make_request(
141182 extra_headers = extra_headers ,
142183 timeout = timeout ,
143184 request_kwargs = kwargs ,
185+ budget = budget ,
144186 )
145187 return response
146188
@@ -154,22 +196,117 @@ def _request_with_rate_limit_retries(
154196 extra_headers : Mapping [str , str ] | None ,
155197 timeout : float ,
156198 request_kwargs : Mapping [str , Any ],
199+ budget : _RetryBudget ,
157200 ) -> requests .Response :
158- response : requests . Response | None = None
159- for attempt in range ( self . _MAX_RATE_LIMIT_RETRIES + 1 ) :
160- response = self ._request_with_same_origin_redirects (
201+ rate_limit_round = 0
202+ while True :
203+ response = self ._request_with_transient_retries (
161204 method ,
162205 url ,
163206 params = params ,
164207 json_data = json_data ,
165208 extra_headers = extra_headers ,
166209 timeout = timeout ,
167210 request_kwargs = request_kwargs ,
211+ budget = budget ,
168212 )
169- if response .status_code != 429 or attempt == self ._MAX_RATE_LIMIT_RETRIES :
213+ # A 429 retry re-enters the transient layer, so it must draw from the
214+ # shared budget rather than a per-round allowance.
215+ if response .status_code != 429 or not budget .can_retry ():
170216 return response
171- self ._sleep_for_rate_limit (response , attempt )
172- return response
217+ self ._sleep_for_rate_limit (response , rate_limit_round )
218+ rate_limit_round += 1
219+
220+ def _request_with_transient_retries (
221+ self ,
222+ method : str ,
223+ url : str ,
224+ * ,
225+ params : Mapping [str , Any ] | None ,
226+ json_data : Any ,
227+ extra_headers : Mapping [str , str ] | None ,
228+ timeout : float ,
229+ request_kwargs : Mapping [str , Any ],
230+ budget : _RetryBudget ,
231+ ) -> requests .Response :
232+ """Retry idempotent GETs on transient 5xx and transport errors.
233+
234+ Mutating methods are sent once (never duplicated). Streamed GETs bypass
235+ this layer so their consumer owns any stream-open retries. 429s are left
236+ to the rate-limit layer, whose retries re-enter this layer with a fresh
237+ backoff. ``SSLError`` raises immediately: certificate failures are
238+ deterministic, so retrying only delays the report. Every physical send
239+ draws from the shared ``budget`` so the transient, rate-limit, and
240+ auth-refresh layers cannot multiply into a large request count. Each
241+ doubling sleep is capped at ``_MAX_GET_RETRY_BACKOFF_SECONDS`` and
242+ announced through ``self.logger`` (a null logger on non-verbose clients
243+ built without one), so a stalled GET is bounded.
244+ """
245+
246+ if method .upper () != "GET" or request_kwargs .get ("stream" ):
247+ budget .consume ()
248+ return self ._request_with_same_origin_redirects (
249+ method ,
250+ url ,
251+ params = params ,
252+ json_data = json_data ,
253+ extra_headers = extra_headers ,
254+ timeout = timeout ,
255+ request_kwargs = request_kwargs ,
256+ )
257+
258+ backoff = self ._GET_RETRY_BACKOFF_SECONDS
259+ attempt = 0
260+ while True :
261+ budget .consume ()
262+ attempt += 1
263+ try :
264+ response = self ._request_with_same_origin_redirects (
265+ method ,
266+ url ,
267+ params = params ,
268+ json_data = json_data ,
269+ extra_headers = extra_headers ,
270+ timeout = timeout ,
271+ request_kwargs = request_kwargs ,
272+ )
273+ # Transient transport failures (reset/refused, timeout, truncated or
274+ # corrupt body) can succeed on retry; other request errors surface.
275+ except (
276+ requests .ConnectionError ,
277+ requests .Timeout ,
278+ requests .exceptions .ChunkedEncodingError ,
279+ requests .exceptions .ContentDecodingError ,
280+ ) as exc :
281+ # SSLError subclasses ConnectionError but signals a certificate
282+ # or TLS configuration problem that no retry can fix.
283+ if isinstance (exc , requests .exceptions .SSLError ):
284+ raise
285+ # Budget exhausted (attempts or deadline): surface the failure.
286+ if not budget .can_retry ():
287+ raise
288+ self ._sleep_for_transient_retry (backoff , attempt , type (exc ).__name__ )
289+ else :
290+ if response .status_code not in self ._RETRYABLE_GET_STATUSES :
291+ return response
292+ # Budget exhausted: return the final 5xx for raise_for_status.
293+ if not budget .can_retry ():
294+ return response
295+ # Release the intermediate response so its connection returns to the pool.
296+ try :
297+ response .close ()
298+ except Exception :
299+ pass
300+ self ._sleep_for_transient_retry (backoff , attempt , f"HTTP { response .status_code } " )
301+ backoff *= 2.0
302+
303+ def _sleep_for_transient_retry (self , backoff : float , attempt : int , reason : str ) -> None :
304+ delay = min (backoff , self ._MAX_GET_RETRY_BACKOFF_SECONDS )
305+ self .logger .warn (
306+ f"transient { reason } on GET; retrying in { delay :.1f} s "
307+ f"(attempt { attempt + 1 } /{ self ._MAX_GET_RETRIES + 1 } )"
308+ )
309+ time .sleep (delay )
173310
174311 def _sleep_for_rate_limit (self , response : requests .Response , attempt : int ) -> None :
175312 retry_after = response .headers .get ("Retry-After" )
0 commit comments