Skip to content

Count the 401 refresh replay against the attempt budget, and amend SPEC §4 to match - #571

Merged
jeremy merged 3 commits into
mainfrom
fix/download-retry-remediation
Aug 1, 2026
Merged

jeremy merged 3 commits into
mainfrom
fix/download-retry-remediation

Conversation

@jeremy

@jeremy jeremy commented Aug 1, 2026 •

Copy link
Copy Markdown
Member

Closes #565.

max_retries: 0 documents exactly one attempt and sends two. A refreshable 401 puts a second request on the wire from inside Python's _single_request and Ruby's single_request, governed by its own _retry_count / retry_count rather than the caller's budget. SPEC §14 makes the same promise for the download hop — "disabling retry yields exactly one hop-1 attempt" — so the contradiction was written down in two places and shipping in both.

Why this is a decision, not a re-application

Counting the replay was tried once on #563 (b9662222d) and reverted (aca2c481b). The revert was right. On its own, counting regresses SPEC §4, which says refresh is attempted once per request, tracked "with a boolean (e.g. refresh_attempted) rather than a counter". Gated only by "have we already refreshed", max_retries: 1 refreshed the token and then rethrew the stale 401 without ever sending the refreshed request — for every GET in Python and Ruby, not just downloads. That is worse than the bug it fixed, and #565 was filed rather than smuggling the decision in.

The resolution needs both halves in the same change:

  1. The replayed wire request counts against the total-attempt budget, preserving the total-attempt semantics Align generated Go + Python retry loops to total-attempt semantics (A4b1) #461 settled (observed attempts 4→3).
  2. SPEC §4 is amended so the refresh is attempted once only when another attempt remains.

The second is what stops the first from regressing §4. With it, there is no state in which the SDK refreshes and then discards the result.

The SPEC §4 wording

Step 2 gains a clause, and the section gains three paragraphs:

  1. 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:

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).

§14 needs no change: its per-SDK budget table already reads "max_retries as total attempts, floored at one (max_retries: 0 still sends one attempt)". It is simply true now.

Scope: which SDKs actually have this

Checked all six rather than assuming.

SDK 401 replay in the transport Change
Python (sync + async) in _single_request, own counter fixed — replays from the retry loop
Ruby in single_request, own counter fixed — replays from the retry loop
Go main GET loop already counts it: a 401 with a successful refresh returns a retryable error that the loop re-issues as the next attempt, bounded by MaxRetries none
Go (mutations) doRequestURL's non-GET arm replays outside any budget none — mutations have no transient-retry budget to draw from; §7's documented divergence, and §4's new gate binds only where a budget governs
TypeScript / Kotlin / Swift none at all none

Direct single-request callers keep the in-primitive replay. Python's and Ruby's mutations bypass the retry loop entirely, so without a seam they would lose 401 refresh outright; a refresh_replay: parameter marks which side owns it. In Ruby that also stops handle_error from rotating credentials as a side effect of classifying an error — the refresh now happens where the budget is known.

Red proofs

Against merged main, with git show origin/main:<path> swapped in. Both the plain GET path and the download hop, because the every-GET case is exactly what the earlier revert protected — it is covered by a test here rather than by absence.

Python — uv run pytest tests/test_http.py -k RefreshReplay tests/test_download.py -k refresh_replay against pristine _http.py:

>       assert route.call_count == 1
E       AssertionError: assert 2 == 1
>       assert hop1.call_count == 1
E       AssertionError: assert 2 == 1
FAILED tests/test_http.py::TestRefreshReplayAttemptBudget::test_no_budget_means_one_request_and_no_refresh
FAILED tests/test_http.py::TestRefreshReplayAttemptBudget::test_replay_and_transient_retries_share_one_budget
FAILED tests/test_download.py::TestHop1Retry::test_refresh_replay_is_not_attempted_without_budget
3 failed, 7 passed, 101 deselected

Ruby — against pristine http.rb:

The request GET https://3.basecampapi.com/test.json was expected to execute 1 time but it executed 2 times
The request GET https://3.basecampapi.com/test.json was expected to execute 2 times but it executed 3 times
3 runs, 7 assertions, 2 failures, 0 errors, 0 skips

The request GET .../12345/attachments/abc/download/file.txt was expected to execute 1 time but it executed 2 times
3 runs, 9 assertions, 2 failures, 0 errors, 0 skips

test_401_refresh_replay_shares_the_budget_with_transient_retries — "expected 2, executed 3" — is the load-bearing one: at max_retries: 2 a 401 (attempt 1, refresh) followed by a 503 should exhaust the cap at two requests, and the replay used to ride outside it for three.

The remaining new tests pass against main by construction and pin the ceiling rather than the floor: that the refreshed request is actually sent (a 200 body, not a rethrown 401), that only one refresh happens per request, and that mutations keep their in-primitive replay.

