Rotating OAuth2 Tokens Without Dropping In-Flight Requests
When an access token expires in the middle of a burst of concurrent channel-manager calls, the naive fix — every coroutine that sees a 401 refreshes the token — produces a thundering herd of refresh calls, and the last one to write wins while every other in-flight request carries a token that was just invalidated. This page shows how to rotate the token exactly once, let the other coroutines wait for that single refresh, and keep both the old and new token accepted for a short grace window so nothing already on the wire is dropped. It is the concurrency-hardened companion to the broader OAuth2 token refresh strategies that govern how a sync worker stays authenticated.
The audience is the engineer who runs a httpx.AsyncClient with a pool of coroutines pushing rate and availability updates to booking_com and expedia at once. A single expiry event should cost one refresh round-trip and zero failed pushes — not a cascade of invalid_token responses that a revenue manager later sees as a gap in a channel’s rate calendar.
Prerequisites & environment
The pattern below lives in the auth layer that sits under every outbound request, so pin these so the lock semantics and clock math behave identically across every worker:
- Python 3.11+ — for
asyncio.Lockused as an async context manager andtime.monotonic()for skew-immune expiry checks. httpx0.27+ — one sharedAsyncClientper worker; the token provider is injected as an auth hook, not copied into each call site.pydantic2.6+ — the token envelope (access_token,expires_at,refresh_token) is a validated model so a malformed provider response fails at parse time, not on the wire.structlog24.x — every refresh emits a key=value event keyed byproperty_idand channel slug so a rotation is greppable against a401spike.- An OAuth2 client-credentials or refresh-token grant on the channel manager, with credentials scoped per the security and authentication boundaries and the base flow established in implementing OAuth2 for PMS API access.
The one hard requirement the provider must document is the grace window: how long, if at all, a rotated token stays valid after a refresh. Many OTAs keep the previous token live for 30–120 seconds; that overlap is what makes zero-drop rotation possible, and Step 4 explains what to do when it is zero.
Step-by-step implementation
Four moving parts: a validated token envelope with a monotonic expiry check, a single-flight refresh guarded by an asyncio.Lock, an auth hook that reads the cache on every request, and a retry-once-on-401 wrapper that survives the narrow race the grace window cannot cover.
Step 1 — Model the token with a skew-aware expiry
Store the token as a validated envelope and compute a stale flag against time.monotonic(), not wall-clock time, so an NTP correction or a clock jump mid-sync can never make a live token look expired or an expired one look live.
import time
from pydantic import BaseModel, Field
class TokenEnvelope(BaseModel):
access_token: str
refresh_token: str | None = None
# monotonic deadline computed at fetch time, immune to wall-clock jumps
expires_at_monotonic: float
skew_seconds: float = Field(default=30.0, ge=0)
@classmethod
def from_response(cls, body: dict, skew: float = 30.0) -> "TokenEnvelope":
return cls(
access_token=body["access_token"],
refresh_token=body.get("refresh_token"),
expires_at_monotonic=time.monotonic() + float(body["expires_in"]),
skew_seconds=skew,
)
def is_stale(self) -> bool:
# refresh 'skew' seconds early so we rotate before the OTA rejects us
return time.monotonic() >= (self.expires_at_monotonic - self.skew_seconds)
Using time.monotonic() rather than datetime.now() matters because these workers are long-lived: a wall-clock adjustment during a multi-hour sync would otherwise silently shift every expiry deadline, and the whole point of proactive rotation is that the deadline stays trustworthy.
Step 2 — Refresh exactly once under an asyncio lock
Wrap the refresh in a TokenProvider that holds an asyncio.Lock. The critical detail is the double-checked read: after acquiring the lock, re-inspect the cached token before refreshing, because by the time a waiting coroutine gets the lock, the winner has usually already written a fresh token.
import asyncio
import httpx
import structlog
log = structlog.get_logger()
class TokenProvider:
def __init__(self, client: httpx.AsyncClient, token_url: str, creds: dict):
self._client = client
self._token_url = token_url
self._creds = creds
self._token: TokenEnvelope | None = None
self._lock = asyncio.Lock()
async def get_token(self) -> str:
if self._token is not None and not self._token.is_stale():
return self._token.access_token # fast path: no lock, no await
async with self._lock:
# double-check: another coroutine may have refreshed while we waited
if self._token is not None and not self._token.is_stale():
return self._token.access_token
resp = await self._client.post(self._token_url, data=self._creds)
resp.raise_for_status()
self._token = TokenEnvelope.from_response(resp.json())
log.info("oauth_token_rotated", channel="booking_com",
expires_in=self._creds.get("expires_in"))
return self._token.access_token
The double-checked read inside the lock is what collapses N concurrent expiries into one HTTP call: without it, every coroutine that queued on the lock would perform its own redundant refresh the instant it acquired the lock, recreating the thundering herd the lock was meant to prevent.
Step 3 — Inject the token through an auth hook, not per call site
Bind the provider to the client with an httpx.Auth subclass so every request reads the current token at send time. This guarantees a request dispatched a millisecond after a rotation picks up the new token, and no call site can accidentally cache a stale bearer string in a local variable.
class RotatingBearerAuth(httpx.Auth):
requires_request_body = False
def __init__(self, provider: TokenProvider):
self._provider = provider
async def async_auth_flow(self, request):
token = await self._provider.get_token()
request.headers["Authorization"] = f"Bearer {token}"
yield request # httpx sends it; response handled by the caller/retry wrapper
Reading the token inside async_auth_flow rather than passing a string into the client means the freshest cached token is stamped onto the request at the last possible moment, closing the window where a request built before a rotation would otherwise ship the old bearer.
Step 4 — Retry once on 401 to cover the grace-window edge
The grace window covers requests already on the wire, but there is still a hairline race: a request whose header was stamped microseconds before a rotation, on a provider with a zero grace window, can land a 401. Handle it by forcing one refresh and replaying the request exactly once — never in a loop, or a genuinely revoked credential becomes an infinite retry storm.
async def send_with_rotation(client: httpx.AsyncClient, provider: TokenProvider,
request: httpx.Request) -> httpx.Response:
resp = await client.send(request)
if resp.status_code == 401:
provider._token = None # force the next get_token() to refresh
fresh = await provider.get_token()
request.headers["Authorization"] = f"Bearer {fresh}"
log.warning("oauth_401_retry", channel="expedia",
property_id=request.headers.get("X-Property-Id"))
resp = await client.send(request) # replay once, then surface the result
return resp
Capping the replay at one attempt is deliberate: a 401 after a fresh forced refresh is no longer an expiry problem but a revoked or misscoped credential, and retrying that further would only hammer the OTA and delay the real signal to error categorization and retry logic.
Gotchas & production notes
- A refresh-token rotation is single-use — serialize it or lose the whole session. Providers that rotate the
refresh_tokenon every use invalidate the old one immediately; if two coroutines somehow both refresh, the second presents a now-dead refresh token and the entire session dies. Theasyncio.Lockin Step 2 is not just an optimization here — it is correctness, and it must guard the refresh-token grant specifically. - Set the skew margin above your P99 request latency, not a round number. If a push to
agodaroutinely takes 8 seconds and your skew is 5, a token can expire between the header stamp and the OTA receiving it. Sizeskew_secondsfrom measured latency plus clock uncertainty; 30 seconds is a safe default but verify it against your slowest channel. - One provider instance per event loop — never share across processes. The
asyncio.Lockonly serializes coroutines inside one loop. A pool of worker processes each gets its own provider and each will refresh independently, multiplying refresh calls by the worker count; when that matters, promote the cache out of process as described in caching access tokens in Redis with proactive refresh. - Do not log the token, even at debug. Rotation events are noisy and easy to over-instrument; log the channel,
property_id, and expiry, never the bearer string, so an access token never lands in a log that outlives it.
Verification snippet
The one behaviour worth proving is single-flight: many concurrent callers hitting an expired cache must trigger exactly one network refresh. Stub the HTTP post with a counter and fan out.
import asyncio
async def test_single_flight_refresh() -> None:
calls = {"n": 0}
class FakeProvider(TokenProvider):
async def _do_refresh(self) -> None:
calls["n"] += 1
await asyncio.sleep(0.05) # simulate network latency
self._token = TokenEnvelope(
access_token=f"tok-{calls['n']}", refresh_token="r",
expires_at_monotonic=time.monotonic() + 3600)
async def get_token(self) -> str:
if self._token is not None and not self._token.is_stale():
return self._token.access_token
async with self._lock:
if self._token is not None and not self._token.is_stale():
return self._token.access_token
await self._do_refresh()
return self._token.access_token
prov = FakeProvider.__new__(FakeProvider)
prov._token = None
prov._lock = asyncio.Lock()
tokens = await asyncio.gather(*(prov.get_token() for _ in range(50)))
assert calls["n"] == 1 # 50 callers, one refresh
assert len(set(tokens)) == 1 # everyone got the same token
asyncio.run(test_single_flight_refresh())
A green run proves the two guarantees that matter under load: fifty coroutines that all observed an empty cache produced exactly one refresh, and every one of them walked away with the identical token — so a live expiry event costs one round-trip, and no concurrent push is stranded on a token that was rotated out from under it.
Related
- OAuth2 Token Refresh Strategies — the parent guide to keeping a sync worker authenticated across expiries
- Caching Access Tokens in Redis with Proactive Refresh — promote the single-flight cache out of process when many workers share one credential
- Automating Channel Manager Token Renewal — the unattended renewal loop this rotation logic plugs into
- Implementing OAuth2 for PMS API Access — the grant flow that issues the tokens rotated here
- Error Categorization & Retry Logic — where a post-refresh 401 stops being retryable and becomes a credential alert
← Back to OAuth2 Token Refresh Strategies