2828* **Refresh, then re-mint** -- prefer the refresh token when available; on
2929 refresh failure, re-mint from the held API token (always possible since the
3030 SDK holds it). Matches the CLI.
31+ * **Transient-failure retry** -- a momentary ``5xx`` or a transport error on the
32+ token endpoint is retried with bounded exponential backoff + jitter
33+ (``_MAX_ATTEMPTS`` total) before giving up, so a brief server-side blip does
34+ not fail the caller (#113). A ``4xx`` is never retried -- a bad/expired
35+ credential is not transient.
3136* **TLS/proxy reuse** -- the exchange call reuses the SDK's configured TLS,
3237 client cert and proxy settings (see :func:`_pool_from_config`) so it behaves
3338 like every other SDK request, with a bounded timeout so a stalled token
3641
3742import json
3843import os
44+ import random
3945import ssl
4046import threading
4147import time
4753_TIMEOUT = 30.0 # seconds -- never let a stalled token endpoint hang every request
4854_CLIENT_ID = "hotdata-python-sdk"
4955
56+ # Bounded retry of *transient* token-exchange failures (#113). A momentary 5xx
57+ # or a transport error (connection/read failure) on the token endpoint should
58+ # not fail the caller outright -- an immediate re-attempt typically succeeds.
59+ # We retry those, but never a 4xx (a bad/expired credential is not transient).
60+ _MAX_ATTEMPTS = 3 # one initial attempt + up to two retries
61+ _BACKOFF_BASE = 0.1 # seconds -- first retry waits ~this, doubling thereafter
62+ _BACKOFF_MAX = 2.0 # cap on a single backoff so a flapping host can't stall us
63+ _BACKOFF_JITTER = 0.5 # additive jitter fraction (delay in [base, 1.5*base]) to spread retries out
64+
5065# Env var that disables exchange entirely. Used as a hard escape hatch during
5166# the rollout window and for local/dev setups. Only affirmative values opt out
5267# (see _DISABLE_VALUES) so that ``=0`` / ``=false`` do NOT silently disable it.
@@ -109,8 +124,11 @@ def _pool_from_config(configuration):
109124 pool_args ["server_hostname" ] = configuration .tls_server_name
110125 if configuration .socket_options is not None :
111126 pool_args ["socket_options" ] = configuration .socket_options
112- # `retries`/`maxsize` are intentionally not mirrored: the exchange is a
113- # single bounded-timeout request that fails fast rather than retrying.
127+ # `retries`/`maxsize` are intentionally not mirrored: urllib3's own Retry is
128+ # left at urllib3's default (it does not retry POST status codes anyway), so
129+ # we do not inherit the SDK's connection-reset retry here. Transient-failure
130+ # retry for the exchange is handled explicitly in `_TokenManager._exchange`
131+ # (5xx + transport errors, bounded backoff), where we can keep 4xx fatal.
114132
115133 if configuration .proxy :
116134 if _is_socks_proxy_url (configuration .proxy ):
@@ -125,6 +143,29 @@ def _pool_from_config(configuration):
125143 return urllib3 .PoolManager (** pool_args )
126144
127145
146+ def _is_transient_status (status ):
147+ """True for HTTP statuses worth retrying (server-side, likely momentary).
148+
149+ Only 5xx is transient: the request reached the server but it failed to
150+ handle it (e.g. a brief ``500``/``503``). A 4xx -- including ``400``/``401``
151+ from a bad or expired credential -- is a definitive rejection that a retry
152+ will not fix, so it is never retried.
153+ """
154+ return 500 <= status < 600
155+
156+
157+ def _backoff_delay (attempt ):
158+ """Seconds to sleep before retry number ``attempt`` (0 = first retry).
159+
160+ Exponential growth from ``_BACKOFF_BASE`` (doubling per attempt) capped at
161+ ``_BACKOFF_MAX``, plus additive jitter in ``[0, _BACKOFF_JITTER * base]`` so
162+ concurrent clients retrying the same blip don't resynchronize into a thundering
163+ herd. Mirrors the Rust SDK's ``backoff_delay``.
164+ """
165+ base = min (_BACKOFF_BASE * (2 ** attempt ), _BACKOFF_MAX )
166+ return base * (1 + _BACKOFF_JITTER * random .random ())
167+
168+
128169class _TokenManager :
129170 """Exchanges an API token for short-lived JWTs and keeps them fresh.
130171
@@ -134,10 +175,11 @@ class _TokenManager:
134175 exchanged.
135176 """
136177
137- def __init__ (self , credential , configuration , pool = None ):
178+ def __init__ (self , credential , configuration , pool = None , sleep = None ):
138179 self ._credential = credential
139180 self ._config = configuration # read host + TLS lazily at mint time
140181 self ._pool = pool # injected in tests; else built from config TLS
182+ self ._sleep = sleep or time .sleep # injected in tests so retry backoff is instant
141183 self ._lock = threading .Lock ()
142184 self ._jwt = None
143185 self ._exp = 0.0
@@ -168,8 +210,18 @@ def bearer_value(self):
168210 """
169211 if not self ._needs_exchange :
170212 return self ._credential # already a JWT (or opt-out) -> unchanged
213+ # Lock-free fast path: a still-valid cached JWT needs no mint and must
214+ # not block behind an in-flight (possibly retrying) mint that holds the
215+ # lock for up to several timeouts. Attribute reads are atomic under the
216+ # GIL; the worst a benign jwt/exp race can do is fall through to re-check
217+ # under the lock, and the _LEEWAY margin keeps a token read here valid on
218+ # the wire even if it is about to be rotated.
219+ jwt = self ._jwt
220+ if jwt and time .time () < self ._exp - _LEEWAY :
221+ return jwt
171222 with self ._lock :
172- # Fast path: a still-valid cached JWT, no network call.
223+ # Re-check under the lock: another thread may have minted a fresh JWT
224+ # while we waited (double-checked locking).
173225 if self ._jwt and time .time () < self ._exp - _LEEWAY :
174226 return self ._jwt
175227 # Prefer the refresh token; on failure, drop it and re-mint below.
@@ -187,24 +239,12 @@ def _mint(self, params):
187239 # -- a non-200, a transport error, or a malformed/missing-token body --
188240 # returns False so the caller re-mints from the held API token. An
189241 # api_token mint instead raises TokenExchangeError on any failure, since
190- # there is no further fallback.
242+ # there is no further fallback. Transient failures (5xx + transport
243+ # errors) are retried inside _exchange before either outcome.
191244 params ["client_id" ] = _CLIENT_ID
192245 is_refresh = params ["grant_type" ] == "refresh_token"
193246 try :
194- pool = self ._pool or _pool_from_config (self ._config ) # reuses ssl_ca_cert/cert/proxy
195- host = self ._config .host .rstrip ("/" ) # read host lazily -- may be set post-construct
196- resp = pool .request (
197- "POST" ,
198- f"{ host } /v1/auth/jwt" ,
199- body = urlencode (params ),
200- headers = {"Content-Type" : "application/x-www-form-urlencoded" },
201- timeout = _TIMEOUT ,
202- )
203- if resp .status != 200 :
204- raise TokenExchangeError (
205- f"token exchange failed: { resp .status } { resp .data [:200 ]!r} "
206- )
207- data = json .loads (resp .data )
247+ data = self ._exchange (params )
208248 token = data ["access_token" ]
209249 expires_in = float (data .get ("expires_in" , 300 ))
210250 except (
@@ -224,6 +264,57 @@ def _mint(self, params):
224264 self ._refresh = data .get ("refresh_token" ) or self ._refresh
225265 return True
226266
267+ def _exchange (self , params ):
268+ # POST the token-exchange request, retrying transient failures, and
269+ # return the parsed JSON body of the 200 response.
270+ #
271+ # Retries 5xx responses and transport errors (urllib3 HTTPError, e.g. a
272+ # connection/read failure) with bounded exponential backoff + jitter, up
273+ # to _MAX_ATTEMPTS total. A 4xx is returned immediately as a fatal
274+ # TokenExchangeError -- bad/expired credentials are not transient. Once
275+ # the budget is exhausted, the last failure is surfaced: a 5xx as a
276+ # TokenExchangeError preserving the status/body, a transport error as the
277+ # raised HTTPError (which _mint wraps). JSON/missing-token errors from
278+ # parsing a 200 body propagate unretried.
279+ pool = self ._pool or _pool_from_config (self ._config ) # reuses ssl_ca_cert/cert/proxy
280+ host = self ._config .host .rstrip ("/" ) # read host lazily -- may be set post-construct
281+ url = f"{ host } /v1/auth/jwt"
282+ body = urlencode (params )
283+ headers = {"Content-Type" : "application/x-www-form-urlencoded" }
284+ for attempt in range (_MAX_ATTEMPTS ):
285+ last = attempt == _MAX_ATTEMPTS - 1
286+ try :
287+ resp = pool .request (
288+ "POST" ,
289+ url ,
290+ body = body ,
291+ headers = headers ,
292+ timeout = _TIMEOUT ,
293+ # Disable urllib3's own per-request retries so this loop is
294+ # the sole arbiter of the attempt budget. Otherwise urllib3's
295+ # default (Retry(3)) would retry connection errors *inside*
296+ # each attempt, multiplying the effective transport-attempt
297+ # count well past _MAX_ATTEMPTS.
298+ retries = False ,
299+ )
300+ except urllib3 .exceptions .HTTPError :
301+ # Transport-level failure (connection/read error): transient, but
302+ # don't retry past the budget -- re-raise for _mint to handle.
303+ if last :
304+ raise
305+ self ._sleep (_backoff_delay (attempt ))
306+ continue
307+ if resp .status == 200 :
308+ return json .loads (resp .data )
309+ if _is_transient_status (resp .status ) and not last :
310+ self ._sleep (_backoff_delay (attempt ))
311+ continue
312+ # A 4xx, or a 5xx with the retry budget exhausted: fatal, surfacing
313+ # the last status/body.
314+ raise TokenExchangeError (
315+ f"token exchange failed: { resp .status } { resp .data [:200 ]!r} "
316+ )
317+
227318
228319__all__ = [
229320 "TokenExchangeError" ,
0 commit comments