Review round

Codex P2 — a refresh network failure escaped the retry loop. Real, and a regression this branch introduced: an exception raised inside an except suite is not offered to that try's sibling handlers (Ruby's rescue behaves the same), so calling refresh() from the AuthError handler put the token endpoint outside the loop's reach. A timing-out refresh ended the request with budget unspent, where before the move it landed in the transient handler and retried.

All three paths now use the directive shape the Swift and Kotlin download loops adopted in #517: the handlers only classify the attempt, and every side effect runs at the loop tail, outside any handler. A refresh that raises is a transient failure of that attempt and retries under the same budget; one that returns false surfaces the original 401; one that succeeds replays immediately with no backoff, since the token is fresh and the server never asked us to wait. Red-proofed in Python and Ruby against the previous commit — the NetworkError propagated raw out of client.get(...) with two attempts still available.

Verification

make check green end to end (REAL_EXIT=0, grep-checked — one run reported "All checks passed!" mid-stream while exiting 2 on a later ruff format step):

Suite Result
Go ok all packages
TypeScript 1051 tests passed
Python 792 passed
Ruby 1023 runs, 2348 assertions, 0 failures, 0 errors
Kotlin BUILD SUCCESSFUL
Swift 285 tests, 0 failures

Conformance: Go 135/0/2, Kotlin 136/0/1, TypeScript 157 passed / 2 skipped, Ruby 126/0/11, Python 137/0/0.

One flake seen locally and not reproducible: tests/client.test.ts > request timeout > propagates a caller-supplied signal through the combined signal failed once in a local make check and passed 3/3 on re-run. This branch changes no TypeScript files at all (git diff origin/main...HEAD -- typescript/ is empty), and the TypeScript CI job is green. Cited numbers are the CI job's.

One aside for whoever hits it next: make go-check first failed here with 557 phantom issues in ../../validation-errors/go/pkg/generated/client.gen.go — a golangci-lint cache shared across worktrees. golangci-lint cache clean cleared it; 0 issues after.

…EC §4 to match

`max_retries: 0` documented one attempt and sent two. A refreshable 401 put a
second request on the wire from inside the single-request primitive, governed
by its own counter rather than the caller's budget — so the promise §14 makes
for downloads ("disabling retry yields exactly ONE hop-1 attempt") was false in
Python and Ruby, and the total-attempt semantics #461 settled leaked.

Counting the replay was tried once on #563 and reverted, correctly: on its own
it regresses SPEC §4, which says refresh is attempted once PER REQUEST, tracked
"with a boolean rather than a counter". Gated only by "have we already
refreshed", `max_retries: 1` refreshed the token and then rethrew the stale 401
without ever sending the refreshed request — for every GET, not just downloads.
That was worse than the bug.

So this lands both halves together, which is what makes it a decision rather
than a drift. The replay counts against the budget, AND §4 is amended to
attempt the refresh only when another attempt remains. The gate is checked
BEFORE refresh() rather than after: rotating a token the SDK has no budget left
to use burns it for nothing and hands the caller the stale 401 anyway, so
declining to refresh is both cheaper and easier to state. This supersedes §4's
unqualified per-request reading and closes #565.

The consequence is stated plainly in §4 rather than left to be discovered: with
a budget of one attempt, a refreshable 401 is NOT replayed and surfaces as
auth_required. Callers who want the replay must leave an attempt for it.

Scope is Python (sync and async) and Ruby — the two SDKs whose transports carry
a 401 replay under a governed budget. Go's main GET loop already counts it: a
401 with a successful refresh returns a retryable error that the loop re-issues
as the next attempt, subject to MaxRetries. Its hand-written mutation path
replays outside any budget, but mutations have no transient-retry budget to
draw from — that is the documented divergence in §7, and §4's new gate says
explicitly that it binds where a total-attempt budget governs the path.
TypeScript, Kotlin and Swift have no 401 replay in the transport at all.

Direct single-request callers keep the in-primitive replay. Python's and Ruby's
mutations bypass the retry loop entirely, so without the seam they would lose
401 refresh outright; `refresh_replay:` marks which side owns it. In Ruby that
also stops handle_error from rotating credentials as a side effect of
classifying an error — the refresh now happens where the budget is known.

Red-proofed against merged main on the plain GET path as well as the download
hop, since the every-GET case is exactly what the earlier revert protected:

    tests/test_http.py::TestRefreshReplayAttemptBudget::test_no_budget_means_one_request_and_no_refresh
    E       AssertionError: assert 2 == 1
    tests/test_http.py::TestRefreshReplayAttemptBudget::test_replay_and_transient_retries_share_one_budget
    tests/test_download.py::TestHop1Retry::test_refresh_replay_is_not_attempted_without_budget

