Skip to content

Retry docs: correct the mutation and 401 claims, add Python to the roster - #484

Merged
jeremy merged 1 commit into
mainfrom
docs/retry-truth-up
Jul 28, 2026
Merged

Retry docs: correct the mutation and 401 claims, add Python to the roster#484
jeremy merged 1 commit into
mainfrom
docs/retry-truth-up

Conversation

@jeremy

@jeremy jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Documentation only — no behavior change. Every claim below was re-derived from source at d5604962.

SECURITY.md — two false bullets

"Mutations (POST/PUT/DELETE): NOT automatically retried on 429/503". They are. behavior-model.json flags 69 operations idempotent: true — all 45 PUTs, all 21 DELETEs, and 3 POSTs (CompleteTodo, PauseQuestion, SubscribeToCardColumn) — and those retry in Go's generated operation path, Python, TypeScript, Kotlin, and Swift. Ruby is the only transport that is GET-only (http.rb routes every non-GET to single_request), as is Go's separate hand-written pkg/basecamp helper. The bullet dates to #60, before the generated client existed.

The 39 non-idempotent POSTs are single-attempt everywhere, which is the property the bullet was reaching for — so the replacement states the gate as idempotency rather than "is it a mutation".

"401 responses: Token refresh attempted, then single retry for all methods". True in three transports, false in four:

SDK 401 behavior
Go — hand-written pkg/basecamp Refresh via AuthManager, then one retry (client.go:794-800)
Go — generated pkg/generated No reactive refresh. AuthTransport.RoundTrip fetches a token proactively (client.gen.go:19851); ParseHTTPError turns 401 into AuthError (:20237)
Ruby Refresh, then one retry (http.rb:417)
Python (sync and async) Refresh, then one retry (_http.py:237, _async_http.py:237)
TypeScript / Kotlin / Swift No refresh; the 401 reaches the caller

Swift's HTTPClient.swift:157 re-runs the auth strategy before each retry, but 401 is not a retryable status, so that path never fires for a 401.

SPEC.md — Python was missing, Ruby was miscategorized

  • :11 "Five shipping SDKs" and :38 "consensus of Go, Ruby, TypeScript, Kotlin, Swift" both omitted Python. Six now.
  • :401 named "Go, Ruby, and Swift" as retrying network errors. Python belongs there (NetworkError is retryable=True and the loop keys off it). Ruby does not belong in the same group — it retries network errors only on GET, because request sends every non-GET to single_request. Listing it alongside Go/Swift implied a parity it does not have.
  • Appendix F had no Python row.

New: Gate 3 fidelity note

Gates 1 and 2 read behavior-model.json. Gate 3's parameters do not, in two SDKs — Go and Python substitute a client-global attempt count for the per-operation retry.max and treat a superset of retry_on as retryable. Go's OperationMetadata carries no attempt count at all. That is now stated in §7 and footnoted under Appendix F, and filed as #479 with the fix design.

Also filed #480: Subscribe and EnableCardColumnOnHold are under-classified as non-idempotent (Subscribe calls the identical Recording::Subscribable#subscribe that the flagged SubscribeToCardColumn calls; EnableCardColumnOnHold's create is unless @on_hold-guarded and its DELETE sibling is already flagged). Both verified against BC3 2f01d0da.

Deliberately left alone

  • SPEC.md:400"Implementations may expand this set to include other 5xx statuses" sanctions the Go/Python behavior. It should go, but only together with the Retry metadata fidelity: Go and Python ignore per-operation retry_on and max_attempts #479 fix; removing it now would make the spec false in the other direction.
  • SPEC.md:407 / :411 / :1591 and waiver 2B.1 — the TypeScript one-retry cap is current, not stale: client.ts:1007 returns native fetch(retryRequest), bypassing middleware, so the X-Retry-Attempt header it sets is never re-consumed. conformance/runner/typescript/runner.test.ts:73-78 pins it.

make check passes.


Summary by cubic

