|
|
@@ -36,6 +36,7 @@ class ServiceClientBase:
|
|
|
def __init__(self) -> None:
|
|
|
self._refresh_lock = asyncio.Lock()
|
|
|
self._refresh_task: asyncio.Task | None = None
|
|
|
+ self._refresh_is_full = False
|
|
|
|
|
|
# ── credentials and staleness ──────────────────────────────────────────────
|
|
|
|
|
|
@@ -71,15 +72,24 @@ class ServiceClientBase:
|
|
|
|
|
|
# ── request skeleton ───────────────────────────────────────────────────────
|
|
|
|
|
|
+ def _auth_shaped(self, r: httpx.Response) -> bool:
|
|
|
+ """True when a response signals an auth failure. Default: HTTP status.
|
|
|
+
|
|
|
+ Override to also recognize body-level signals (e.g. Airbnb's GQL error
|
|
|
+ extensions on HTTP 200) so they get the same refresh → retry-once → stale
|
|
|
+ sequence as status-shaped failures.
|
|
|
+ """
|
|
|
+ return r.status_code in _AUTH_STATUSES
|
|
|
+
|
|
|
async def _request(self, attempt: Callable[[], Awaitable[httpx.Response]], *, op: str = '') -> httpx.Response:
|
|
|
- """Run attempt; on an auth-shaped status, refresh credentials and retry once."""
|
|
|
+ """Run attempt; on an auth-shaped response, refresh credentials and retry once."""
|
|
|
r = await self._classified(attempt, op)
|
|
|
- if r.status_code in _AUTH_STATUSES:
|
|
|
- _log.warning('%s %s http=%d — refreshing credentials and retrying; body=%.500s',
|
|
|
+ if self._auth_shaped(r):
|
|
|
+ _log.warning('%s %s auth-shaped response (http=%d) — refreshing credentials and retrying; body=%.500s',
|
|
|
self.service_key, op, r.status_code, r.text)
|
|
|
await self._ensure_refreshed()
|
|
|
r = await self._classified(attempt, op)
|
|
|
- if r.status_code in _AUTH_STATUSES:
|
|
|
+ if self._auth_shaped(r):
|
|
|
mark_stale(self.service_key)
|
|
|
raise self.auth_error_cls(
|
|
|
f'{self.service_name} session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
|
|
|
@@ -100,13 +110,29 @@ class ServiceClientBase:
|
|
|
|
|
|
# ── refresh orchestration ──────────────────────────────────────────────────
|
|
|
|
|
|
- async def _ensure_refreshed(self) -> None:
|
|
|
- """Single-flight refresh: concurrent callers await the same in-progress attempt."""
|
|
|
- async with self._refresh_lock:
|
|
|
- if self._refresh_task is None or self._refresh_task.done():
|
|
|
- self._refresh_task = asyncio.create_task(self._auto_refresh())
|
|
|
- task = self._refresh_task
|
|
|
- await task
|
|
|
+ async def _ensure_refreshed(self, *, full: bool = False) -> None:
|
|
|
+ """Single-flight refresh: concurrent callers await the same in-progress attempt.
|
|
|
+
|
|
|
+ full=True skips the cheap-refresh hook and forces the class's
|
|
|
+ refresh_credentials — for failures a cheap refresh can't fix even though
|
|
|
+ the session looks alive (e.g. Booking's expired messaging-2FA grant). If a
|
|
|
+ cheap-allowed refresh is already in flight, it is allowed to finish and a
|
|
|
+ full refresh runs after it.
|
|
|
+ """
|
|
|
+ while True:
|
|
|
+ async with self._refresh_lock:
|
|
|
+ if self._refresh_task is None or self._refresh_task.done():
|
|
|
+ coro = self._full_refresh() if full else self._auto_refresh()
|
|
|
+ self._refresh_task = asyncio.create_task(coro)
|
|
|
+ self._refresh_is_full = full
|
|
|
+ task = self._refresh_task
|
|
|
+ satisfied = self._refresh_is_full or not full
|
|
|
+ if satisfied:
|
|
|
+ await task
|
|
|
+ return
|
|
|
+ # need a full refresh but a cheap-allowed one is in flight — let it
|
|
|
+ # finish (ignoring its outcome), then loop to start or join a full one
|
|
|
+ await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
async def _try_cheap_refresh(self) -> bool:
|
|
|
"""Attempt a lightweight in-process session refresh; True on success.
|
|
|
@@ -122,12 +148,21 @@ class ServiceClientBase:
|
|
|
_log.info('%s session refreshed in place', self.service_name)
|
|
|
clear_stale(self.service_key)
|
|
|
return
|
|
|
+ _log.warning('%s: falling back to full credential refresh', self.service_name)
|
|
|
+ await self._full_refresh()
|
|
|
+
|
|
|
+ async def _full_refresh(self) -> None:
|
|
|
+ """Run the class's refresh_credentials in an executor, then reload from env.
|
|
|
+
|
|
|
+ Owns the stale-flag bookkeeping for the full-refresh path; never attempts
|
|
|
+ the cheap refresh. Reach it via _ensure_refreshed(full=True) so concurrent
|
|
|
+ callers share one attempt.
|
|
|
+ """
|
|
|
refresh = getattr(type(self), 'refresh_credentials', None)
|
|
|
if refresh is None:
|
|
|
mark_stale(self.service_key)
|
|
|
raise self.auth_error_cls(
|
|
|
f'{self.service_name} session expired and no automatic refresh is available — run: {_AUTH_HINT}')
|
|
|
- _log.warning('%s: falling back to full credential refresh', self.service_name)
|
|
|
try:
|
|
|
await asyncio.get_running_loop().run_in_executor(None, refresh)
|
|
|
self._reload_from_env()
|