and in Ruby:

    The request GET https://3.basecampapi.com/test.json was expected to execute 1 time but it executed 2 times
    The request GET https://3.basecampapi.com/test.json was expected to execute 2 times but it executed 3 times
    The request GET .../download/file.txt was expected to execute 1 time but it executed 2 times

The middle one is the load-bearing case: at `max_retries: 2` the replay used to
ride outside the cap for a total of three requests. Positive tests pin that the
refreshed request is actually SENT rather than refreshed-then-discarded, and
that mutations keep their replay.
Copilot AI review requested due to automatic review settings August 1, 2026 07:19
@jeremy jeremy added bug Something isn't working python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK labels Aug 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2a2aed778

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread python/src/basecamp/_http.py Outdated
…int still retries

An exception raised inside an `except` suite is not offered to that `try`'s
sibling handlers — same in Ruby for `rescue`. Calling refresh() from inside the
AuthError handler therefore put the token endpoint outside the retry loop's
reach: a NetworkError from a timing-out refresh escaped the whole loop and
ended the request with budget still unspent. Before this branch the replay
lived in the single-request primitive, where that failure landed in the
transient handler and retried, so this was a regression introduced by moving
it.

Both loops now follow the shape the Swift and Kotlin download loops already use
(#517): the handlers only CLASSIFY the attempt, and every side effect runs at
the loop tail, outside any handler. A refresh that raises is classified as a
transient failure of this attempt and retries under the same budget; a refresh
that returns false surfaces the original 401; a refresh that succeeds replays
immediately with no backoff, since the token is fresh and the server never
asked us to wait.

Red-proofed against the previous commit in both languages — the refresh's
NetworkError propagated raw out of `client.get(...)` rather than being retried:

    E       basecamp.errors.NetworkError: token endpoint timed out
    FAILED tests/test_http.py::TestRefreshReplayAttemptBudget::test_refresh_network_failure_still_retries_under_the_budget
Copilot AI review requested due to automatic review settings August 1, 2026 07:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d44c0f59f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread python/src/basecamp/_http.py
SPEC §4 tracks refresh with an "attempted" boolean — the algorithm's own
condition is "refresh has not yet been attempted for this request" — but the
flag was set only after a refresh returned true. A refresh that RAISES left it
false, so the next 401 in the same request called the provider again.

Reachable as soon as the token endpoint is flaky: attempt 1 401s, refresh
throws a NetworkError, the attempt retries under the budget, attempt 2 401s
with the same unchanged token, and refresh fires a second time. Beyond
violating the at-most-once rule, that is unsafe with a rotating refresh token —
if the first call reached the server and rotated before its response was lost,
the second spends a credential that is already dead.

All three paths now mark the attempt before invoking the provider. Red-proofed
against the previous commit, with a provider that always throws and a server
that always 401s:

    >       assert provider.refreshes == 1
    E       assert 2 == 1

The existing network-failure tests missed this because their second request
returns 200, so no second 401 ever arrives to trigger the second refresh.
Copilot AI review requested due to automatic review settings August 1, 2026 07:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Merging on green CI with a reviewer status note, per the repo's documented fallback when the bots do not deliver at the final head.

Convergence check at db7d32444 (fail-closed, cursor-paginating, count taken from totalCount rather than the returned node count):

head=db7d32444d8f1f26ba5501a1f420fb66399b4ccc
total=2 collected=2 unresolved=0 unresolved_current=0

Both threads are Codex P2 findings, both real, both fixed and answered with red proofs — the except-suite control-flow regression (5d44c0f59) and the refresh-attempt-vs-success flag (db7d32444). Review bodies audited for <details>Comments suppressed due to low confidence</details>: none present.

Reviewer coverage. Copilot errored on all three rounds ("Copilot encountered an error and was unable to review this pull request"). Codex reviewed c2a2aed778 and 5d44c0f59f and found both defects, but has not posted against the final head db7d32444 after ~20 minutes. That last commit is the narrowest of the three — it moves one assignment above the try in three places and adds two tests — and it exists because of Codex's second finding.

Verification at the merged head. make check green end to end, REAL_EXIT=0 grep-checked from the log rather than trusted from a mid-run "All checks passed!" line:

Suite Result
Go ok all packages
TypeScript 1070 passed
Python 792 passed
Ruby 1024 runs, 2351 assertions, 0 failures, 0 errors
Kotlin BUILD SUCCESSFUL
Swift 285 tests, 0 failures

Conformance: Go 135/0/2, Kotlin 136/0/1, TypeScript 157 passed / 2 skipped, Ruby 126/0/11, Python 137/0/0. CI is CLEAN on every required check.

@jeremy
jeremy merged commit 573ebec into main Aug 1, 2026
42 of 43 checks passed
@jeremy
jeremy deleted the fix/download-retry-remediation branch August 1, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A 401 token-refresh replay is not counted against the attempt cap

2 participants