Updates retry docs to match reality: idempotent mutations do retry on 429/503, 401 handling varies by SDK, and Python is now included. Also clarifies network-error retry behavior, per-transport 401 differences, Ruby’s raw-upload exception, and Gate 3 fidelity.

  • Bug Fixes

    • SECURITY.md: Replace “mutations never retry” with idempotency rules; Ruby and Go’s hand-written helper are GET-only; respect Retry-After; non-idempotent POSTs never retry on 429/503 or network errors.
    • SECURITY.md: Add per-SDK 401 matrix — reactive refresh only in hand-written Go, Ruby, and Python; generated Go *WithResponse fills JSON401 and returns no error; TypeScript service wrappers throw on 401 but the raw client returns { data, error } without throwing; Kotlin/Swift surface 401; Ruby raw uploads (post_raw/put_raw) do not replay.
  • Spec Updates

    • Add Python across the spec and Appendix F; update “five” to “six”; fix network-error grouping: Go/Python/Swift retry; Ruby GET-only; TypeScript retries only after a response; Kotlin doesn’t retry network errors; clarify OAuth discovery SSRF note (Swift out of scope).
    • Gate 3 fidelity: Go/Ruby retry a superset of retry_on; Python has no status gate (uses a retryable flag); TypeScript/Kotlin/Swift use the declared set. Ruby ignores per-operation retry ceilings; others honor them.

Written for commit 8af46a1. Summary will update on new commits.

Review in cubic

Copilot AI review requested due to automatic review settings July 28, 2026 06:32
@jeremy jeremy added the documentation Improvements or additions to documentation label Jul 28, 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.

Pull request overview

This PR updates retry-related documentation to match current SDK behavior (no runtime changes), correcting prior claims about mutation retries and 401 refresh behavior and adding Python to the set of “shipping SDKs” referenced by the spec.

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.

Changes:

  • Update SPEC.md to include Python among shipping SDK exemplars and to document Gate 3 retry-metadata fidelity drift for Go/Python.
  • Update SECURITY.md retry and 401 sections to reflect per-operation idempotency gating and per-SDK 401 refresh differences.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
SPEC.md Adds Python to the “shipping SDKs” references and documents known retry-metadata fidelity divergences (Gate 3).
SECURITY.md Rewrites retry/401 behavior documentation to align with actual per-SDK behavior and idempotency-based retry eligibility.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread SPEC.md Outdated
2. **Shipping SDK code** (consensus of Go, Ruby, TypeScript, Kotlin, Swift) — implementation truth. When 4+ SDKs agree, that's the contract.
2. **Shipping SDK code** (consensus of Go, Ruby, Python, TypeScript, Kotlin, Swift) — implementation truth. When 4+ SDKs agree, that's the contract.
3. **`behavior-model.json`** — machine-readable metadata. Descriptive of retry/idempotency semantics, but the retry block alone does not activate retry for POST (see §7).
4. **`rubric-audit.json`** — audit snapshot. Known to drift (e.g., 3C.3 claims 1024 chars; all 5 SDKs use 500). Trust code over audit.
Comment thread SECURITY.md Outdated
`SubscribeToCardColumn`). The other 39 POSTs are attempted exactly once. SPEC.md §7 specifies the
three-gate algorithm and the per-SDK divergences.

- **Reads (GET/HEAD)**: retried with exponential backoff on 429/503 in every SDK.

@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: d2ea6f6d6e

ℹ️ 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 SECURITY.md Outdated
duplicate a resource, which is why the gate is idempotency rather than "is it a mutation".
**Ruby is the sole exception** — its transport retries GET only.
Go's separate hand-written `pkg/basecamp` HTTP helper is also GET-only.
- **Non-idempotent POSTs**: never retried, in any SDK — a retry could create a duplicate resource.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify the single-attempt POST claim

When a non-idempotent POST receives a 401 and a refreshable token provider successfully refreshes, Ruby and both Python transports replay the request once regardless of operation idempotency (ruby/lib/basecamp/http.rb:416-420, python/src/basecamp/_http.py:237-251, and the async equivalent), as the table immediately below acknowledges. Therefore these POSTs are not universally “never retried” or always attempted exactly once; scope this claim to retries after 429/503 or network failures.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="SPEC.md">

