Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,36 @@ END
### 401 Refresh-and-Retry Algorithm

1. Receive 401 response.
2. If the token provider supports refresh (`refreshable() == true`) and refresh has not yet been attempted for this request:
2. If the token provider supports refresh (`refreshable() == true`), refresh has not yet been attempted for this request, **and the attempt budget has another attempt left**:
a. Call `refresh()`.
b. If refresh succeeded, retry the request once with updated token.
c. → response from retry.
3. → `⊥ BasecampError(code: "auth_required", http_status: 401)`.

Refresh is attempted at most once per request. Implementations track this with a boolean (e.g., `refresh_attempted`) rather than a counter.

**The replay spends an attempt.** It puts a request on the wire, so it draws
from the same total-attempt budget as a transient retry (§7): `max_retries`
counts requests, not failure kinds, and a cap of one means one request no
matter what would have caused the second. That is what makes §14's "disabling
retry yields exactly ONE hop-1 attempt" literally true — refresh included —
and it preserves the total-attempt semantics settled for observed attempts in
#461.

Step 2's budget gate is checked **before** `refresh()` is called, not after.
Refreshing a token the SDK has no budget left to use would burn a rotation for
nothing and still hand the caller the stale 401; declining to refresh at all is
both cheaper and easier to reason about. The consequence is worth stating
plainly: with a budget of one attempt, a refreshable 401 is NOT replayed and
surfaces as `auth_required`. Callers who want the refresh replay must leave an
attempt for it.

This gate applies wherever a total-attempt budget governs the path. Go's
hand-written mutation path has no such budget — mutations are deliberately
single-attempt for transient failures — and keeps its documented
mutation-specific single re-attempt after a successful refresh (see §7's
Cross-SDK Divergence).

---

