Python README: state the retry contract the code actually implements - #502
Conversation
The Retry Behavior section still described the pre-#486 contract. Three claims were wrong or incomplete, all the same failure: the contract changed and the README did not follow. "Retried on RateLimitError (429), NetworkError, and retryable ApiError (500, 502, 503, 504)" — 500, 502 and 504 have not been retried since #486. The gate is the operation's declared retry_on set, which every one of the 226 operations declares as [429, 503], and it is exhaustive in both directions. A reader planning around this would have expected three retries on a 500 and got one. "Max retries - Controlled by config.max_retries" — #483 made the effective budget min(config.max_retries, the operation's declared max). This is not hypothetical: 43 of 226 operations declare max 2 against a default cap of 3, so they retry once fewer than the README promised. The error table itself was accurate — 500 really is ApiError(retryable=True) — but it now reads as a retry prediction when it is only a taxonomy. That gap is what makes the first bullet's error believable, so `retryable` now says what it is: a hint for your code, not a statement about the transport. SPEC.md:432 already drew this line; the README hadn't. Verified rather than asserted, after a first probe passed a Response where the signature wants an int status and wrongly reported 500 as non-retryable — which would have turned correct documentation into incorrect documentation. Corrected: 429 -> RateLimitError, 500/502/503/504 -> ApiError, all retryable=True, 418 False. Metadata confirms 226/226 declare [429, 503] and max is 3 (183 ops) or 2 (43). Behavior is pinned by TestDeclaredRetryStatuses ([500-1], [502-1], [504-1], [429-3], [503-3]) and TestPerOperationRetryMax (CapTwoOp-5-2). make py-check: 508 passed.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d7cc526306
ℹ️ 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".
There was a problem hiding this comment.
Pull request overview
This PR updates the Python SDK README’s “Retry Behavior” section to match the retry contract the Python transport actually enforces (operation-declared retry_on + per-operation retry ceiling), and clarifies that BasecampError.retryable is an error taxonomy hint rather than a transport guarantee.
Changes:
- Clarify that retried HTTP statuses are governed by the operation’s declared
retry_onset (and that 500/502/504 are not retried for governed operations). - Clarify that the effective attempt budget is
min(config.max_retries, operation_declared_max). - Clarify that
retryableis informational for callers and does not predict whether the SDK will retry.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Comments suppressed due to low confidence (2)
python/README.md:453
- The “declared set is exhaustive” statement is accurate for governed API operations, but the SDK also issues requests without operation metadata (e.g., AuthorizationService / get_absolute) where status-based retries fall back to the error’s
retryableclassification. As written, this can read as a global promise for all SDK requests.
- **Which statuses** - Only the statuses an operation declares retryable. Every operation declares `429, 503`, and the declared set is exhaustive: a status outside it — **including 500, 502, and 504** — is surfaced to you on the first attempt rather than retried
- **GET requests** - Retried on the declared statuses above, plus `NetworkError`, which carries no HTTP status and so is governed by its error classification instead
python/README.md:459
- Per-operation retry maxima are only available when an operation id is provided; requests without operation metadata (e.g., AuthorizationService / get_absolute) are not bounded by an operation-declared maximum. The README line currently reads like the
min()ceiling applies universally.
- **Max retries** - `min(config.max_retries, the operation's declared maximum)`. `config.max_retries` is a total attempt count including the initial request (default: 3 attempts; `0` means a single request with no retry). An operation declaring a lower maximum wins — the declared value can only lower the cap, never raise it
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Both reviewers caught the same real error, and they were right. The claim that a 500 "is not retried" was categorical, but it only holds for governed operations. client.authorization.get() reaches the Launchpad endpoint through get_absolute(), which passes no operation id, so _is_retryable_error falls back to the error classification and a 500 does retry there. That is a public flow, not internal plumbing, so the previous wording misdocumented it. Measured both paths rather than reasoning about them: UNGOVERNED launchpad 500 -> 3 attempts GOVERNED GetProjects 500 -> 1 attempt GOVERNED GetProjects 429 -> 3 attempts Scoped the status and ceiling rules to Basecamp API operations, and added a bullet for the authorization endpoint stating what it actually does. The `retryable` note is scoped the same way, since its example is the 500 case. I knew about this carve-out — it is commented in _http.py and specified at SPEC.md:454 — and judged it too internal for a consumer README. That was the wrong call: `client.authorization` is public API.
The Python README's Retry Behavior section still described the pre-#486 contract. Three claims were wrong or incomplete — all the same failure, the contract changed and the README didn't follow.
What was wrong
The status set. "Retried on
RateLimitError(429),NetworkError, and retryableApiError(500, 502, 503, 504)" — 500, 502 and 504 have not been retried since #486. The gate is the operation's declaredretry_onset, and it is exhaustive in both directions. A reader planning around this expected three attempts on a 500 and got one.The attempt budget. "Controlled by
config.max_retries" — #483 made itmin(config.max_retries, the operation's declared max). Not hypothetical: 43 of 226 operations declare max 2 against a default cap of 3, so they retry once fewer than the README promised.The error table read as a retry prediction. It was accurate — 500 really is
ApiError(retryable=True)— but nothing said thatretryableis a taxonomy hint rather than a statement about the transport. That gap is what makes the first bullet's error believable.SPEC.md:432already draws this line; the README hadn't.Verification
Everything here is measured, not read.
[429, 503]generated/metadata.json— one distinct setRateLimitError, 500/502/503/504 →ApiError, allretryable=True; 418Falseerror_from_responseTestDeclaredRetryStatuses[500-1] [502-1] [504-1] [429-3] [503-3]TestPerOperationRetryMaxCapTwoOp-5-2make py-check— 508 passed;make check-retry-metadata-paritygreenOne process note worth recording. My first probe passed an
httpx.Responsewhereerror_from_responsewants anintstatus. It fell through to theelsebranch and reported 500 asretryable=False— which, taken at face value, would have had me "correct" an accurate table into an inaccurate one. Re-run against the real signature it gives the opposite answer. The table was right; the probe was wrong.Scope
Documentation only — no code change, and no change to the error taxonomy itself, which is correct as it stands. This is the last of the three defects found while auditing #486's nine unresolved review threads; the other two shipped in #500.
Summary by cubic
Update the Python README to match the SDK’s actual retry behavior and scope the rules to Basecamp API operations. Clarifies which statuses retry, how the retry cap works, and that
client.authorization.get()follows error classification.min(config.max_retries, operation's declared maximum); the declared max can only lower the cap.retryableflag: A classification hint for your code, not a retry promise (e.g., 500 isretryable=Truebut not retried for API operations).client.authorization.get()is not a Basecamp API operation; it retries whateverretryablereports — including 500/502/504 — bounded only byconfig.max_retries.Written for commit 8985a28. Summary will update on new commits.