<violation number="1" location="SPEC.md:405">
P1: Claim in Gate 3 fidelity note about Go's `isRetryableStatus` covering `{429, 500, 502, 503, 504}` needs verification against the actual generated code in `client.gen.go`. The hand-written `checkResponse` in `helpers.go` uses `resp.StatusCode >= 500 && resp.StatusCode < 600` (all 5xx, not just 500/502/503/504), so the doc's narrower set may be incorrect if `isRetryableStatus` diverges from the hand-written path.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread SPEC.md Outdated

**Non-retryable statuses (never retry regardless of method):** 401, 403, 404, 400, 422.

**Gate 3 metadata fidelity `[CONFLICT]`.** Gates 1 and 2 read `behavior-model.json`; Gate 3's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Claim in Gate 3 fidelity note about Go's isRetryableStatus covering {429, 500, 502, 503, 504} needs verification against the actual generated code in client.gen.go. The hand-written checkResponse in helpers.go uses resp.StatusCode >= 500 && resp.StatusCode < 600 (all 5xx, not just 500/502/503/504), so the doc's narrower set may be incorrect if isRetryableStatus diverges from the hand-written path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At SPEC.md, line 405:

<comment>Claim in Gate 3 fidelity note about Go's `isRetryableStatus` covering `{429, 500, 502, 503, 504}` needs verification against the actual generated code in `client.gen.go`. The hand-written `checkResponse` in `helpers.go` uses `resp.StatusCode >= 500 && resp.StatusCode < 600` (all 5xx, not just 500/502/503/504), so the doc's narrower set may be incorrect if `isRetryableStatus` diverges from the hand-written path.</comment>

<file context>
@@ -398,10 +398,19 @@ If `behavior-model.json` marks an operation with `idempotent: true`, the POST be
 
 **Non-retryable statuses (never retry regardless of method):** 401, 403, 404, 400, 422.
 