## §5. Service Surface
Expand Down
71 changes: 64 additions & 7 deletions python/src/basecamp/_async_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,21 @@ async def _request_with_retry(
max_attempts = self._config.max_retries if self._config.max_retries > 0 else 1
max_attempts = self._apply_operation_retry_max(operation, max_attempts)
attempt = 0
refreshed_once = False
last_error: BasecampError | None = None

while True:
attempt += 1
if attempt > max_attempts:
break

# Each except suite only CLASSIFIES the attempt; the retry side
# effects run below, outside every handler. An exception raised
# inside an except suite bypasses that try's sibling handlers, so
# refreshing there would let a NetworkError from the token endpoint
# escape the loop with budget still on the table.
error: BasecampError | None = None
stale_auth: AuthError | None = None
try:
return await self._single_request(
method,
Expand All @@ -182,19 +190,63 @@ async def _request_with_retry(
attempt=attempt,
allow_cross_origin=allow_cross_origin,
accept=accept,
refresh_replay=False,
)
except (RateLimitError, NetworkError, ApiError) as e:
if not self._is_retryable_error(e, operation, retry_on=retry_on):
except AuthError as e:
# SPEC §4: the refresh replay is a request on the wire, so it
# spends an attempt from THIS budget rather than an uncounted
# one inside the single-request primitive. max_retries is a
# total attempt count (#461), and a cap of one means one
# request whatever would have caused the second.
#
# The budget is checked BEFORE refresh() so a rotation is never
# burned on an attempt the loop has no room to make. Await the
# refresh rather than truthy-testing the coroutine, which would
# pass the gate without ever rotating the token.
if refreshed_once or e.http_status != 401 or attempt >= max_attempts:
raise
last_error = e
stale_auth = e
except (RateLimitError, NetworkError, ApiError) as e:
error = e

if stale_auth is not None:
# SPEC §4 tracks refresh with an "attempted" boolean, not a
# "succeeded" one, so mark it BEFORE invoking the provider: a
# refresh that throws still counts as the one attempt this
# request gets. Otherwise a transient token-endpoint failure
# lets the NEXT 401 in the same request call refresh() again —
# and if the first call reached the server and rotated the
# token before its response was lost, the second spends a
# refresh token that is already dead.
refreshed_once = True
try:
tp = getattr(self._auth, "token_provider", None)
refreshed = bool(tp and getattr(tp, "refreshable", False) and await tp.refresh())
except (RateLimitError, NetworkError, ApiError) as e:
# A token endpoint that times out is a transient failure of
# this attempt, not a terminal auth fault: it retries under
# the same budget, exactly as it did when the replay lived
# inside _single_request.
error = e
else:
if not refreshed:
raise stale_auth
# No backoff: the token is fresh, and the server never asked
# us to wait. The replay still costs the attempt counted above.
continue

if error is not None:
if not self._is_retryable_error(error, operation, retry_on=retry_on):
raise error
last_error = error
if attempt >= max_attempts:
break
delay = self._calculate_delay(attempt, e.retry_after)
delay = self._calculate_delay(attempt, error.retry_after)
safe_hook(
self._hooks.on_retry,
RequestInfo(method=method, url=url, attempt=attempt),
attempt + 1,
e,
error,
delay,
)
await asyncio.sleep(delay)
Expand All @@ -218,6 +270,7 @@ async def _single_request(
_retry_count: int = 0,
allow_cross_origin: bool = False,
accept: str | None = "application/json",
refresh_replay: bool = True,
) -> httpx.Response:
if not allow_cross_origin and not (
_security.is_localhost(url) or _security.same_origin(url, self._config.base_url)
Expand Down Expand Up @@ -247,8 +300,11 @@ async def _single_request(

if response.status_code >= 400:
error = self._handle_error(response)
# 401 retry with async token refresh
if isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
# 401 replay for callers that come here directly (mutations),
# which have no retry loop to own it. _request_with_retry
# passes refresh_replay=False and replays from the loop so the
# extra request draws from the attempt budget (SPEC §4).
if refresh_replay and isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
tp = getattr(self._auth, "token_provider", None)
if tp and getattr(tp, "refreshable", False) and await tp.refresh():
return await self._single_request(
Expand All @@ -263,6 +319,7 @@ async def _single_request(
_retry_count=_retry_count + 1,
allow_cross_origin=allow_cross_origin,
accept=accept,
refresh_replay=refresh_replay,
)
raise error

Expand Down
69 changes: 62 additions & 7 deletions python/src/basecamp/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,21 @@ def _request_with_retry(
max_attempts = self._config.max_retries if self._config.max_retries > 0 else 1
max_attempts = self._apply_operation_retry_max(operation, max_attempts)
attempt = 0
refreshed_once = False
last_error: BasecampError | None = None

while True:
attempt += 1
if attempt > max_attempts:
break

# Each except suite only CLASSIFIES the attempt; the retry side
# effects run below, outside every handler. An exception raised
# inside an except suite bypasses that try's sibling handlers, so
# refreshing there would let a NetworkError from the token endpoint
# escape the loop with budget still on the table.
error: BasecampError | None = None
stale_auth: AuthError | None = None
try:
return self._single_request(
method,
Expand All @@ -182,19 +190,61 @@ def _request_with_retry(
attempt=attempt,
allow_cross_origin=allow_cross_origin,
accept=accept,
refresh_replay=False,
)
except (RateLimitError, NetworkError, ApiError) as e:
if not self._is_retryable_error(e, operation, retry_on=retry_on):
except AuthError as e:
# SPEC §4: the refresh replay is a request on the wire, so it
# spends an attempt from THIS budget rather than an uncounted
# one inside the single-request primitive. max_retries is a
# total attempt count (#461), and a cap of one means one
# request whatever would have caused the second.
#
# The budget is checked BEFORE refresh() so a rotation is never
# burned on an attempt the loop has no room to make.
if refreshed_once or e.http_status != 401 or attempt >= max_attempts:
raise
last_error = e
stale_auth = e
except (RateLimitError, NetworkError, ApiError) as e:
error = e

if stale_auth is not None:
# SPEC §4 tracks refresh with an "attempted" boolean, not a
# "succeeded" one, so mark it BEFORE invoking the provider: a
# refresh that throws still counts as the one attempt this
# request gets. Otherwise a transient token-endpoint failure
# lets the NEXT 401 in the same request call refresh() again —
# and if the first call reached the server and rotated the
# token before its response was lost, the second spends a
# refresh token that is already dead.
refreshed_once = True
try:
tp = getattr(self._auth, "token_provider", None)
refreshed = bool(tp and getattr(tp, "refreshable", False) and tp.refresh())
Comment thread
jeremy marked this conversation as resolved.
except (RateLimitError, NetworkError, ApiError) as e:
# A token endpoint that times out is a transient failure of
# this attempt, not a terminal auth fault: it retries under
# the same budget, exactly as it did when the replay lived
# inside _single_request.
error = e
else:
if not refreshed:
raise stale_auth
# No backoff: the token is fresh, and the server never asked
# us to wait. The replay still costs the attempt counted above.
continue

if error is not None:
if not self._is_retryable_error(error, operation, retry_on=retry_on):
raise error
last_error = error
if attempt >= max_attempts:
break
delay = self._calculate_delay(attempt, e.retry_after)
delay = self._calculate_delay(attempt, error.retry_after)
safe_hook(
self._hooks.on_retry,
RequestInfo(method=method, url=url, attempt=attempt),
attempt + 1,
e,
error,
delay,
)
time.sleep(delay)
Expand All @@ -218,6 +268,7 @@ def _single_request(
_retry_count: int = 0,
allow_cross_origin: bool = False,
accept: str | None = "application/json",
refresh_replay: bool = True,
) -> httpx.Response:
if not allow_cross_origin and not (
_security.is_localhost(url) or _security.same_origin(url, self._config.base_url)
Expand Down Expand Up @@ -247,8 +298,11 @@ def _single_request(

if response.status_code >= 400:
error = self._handle_error(response)
# 401 retry with token refresh
if isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
# 401 replay for callers that come here directly (mutations),
# which have no retry loop to own it. _request_with_retry
# passes refresh_replay=False and replays from the loop so the
# extra request draws from the attempt budget (SPEC §4).
if refresh_replay and isinstance(error, AuthError) and error.http_status == 401 and _retry_count < 1:
tp = getattr(self._auth, "token_provider", None)
if tp and getattr(tp, "refreshable", False) and tp.refresh():
return self._single_request(
Expand All @@ -263,6 +317,7 @@ def _single_request(
_retry_count=_retry_count + 1,
allow_cross_origin=allow_cross_origin,
accept=accept,
refresh_replay=refresh_replay,
)
raise error

Expand Down
Loading
Loading