Python (_http.py / _async_http.py) and Ruby (http.rb) replay a request once after a 401 when the token provider is refreshable. The replay lives inside _single_request / single_request and is governed by its own _retry_count / retry_count counter, entirely separate from the caller-facing attempt budget.
Consequence: with max_retries: 0 — which every SDK documents as "exactly one attempt" — a 401 whose refresh succeeds still puts two requests on the wire. SPEC §14 makes the same promise for downloads ("disabling retry yields exactly ONE hop-1 attempt"), so the tension is now written down in two places.
This predates #563. The replay is in the single-request primitive, which mains get_no_retry already called, so routing the download hop through the retry loop did not change it — surfaced by review on #563 (python/src/basecamp/_async_http.py).
The real question is definitional, and worth settling before patching: is a credential-refresh replay a retry? Two defensible readings.
- It is. Then it must draw from the same budget, and
max_retries: 0 means one request no matter what — refresh included. Simple to state, simple to test, and makes the §14 sentence literally true.
- It is not. A refresh replay is re-authentication, not a retry of a failure: the first request never reached a state the caller would call an attempt. Then the cap governs retries only, and the docs plus §14 should say "one hop-1 retry attempt" rather than implying a request count.
Whichever we pick, the fix spans Python and Ruby (Go, TypeScript, Kotlin and Swift have no 401-refresh replay in the transport at all — which is its own parity question worth answering in the same pass).
Python (
_http.py/_async_http.py) and Ruby (http.rb) replay a request once after a 401 when the token provider is refreshable. The replay lives inside_single_request/single_requestand is governed by its own_retry_count/retry_countcounter, entirely separate from the caller-facing attempt budget.Consequence: with
max_retries: 0— which every SDK documents as "exactly one attempt" — a 401 whose refresh succeeds still puts two requests on the wire. SPEC §14 makes the same promise for downloads ("disabling retry yields exactly ONE hop-1 attempt"), so the tension is now written down in two places.This predates #563. The replay is in the single-request primitive, which
mainsget_no_retryalready called, so routing the download hop through the retry loop did not change it — surfaced by review on #563 (python/src/basecamp/_async_http.py).The real question is definitional, and worth settling before patching: is a credential-refresh replay a retry? Two defensible readings.
max_retries: 0means one request no matter what — refresh included. Simple to state, simple to test, and makes the §14 sentence literally true.Whichever we pick, the fix spans Python and Ruby (Go, TypeScript, Kotlin and Swift have no 401-refresh replay in the transport at all — which is its own parity question worth answering in the same pass).