+**Gate 3 metadata fidelity `[CONFLICT]`.** Gates 1 and 2 read `behavior-model.json`; Gate 3's
+*parameters* are not uniformly sourced from it. **Go and Python ignore the per-operation `retry`
+block entirely**: both substitute a client-global attempt count for `retry.max` (Go
</file context>

Comment thread SECURITY.md Outdated
Comment thread SECURITY.md Outdated
Comment thread SPEC.md

@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: 3edf1b37cc

ℹ️ 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 SECURITY.md Outdated
| SDK | 401 behavior |
|-----|--------------|
| Go — hand-written `pkg/basecamp` | Refresh via `AuthManager`, then a single retry (`client.go`) |
| Go — generated `pkg/generated` operations | **No reactive refresh.** `AuthTransport` obtains a token proactively per request via `TokenProvider.AccessToken`; a 401 is returned as `AuthError` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Describe generated Go 401s as response values

When a generated *WithResponse operation receives a well-formed JSON 401, it does not return an AuthError: for example, GetAccountWithResponse delegates to ParseGetAccountResponse, which populates response.JSON401 and returns response, nil (go/pkg/generated/client.gen.go:29549-29554,32195-32218). Callers following this table may therefore check only err and mistakenly treat the request as successful; document the typed 401 response behavior instead.

Useful? React with 👍 / 👎.

Comment thread SPEC.md Outdated
| Swift | Three-gate: retries when the method is naturally idempotent (GET/HEAD/PUT/DELETE) or the operation is marked `idempotent: true`; non-idempotent POSTs make a single attempt. Gate covers both HTTP status and network-error retries, so Swift *does* retry network errors (unlike Kotlin/TS), gated by idempotency. |

The table above describes **Gate 1 and Gate 2** — *whether* an operation retries. Gate 3's parameters
are tracked separately: the per-operation attempt ceiling is honored in every SDK, but the declared

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude Ruby from the per-operation ceiling claim

When a Ruby caller raises max_retries above 3 and a GET repeatedly receives a retryable error, request_with_retry loops until @config.max_retries (ruby/lib/basecamp/http.rb:354-370) without consulting operation metadata, even though the current model caps retryable GETs at 3. Thus the per-operation ceiling is not honored in every SDK—consistent with the earlier statement that Ruby's per-op fields are inert—so this should explicitly describe the Ruby divergence rather than promise a universal ceiling.

Useful? React with 👍 / 👎.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

SECURITY.md:209

  • The Go paths in this table should be go/pkg/... (not pkg/...). Also, the generated client returns a structured APIError with code auth_required on 401 (there is no AuthError type in go/pkg/generated).
| Go — hand-written `pkg/basecamp` | Refresh via `AuthManager`, then a single retry (`client.go`) |
| Go — generated `pkg/generated` operations | **No reactive refresh.** `AuthTransport` obtains a token proactively per request via `TokenProvider.AccessToken`; a 401 is returned as `AuthError` |

SPEC.md:415

  • errors.rb is referenced here, but the Ruby retryable-status logic lives in ruby/lib/basecamp/http.rb (see handle_error, which marks 500/502/503/504 as retryable: true). This makes the citation misleading/non-actionable for readers.
Gate 3's *status set* is not. **Go and Ruby retry statuses the spec never declared retryable**: Go's
`isRetryableStatus` covers `{429, 500, 502, 503, 504}` regardless of the operation's declared
`retry_on`, and Ruby's loop keys off `retryable?`, which `errors.rb` sets for 500/502/503/504.
**Python** has no status gate at all — its loop keys off the `retryable` flag `errors.py` sets for

SECURITY.md:197

  • The referenced Go package paths don't exist in this repo (they live under go/pkg/...). Using the correct paths makes it easier for readers to verify the claims.

This issue also appears on line 208 of the same file.

- **Naturally-idempotent mutations (PUT/DELETE) and the 3 flagged POSTs**: *are* retried on 429/503
  by Go (generated operation path), Python, TypeScript, Kotlin, and Swift. Retrying these cannot
  duplicate a resource, which is why the gate is idempotency rather than "is it a mutation".
  **Ruby is the sole exception** — its transport retries GET only.
  Go's separate hand-written `pkg/basecamp` HTTP helper is also GET-only.
- **Non-idempotent POSTs**: never retried, in any SDK — a retry could create a duplicate resource.

@jeremy
jeremy force-pushed the feat/perop-retry-max-go-python branch from 7a4c329 to aee8bd9 Compare July 28, 2026 07:52
Copilot AI review requested due to automatic review settings July 28, 2026 19:57
@jeremy
jeremy force-pushed the docs/retry-truth-up branch from 3edf1b3 to acdb157 Compare July 28, 2026 19:57
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current #483 (aee8bd98 + the rationale fix). It was based on the superseded 7a4c3297, which is why it went CONFLICTING and exposed 55 files. Back to 2 files, SECURITY.md and SPEC.md.

All six review findings applied — each verified against source first:

  1. SPEC.md:40 "all 5 SDKs use 500" — correct, and it wasn't the only one. Fixed line 40, the matching [CONFLICT] note at :658, the SSRF heading, and six rows of the constants table. Python does share every one of them (errors.py:186 _truncate(max_bytes=500), and config.py:21-25 for the defaults), so "six" is accurate rather than just arithmetic.

  2. GET/HEAD is not uniform — correct. Ruby gates on method == :get specifically, so a HEAD would not retry there. Narrowed to GET, with the divergence noted and the fact that there are no HEAD operations in the surface today.

  3. The single-attempt POST claim needed qualifying — correct, and my own table two paragraphs below already contradicted it. Now scoped: never retried on 429/503 or network failure, with an explicit pointer to the 401-refresh replay in Ruby and both Python transports.

  4. Generated Go 401s are typed responses — correct and the sharper of the two Go findings. *WithResponse populates response.JSON401 and returns (response, nil); a caller checking only err reads a 401 as success. The table now says that, and notes ParseHTTPError is the helper that maps 401 → AuthError.

  5. Ruby doesn't honor the per-operation ceiling — correct. The Appendix F footnote promised a universal ceiling. It now names the three tiers: Go and Python honor it and let a caller ask for fewer; TypeScript/Swift/Kotlin honor it but override a lower caller cap where one exists; Ruby never consults it.

  6. The cubic P1 on isRetryableStatus is wrong — I checked client.gen.go at this base rather than reasoning about it. isRetryableStatus really is {429, 500, 502, 503, 504} (client.gen.go:3964-3974). The comparison to checkResponse's >= 500 && < 600 in helpers.go is a different function on the hand-written path; it does not govern generated-operation retry. The doc's narrower set is accurate, so no change. Retry: honor the declared retry_on and max_attempts in Go and Python #486 is what replaces that global set with the per-operation declared one.

@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: acdb157155

ℹ️ 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 SECURITY.md Outdated
duplicate a resource, which is why the gate is idempotency rather than "is it a mutation".
**Ruby is the sole exception** — its transport retries GET only.
Go's separate hand-written `pkg/basecamp` HTTP helper is also GET-only.
- **Non-idempotent POSTs**: never retried on 429/503 or network failure, in any SDK — a retry could create a duplicate resource. This does **not** mean such a POST is always attempted exactly once: a 401 that triggers a successful token refresh replays the request once regardless of idempotency, in Ruby and both Python transports (see the 401 table below).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify Ruby 401 replay for raw uploads

When a Ruby attachment or campfire upload receives a 401, the generated methods call http_post_raw, which delegates to single_request_raw (ruby/lib/basecamp/http.rb:145-147); unlike single_request, that raw path raises the mapped error directly without the refresh-and-replay branch (http.rb:461-471). These non-idempotent POSTs therefore are not replayed after refresh, so qualify both this sentence and the blanket Ruby row in the 401 table to exclude raw upload requests.

Useful? React with 👍 / 👎.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

SECURITY.md:190

  • This paragraph says the 39 non-idempotent POST operations are "attempted exactly once", but later bullets note some SDKs replay a request once after a successful 401 refresh. To avoid a contradiction, describe them as non-retryable on 429/503/network errors rather than always single-attempt.
Retry eligibility is decided per *operation*, not per HTTP method. `behavior-model.json` classifies
all 226 operations: the 118 GETs are retryable by method, and 69 mutations are flagged
`idempotent: true` — all 45 PUTs, all 21 DELETEs, and 3 POSTs (`CompleteTodo`, `PauseQuestion`,
`SubscribeToCardColumn`). The other 39 POSTs are attempted exactly once. SPEC.md §7 specifies the
three-gate algorithm and the per-SDK divergences.

SPEC.md:412

  • This sentence claims the per-operation attempt ceiling is "honored everywhere", but earlier in §2 the spec notes Ruby does not enforce per-op retry.max at all, and Kotlin treats per-op maxRetries as an override (can exceed a lower client cap). The Gate 3 note should be consistent with those documented divergences.
**Gate 3 status-set fidelity `[CONFLICT]`.** Gates 1 and 2 read `behavior-model.json`, and the
per-operation attempt ceiling is now honored everywhere (see *Per-operation retry ceiling* in §2).

SPEC.md:1615

  • Appendix F repeats that the per-operation attempt ceiling is honored in every SDK, which conflicts with the earlier §2 discussion (Ruby ignores per-op retry.max; Kotlin treats it as an override rather than a ceiling). Align this summary sentence with the detailed behavior described earlier.
The table above describes **Gate 1 and Gate 2** — *whether* an operation retries. Gate 3's parameters
are tracked separately: the per-operation attempt ceiling is honored in every SDK, but the declared
`retry_on` set is not — Go, Python, and Ruby all retry a superset of it. See the Gate 3 status-set
note at §7 above.

Base automatically changed from feat/perop-retry-max-go-python to main July 28, 2026 20:19
@jeremy
jeremy force-pushed the docs/retry-truth-up branch from acdb157 to 4908d52 Compare July 28, 2026 20:24
@jeremy
jeremy requested a review from Copilot July 28, 2026 20:25

@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: 4908d52a67

ℹ️ 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 SECURITY.md Outdated
| Go — generated `pkg/generated` operations | **No reactive refresh.** `AuthTransport` obtains a token proactively per request via `TokenProvider.AccessToken`. A 401 is **not** returned as an error by the `*WithResponse` variants: they populate `response.JSON401` and return `(response, nil)`, so a caller checking only `err` would read the request as successful. Check `JSON401` (or the status code) explicitly. The `ParseHTTPError` helper is what maps a 401 to `AuthError` |
| Ruby | Refresh, then a single retry (`http.rb`) |
| Python (sync **and** async) | Refresh, then a single retry (`_http.py`, `_async_http.py`) |
| TypeScript | No refresh; the 401 is raised to the caller |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify TypeScript 401 handling for raw client calls

When consumers use the public raw API (BasecampClient extends RawClient), a 401 is not raised: client.GET(...) resolves with { data: undefined, error }, as asserted in typescript/tests/client.test.ts:335-353. Only generated service wrappers convert that result into a thrown BasecampError, so callers following this row may rely on try/catch and miss authentication failures from direct GET/POST calls; distinguish service-wrapper behavior from the raw openapi-fetch client.

AGENTS.md reference: AGENTS.md:L25-L28

Useful? React with 👍 / 👎.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

SPEC.md:1610

  • The Appendix F Ruby row says “All non-GET methods never retry”, but Ruby does replay a request once after a successful 401 token refresh (see ruby/lib/basecamp/http.rb where single_request retries once when AuthError 401 and @token_refreshed is true). The Ruby row should mention this exception so it matches the actual transport behavior described elsewhere.
| Ruby | Simplified: only GET retries. All non-GET methods never retry. Ruby retries on any error with `retryable? == true`. |
| Python | Three-gate, sync and async: `_mutation()` retries only when `behavior-model` metadata classifies the operation retryable, so non-idempotent POSTs are single-attempt; GETs always retry. Retries on any error with `retryable == True`. |

SPEC.md:417

  • errors.rb is referenced as the place Ruby marks 500/502/503/504 as retryable, but there is no ruby/lib/basecamp/errors.rb in this repo. Ruby’s retryability is set by the HTTP error mapping (handle_error) in ruby/lib/basecamp/http.rb, which constructs Basecamp::ApiError(..., retryable: true) for 500/502/503/504; the doc should point at the correct file to keep this claim verifiable.

This issue also appears on line 1609 of the same file.

Gate 3's *status set* is not. **Go and Ruby retry statuses the spec never declared retryable**: Go's
`isRetryableStatus` covers `{429, 500, 502, 503, 504}` regardless of the operation's declared
`retry_on`, and Ruby's loop keys off `retryable?`, which `errors.rb` sets for 500/502/503/504.
**Python** has no status gate at all — its loop keys off the `retryable` flag `errors.py` sets for
the same statuses. TypeScript, Kotlin, and Swift do gate on the declared set.

SECURITY.md:192

  • This bullet reads as if GET retries are limited to 429/503, but several SDKs also retry other 5xx statuses (e.g., Go generated client retries 500/502/504 via isRetryableStatus, Ruby marks 500/502/503/504 errors retryable: true for GET requests, and Python marks 500/502/503/504 retryable in errors.py). The docs should either mention that superset or explicitly point readers to SPEC.md §7 for the expanded status set so this doesn’t understate retry behavior.
- **Reads (GET)**: retried with exponential backoff on 429/503 in every SDK. (HEAD is idempotent by method too, but Ruby's transport gates on `method == :get` specifically, so a HEAD would not retry there. The API surface has no HEAD operations today, so this is theoretical.)

Copilot AI review requested due to automatic review settings July 28, 2026 21:26
@jeremy
jeremy force-pushed the docs/retry-truth-up branch from 4908d52 to 6b71f7a Compare July 28, 2026 21:26
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Three more corrections, all verified against source first.

Ruby is excluded from the per-operation ceiling claim. The Appendix F footnote said the ceiling is "honored in every SDK". request_with_retry loops until @config.max_retries (ruby/lib/basecamp/http.rb) without reading operation metadata at all, so a Ruby caller who raises the cap above 3 exceeds the operation's declared max. Now names Ruby explicitly, and notes the separate Kotlin direction (honors the ceiling but overrides a lower caller cap) with a pointer to #485.

Ruby's 401 replay excludes raw uploads. post_raw/put_raw — attachments and campfire uploads — go through single_request_raw, whose rescue raises the mapped error directly with no refresh-and-replay branch (http.rb:461-471), unlike single_request. So for those POSTs the "attempted exactly once" claim I had just qualified away is in fact true. Both the bullet and the Ruby row in the 401 table now carry the exception.

TypeScript's 401 depends on which API you call. BasecampClient extends RawClient, so client.GET(...) resolves with { data: undefined, error } and never throws — pinned by typescript/tests/client.test.ts:335-353. Only the generated service wrappers convert that into a thrown BasecampError. The table row said flatly "raised to the caller", which would lead someone using the raw API with try/catch to miss authentication failures entirely. This is the same shape as the generated-Go typed-401 finding fixed earlier, and it now reads the same way.

@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: 6b71f7af77

ℹ️ 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 SPEC.md Outdated
BC5 issuer that was then selected.)

#### SSRF hardening — both hops, all five SDKs `[conformance]`
#### SSRF hardening — both hops, all six SDKs `[conformance]`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude Swift from the six-SDK OAuth claim

When readers rely on the [conformance] marker, this heading incorrectly implies that OAuth discovery and its SSRF checks exist in every SDK. A repo-wide search finds discovery implementations and fixture coverage for Go, TypeScript, Ruby, Kotlin, and Python only; Swift has no OAuth implementation, and make conformance has no Swift runner. Keep this scoped to the five implementing SDKs unless Swift support and coverage are added.

AGENTS.md reference: AGENTS.md:L36-L39

Useful? React with 👍 / 👎.

Comment thread SPEC.md Outdated
**Non-retryable statuses (never retry regardless of method):** 401, 403, 404, 400, 422.

**Gate 3 status-set fidelity `[CONFLICT]`.** Gates 1 and 2 read `behavior-model.json`, and the
per-operation attempt ceiling is now honored everywhere (see *Per-operation retry ceiling* in §2).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude Ruby from the universal retry-ceiling claim

When a Ruby caller configures more attempts than an operation's declared retry.max, this statement is false because request_with_retry uses only @config.max_retries. Fresh evidence in this revision is the newly added Appendix F note at lines 1613–1617, which explicitly says Ruby never consults the per-operation metadata; qualify “everywhere” here to avoid giving implementers the opposite contract.

Useful? React with 👍 / 👎.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

SPEC.md:412

  • This sentence says the per-operation attempt ceiling is honored “everywhere”, but Appendix F below explicitly notes Ruby never consults the per-operation ceiling. Tighten this claim to the SDKs that actually honor the ceiling to avoid an internal contradiction.
**Gate 3 status-set fidelity `[CONFLICT]`.** Gates 1 and 2 read `behavior-model.json`, and the
per-operation attempt ceiling is now honored everywhere (see *Per-operation retry ceiling* in §2).

SPEC.md:415

  • This references errors.rb, but Ruby’s retryability is set via handle_error in ruby/lib/basecamp/http.rb (and ApiError.from_status in api_error.rb). Updating the file reference will make the citation actionable.
`isRetryableStatus` covers `{429, 500, 502, 503, 504}` regardless of the operation's declared
`retry_on`, and Ruby's loop keys off `retryable?`, which `errors.rb` sets for 500/502/503/504.

SECURITY.md:190

  • This summary says “The other 39 POSTs are attempted exactly once”, but Ruby and Python both may replay a request once after a successful 401 refresh (and you note that later in this section). Consider softening this sentence so it doesn’t contradict the 401 behavior described below.
Retry eligibility is decided per *operation*, not per HTTP method. `behavior-model.json` classifies
all 226 operations: the 118 GETs are retryable by method, and 69 mutations are flagged
`idempotent: true` — all 45 PUTs, all 21 DELETEs, and 3 POSTs (`CompleteTodo`, `PauseQuestion`,
`SubscribeToCardColumn`). The other 39 POSTs are attempted exactly once. SPEC.md §7 specifies the
three-gate algorithm and the per-SDK divergences.

…ster

SECURITY.md said mutations are never retried and that a 401 triggers a
token refresh plus a single retry "for all methods". Both are false.

All 45 PUTs, 21 DELETEs, and the 3 flagged POSTs do retry — in Go's
generated operation path, Python, TypeScript, Kotlin, and Swift. Ruby is
the sole GET-only transport (as is Go's separate hand-written HTTP
helper). The mutations bullet dates to PR #60, before the generated
client existed.

Reactive 401 refresh exists in exactly three places: hand-written Go,
Ruby, and Python (sync and async). Generated Go operations do not
refresh reactively — AuthTransport fetches a token proactively per
request and a 401 becomes AuthError. TypeScript, Kotlin, and Swift have
no refresh at all. Replaced the bullet with a per-SDK matrix.

SPEC.md:
- "Five shipping SDKs" and the implementation-consensus list both
  omitted Python.
- The network-error divergence named Go, Ruby, and Swift. Python belongs
  in that group; Ruby does not — it retries network errors only on GET,
  since every non-GET routes to a single-attempt path.
- Appendix F's retry table had no Python row.
- Added a Gate 3 fidelity note: Go and Python honor the per-operation
  idempotent classification but ignore the per-operation retry block,
  substituting a client-global attempt count and a superset of retry_on.
  Tracked in #479.

Documentation only; no behavior change. The TypeScript one-retry waiver
(2B.1) is current, not stale — client.ts:1007 returns native fetch(),
bypassing middleware — so it is left as written.
Copilot AI review requested due to automatic review settings July 28, 2026 22:22
@jeremy
jeremy force-pushed the docs/retry-truth-up branch from 6b71f7a to 8af46a1 Compare July 28, 2026 22:22

@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: 8af46a1f4f

ℹ️ 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 SECURITY.md
Comment on lines +193 to +194
- **Naturally-idempotent mutations (PUT/DELETE) and the 3 flagged POSTs**: *are* retried on 429/503
by Go (generated operation path), Python, TypeScript, Kotlin, and Swift. Retrying these cannot

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify Kotlin's multipart PUT retry behavior

When UpdateAccountLogo receives a 429 or 503, Kotlin does not retry it: the generated account service routes this PUT through httpPutMultipart, which calls requestBinaryWithRetry, and that method returns the first response without any retry/status check (BaseService.kt:146-151, BasecampHttpClient.kt:211-218). Therefore Kotlin is another exception to this blanket mutation claim, despite the operation being marked idempotent.

Useful? React with 👍 / 👎.

Comment thread SPEC.md
```

`[CONFLICT: rubric-audit.json 3C.3 says 1024; all 5 SDKs use 500. Code wins.]`
`[CONFLICT: rubric-audit.json 3C.3 says 1024; all six SDKs use 500. Code wins.]`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude untruncated Python auth errors from the 500-byte claim

When Python receives a 401 or 403 whose JSON error string exceeds 500 bytes, error_from_response passes that message directly to AuthError or ForbiddenError (python/src/basecamp/errors.py:143-146), unlike the branches that call _truncate. Thus Python does not satisfy the following universal error-message truncation contract for those responses, so claiming all six SDKs use the 500-byte limit is misleading unless these statuses are qualified.

Useful? React with 👍 / 👎.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

SPEC.md:418

  • This section contradicts the preceding Gate 3 bullet: the spec explicitly allows implementations to expand the retryable status set beyond retry_on: [429, 503], so these statuses are spec-sanctioned. It looks like you meant that these statuses are not declared in behavior-model.json’s retry_on metadata.

Also, errors.rb doesn’t exist in the Ruby SDK; the 500/502/503/504 retryable classification is set in ruby/lib/basecamp/http.rb (handle_error).

by fewer still. **Go and Ruby retry statuses the spec never declared retryable**: Go's
`isRetryableStatus` covers `{429, 500, 502, 503, 504}` regardless of the operation's declared
`retry_on`, and Ruby's loop keys off `retryable?`, which `errors.rb` sets for 500/502/503/504.
**Python** has no status gate at all — its loop keys off the `retryable` flag `errors.py` sets for

@jeremy
jeremy merged commit 6cd769e into main Jul 28, 2026
47 checks passed
@jeremy
jeremy deleted the docs/retry-truth-up branch July 28, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants