Skip to content

Commit d073681

Browse files
authored
fix(auth): retry token exchange on transient failures (#129)
* fix(auth): retry token exchange on transient failures * fix(auth): bound transport retries by disabling urllib3 retries * fix(auth): serve cached JWT lock-free so retries don't block callers * docs(auth): correct jitter comment to reflect additive range
1 parent 25b060b commit d073681

2 files changed

Lines changed: 305 additions & 21 deletions

File tree

‎hotdata/_auth.py‎

Lines changed: 110 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
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
@@ -36,6 +41,7 @@
3641

3742
import json
3843
import os
44+
import random
3945
import ssl
4046
import threading
4147
import time
@@ -47,6 +53,15 @@
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+
128169
class _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

Comments
 (0)