Skip to content

Ruby: max_retries: 0 made zero requests on an ungoverned GET (#532) - #656

Merged
jeremy merged 4 commits into
mainfrom
fix/532-ruby-zero-attempt
Aug 4, 2026
Merged

jeremy merged 4 commits into
mainfrom
fix/532-ruby-zero-attempt

Conversation

@jeremy

@jeremy jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member

Closes #532.

The defect

A Ruby client configured with max_retries: 0 made zero HTTP requests on any ungoverned GET and raised Basecamp::ApiError("Request failed after 0 attempts").

ruby/lib/basecamp/http.rb floored the cap at one attempt on the governed branch but used the raw @config.max_retries on the ungoverned branch of the same expression, so break if attempt > max_attempts fired before the first request.

Whether a request reached the wire depended on whether the operation carried a declared retry block — same client, same method, same config.

The fix

Floor the cap on every path. The retry_on branch and the ungoverned branch only ever resolved to caller_cap, so the three-branch expression collapses to a ternary. retry_on stays load-bearing for the retryable-status set (retry_eligible?, http.rb:459/:485) — only its redundant budget arm went away.

Ruby now matches Kotlin's config.maxRetries.coerceAtLeast(1).

Red proof, against the un-fixed code

The tests assert the request count, not the error class — the un-fixed path raises the same class from the same method, so only "did a request happen" separates fixed from broken.

Run options: -n /zero_max_retries/ --seed 12906

.E

  1) Error:
HTTPGovernedRetryTest#test_ungoverned_get_with_zero_max_retries_still_makes_one_request:
Basecamp::ApiError: Request failed after 0 attempts
    lib/basecamp/http.rb:481:in 'Basecamp::Http#request_with_retry'
    lib/basecamp/http.rb:383:in 'Basecamp::Http#request'
    lib/basecamp/http.rb:63:in 'Basecamp::Http#get'

2 runs, 2 assertions, 0 failures, 1 errors, 0 skips
REAL_EXIT=1

The governed test passes against un-fixed code (.) — that is the point of the pair. It pins the two branches together so they cannot drift apart again.

After the fix: 2 runs, 4 assertions, 0 failures, 0 errors.

Docs corrected

Both places that documented the bug as behavior:

  • SPEC.md §2 rule 4 — the Ruby bullet described the zero-request outcome as a known divergence. Rewritten; the summary line drops from three distinct outcomes across four implementations to two, since Ruby's outcome now collapses into Kotlin's.
  • ruby/README.md:447 — stated "max_retries: 0 sends zero requests". That would have shipped as a false claim.

Verification

make check-readme-env-vars check-retry-metadata-parity rb-check — real exit code written to a marker file and grepped back:

1362 runs, 30397 assertions, 0 failures, 0 errors, 0 skips
Line coverage: 2049 / 2130 (96.19%)   Branch coverage: 667 / 784 (85.07%)
153 files inspected, no offenses detected
✓ Ruby consumes max + retry_on — http.rb retries governed GETs with min(caller cap, maxAttempts)
MARKER_GATES_REAL_EXIT=0

247 operations unchanged. Not breaking: this only turns a no-op-and-raise into a request.


Summary by cubic

Fixes Ruby GET retry logic where max_retries: 0 sent zero requests on ungoverned GETs by flooring attempts to at least one across all paths. Behavior now matches Kotlin (always at least one attempt).

  • Bug Fixes
    • Floor attempt cap to max(1, config.max_retries) on every GET path; governed operations still clamp to their declared ceiling.
    • Collapse branching to a ternary; retry_on still gates which statuses retry.
    • Add tests asserting one request for governed and ungoverned GETs when max_retries: 0.
    • Update docs: clarify universal attempt floor; governed vs ungoverned status gates (governed GETs don’t retry 500); download flow behavior; and that Ruby consumes max and retry_on on governed GETs (SPEC.md §§2, 7, 14; ruby/README.md; scripts/check-retry-metadata-parity.py; fix download test comment).
    • Non-breaking: turns a no-op-and-raise into a single request.

Written for commit 87deb48. Summary will update on new commits.

Review in cubic

A client configured with `max_retries: 0` made no HTTP request at all on
any ungoverned GET and raised `Request failed after 0 attempts`. The
governed branch floored the cap at one attempt; the ungoverned branch of
the same expression used the raw config value, so `break if attempt >
max_attempts` fired before the first request.

Whether a request reached the wire depended on whether the operation
carried a declared retry block — same client, same method, same config.

Floor the cap on every path. The `retry_on` and ungoverned branches only
ever shared `caller_cap`, so the three-branch expression collapses to a
ternary; `retry_on` stays load-bearing for the retryable-status set.
Ruby now matches Kotlin's `coerceAtLeast(1)`.

The tests assert the request count, not the error class: the un-fixed
path raises the same class from the same method, so only "did a request
happen" separates them. The governed test passes against un-fixed code
and pins the two branches together.

SPEC §2 drops to two divergent outcomes across four implementations;
ruby/README.md no longer documents the zero-request outcome as behavior.
Copilot AI balanced review requested due to automatic review settings August 4, 2026 17:45
@jeremy jeremy added bug Something isn't working ruby Pull requests that update the Ruby SDK labels Aug 4, 2026

@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: 31db075016

ℹ️ 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
- **Generated Go** (low-level `pkg/generated` client) and **Python** (sync + async): accept `0` as a compatibility exception and make a single attempt with no retry. Both reject a *negative* value as a configuration error (generated Go: `WithRetryConfig`/`doWithRetry` return a plain `error`; Python: `Config` raises `ValueError` at construction).
- **Kotlin:** the builder rejects a *negative* value (`require(maxRetries >= 0)`) and accepts `0`, which the transport coerces to a single attempt (`config.maxRetries.coerceAtLeast(1)`).
- **Ruby:** `0` passes config validation. A **governed** GET (canonical operation ID present) coerces the cap to one attempt (`[config.max_retries, 1].max`) and makes a single request. An **ungoverned** GET keeps the old outcome: the retry loop's `break if attempt > max_retries` fires before the first request, so it makes **zero** requests and raises `Basecamp::ApiError("Request failed after 0 attempts")`.
- **Ruby:** `0` passes config validation and the transport coerces it to a single attempt (`[config.max_retries, 1].max`), matching Kotlin. The floor applies on every path: whether a request reaches the wire does not depend on whether the operation carries a declared retry block. A declared operation ceiling still clamps the floored cap downward.

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 Update the stale zero-attempt note in the download section

After this change, max_retries: 0 is floored to one attempt for every Ruby GET path, but the Ruby row in SPEC.md §14 still says that the general ungoverned GET path makes zero attempts and is tracked separately (line 1319). This leaves the normative specification internally contradictory and can cause future implementations or conformance work to preserve behavior that this commit intentionally removed; update that row to reflect the universal floor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in df89ab3.

You were right that §2 was not the only place. SPEC.md:1319 — the §14 per-SDK hop-1 attempt-budget table — still read "floored at one for downloads (max_retries: 0 still sends one attempt; the general ungoverned GET path's zero-attempt behavior is tracked separately)". With §2 rule 4 now saying the floor applies on every path, the normative spec contradicted itself. That row now reads:

| Ruby | max_retries as total attempts, floored at one on every path — downloads, governed GETs and ungoverned GETs alike (max_retries: 0 still sends one attempt) |

Swept the rest of SPEC.md (max_retries|floored|floor|tracked separately|532|zero) and the tree outside it. One more hit: ruby/test/basecamp/download_test.rb:414 carried the same claim in a comment, citing #532 as the tracker for the behavior this PR removes — corrected to say the floor now applies on every Ruby request path.

Left alone deliberately, because they are still true: §7 lines 111 and 115 say governed paths coerce the cap to at least one attempt (min(max(1, cap), op_max)). That is ceiling arithmetic, and the ceiling only exists where an operation declares a retry block — the sentences assert what governed paths do, not that ungoverned paths do otherwise, and §2 rule 4 resolves the scope. Same for §7's line 3355 and §19's Ruby row, which are about status gating and the per-op ceiling, not attempt floors.

make rb-check green: 1362 runs, 30397 assertions, 0 failures, 0 errors; rubocop 153 files, no offenses.

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 fixes a Ruby SDK defect (#532) where a client configured with max_retries: 0 made zero HTTP requests on an ungoverned GET and raised Basecamp::ApiError("Request failed after 0 attempts"). The root cause was in ruby/lib/basecamp/http.rb: the attempt cap was floored at one on the governed branch (op_retry) but used the raw @config.max_retries on the ungoverned else branch, so break if attempt > max_attempts fired before the first request.

The fix floors the cap on every path. Since both the retry_on branch and the ungoverned branch only ever resolved to caller_cap, the three-branch expression collapses to a ternary. retry_on remains load-bearing for status eligibility via retry_eligible? (http.rb:459/:485) — only its redundant budget arm was removed. This aligns Ruby with Kotlin's coerceAtLeast(1) behavior.

Changes:

  • Floor max_attempts to at least one attempt across all GET paths by collapsing the branching to op_retry ? [caller_cap, maxAttempts].min : caller_cap.
  • Add paired tests asserting a single request for both ungoverned and governed GETs with max_retries: 0.
  • Correct SPEC.md §2 rule 4 (three→two divergent outcomes) and ruby/README.md:447 to drop the now-false zero-request claim.

I verified that retry_on is still consumed by retry_eligible?, that the download flow (get_download, http.rb:179) previously used the retry_on arm and now uses the equivalent else arm (caller_cap) with identical behavior, and that the SPEC.md edits touch no @bc3-pin/@api-version/@assertion-types marked spans. No issues found.

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.

Reviewed changes

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

File Description
ruby/lib/basecamp/http.rb Collapses the three-branch attempt-cap expression to a ternary, flooring the cap at one on every path so ungoverned GETs make at least one request.
ruby/test/basecamp/http_test.rb Adds paired ungoverned/governed tests asserting exactly one request when max_retries: 0.
SPEC.md Updates §2 rule 4 divergence note: Ruby now matches Kotlin, reducing distinct outcomes from three to two.
ruby/README.md Removes the now-incorrect "max_retries: 0 sends zero requests" claim, documenting the floor-to-one behavior.

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

…pt path

SPEC §14's per-SDK download attempt budget still said Ruby floors at one
"for downloads" and that the general ungoverned GET path's zero-attempt
behavior was tracked separately. §2 rule 4 now says the floor applies on
every path, so the normative spec contradicted itself.

The download test carried the same claim in a comment, citing #532 as the
tracker for behavior this change removed.

The rest of the sweep found no other statement of the old behavior. §7's
per-op ceiling notes say governed paths coerce the cap to at least one
attempt, which remains true and is about ceiling arithmetic that only
exists where an operation declares a retry block.
Copilot AI review requested due to automatic review settings August 4, 2026 18:16

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.

Two lines in the per-operation ceiling section still scoped the floor to
governed paths. Not false about governed paths, but misleading by
scoping in a normative document — and that exact scoping was the defect
this PR fixes. §2 and §14 already say the floor is universal; these were
the last places implying otherwise.

The ceiling really is governed-only (it needs a declared retry block);
the floor is not. Separate the two rather than describing both as
governed.
Copilot AI review requested due to automatic review settings August 4, 2026 18:19

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.

The §7 paragraph I added claimed the floor-before-ceiling formula
min(max(1, cap), op_max) holds for every SDK it names, and that the floor
does not depend on the operation declaring a retry block. That is true of
Go, Python and Ruby. It is not true of Kotlin, which computes

    minOf(config.maxRetries.coerceAtLeast(1), opRetry?.maxRetries ?: config.maxRetries)

so an ungoverned operation at a cap of 0 yields minOf(1, 0) = 0. Kotlin
still makes the one request, because its loop fires the attempt before it
consults the budget — §14's Kotlin row is right, the formula-level claim
was not. Say both halves.

The two "ungoverned traffic keeps the pre-metadata contract" lines are now
imprecise about the budget: the floor applies there too. Scope them to the
status half and name the floor.

ruby/README.md's retry bullets were flatly wrong in three places, all
predating this branch:

  - "500 ... and any other 5xx all retry" — a governed GET does not retry
    500. test_governed_get_does_not_retry_500 has pinned that since #486.
  - the declared policy "is inert in Ruby — every API GET ... rides the
    same classification-based loop" — generated services pass canonical
    operation IDs, so essentially every SDK GET is governed and gated on
    the declared retryOn [429, 503]. Only get_absolute and the Launchpad
    authorization fetch it backs are ungoverned.
  - "the raw upload and download paths skip the retry loop entirely" —
    get_download goes through request_with_retry under DOWNLOAD_RETRY_ON.
    Only the upload path and the signed-URL second hop are single-request.

check-retry-metadata-parity.py's docstring carried the same "Ruby consumes
NONE" claim its own RUNTIME_CONSUMPTION table contradicts. And http.rb no
longer floors the cap "for downloads" — that is every path now.
Copilot AI review requested due to automatic review settings August 4, 2026 19:00
@jeremy

jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Absorbed the review. Three of the four items were real; the fourth I tightened rather than left.

A1 — the SPEC sentence was wrong about Kotlin, and it was mine. SPEC.md:111 claimed the floor-before-ceiling formula min(max(1, cap), op_max) held for every SDK the paragraph names, and that "the floor is universal — it does not depend on the operation declaring a retry block". That is true of Go, Python and Ruby. It is false for Kotlin, which computes

// BasecampHttpClient.kt:119-122
val maxAttempts = minOf(
    config.maxRetries.coerceAtLeast(1),
    opRetry?.maxRetries ?: config.maxRetries,
)

With no declared block the second argument is the raw un-floored cap, so maxRetries = 0 gives minOf(1, 0) = 0 — no floor at all.

Verified the other three against source rather than taking it on trust:

  • Go doWithRetry (client.gen.go:5034) resolves effectiveMaxAttempts(), which returns 1 for MaxRetries == 0, then applies the ceiling at :5045 under opMax > 0 && opMax < maxAttempts — so the ceiling can never drive it back to zero. Formula holds.
  • Python _http.py:164-165: max_attempts = max_retries if max_retries > 0 else 1, then _apply_operation_retry_max, which also guards 0 < op_max. Formula holds.
  • Ruby http.rb:396-397: caller_cap = [max_retries, 1].max, then min with the declared maxAttempts. Formula holds.

Kotlin still makes the one request, and §14's Kotlin row stays true — its loop is post-check. requestWithRetry fires the attempt at BasecampHttpClient.kt:132 (AttemptOutcome.Completed(request(method, url, body))) and only consults attempt < maxAttempts afterwards, at :183 (network) and :206 (status). At maxAttempts = 0: attempt 1 goes out, 1 < 0 is false, one attempt total. So the outcome is right and only the formula-level claim was wrong.

It now reads:

In Go, Python, and Ruby's governed path the cap is floored at one attempt before the ceiling applies (min(max(1, cap), op_max)), so a cap of 0 yields a single attempt rather than none whether or not the operation declares a retry block. Kotlin computes a different expression — min(max(1, cap), op_max ?: cap) — which is 0 for an ungoverned operation at a cap of 0, but it lands on the same single attempt anyway because its loop consults the budget only after the first request has already gone out.

A2 — ruby/README.md. Both claims confirmed false, and I found a third in the same paragraph.

  • :451 said the declared policy "is inert in Ruby — every API GET … rides the same classification-based loop bounded by config.max_retries alone". Generated services pass canonical operation IDs (documents_service.rb:15: http_get(..., operation: "GetDocument")). Of every http_get in ruby/lib/basecamp/generated/services/, exactly one lacks an operation: — and that one is the @!method doc stub in base_service.rb. So every generated GET is governed.
  • :446 said "429 …, 500, 502, 503, 504, and any other 5xx all retry". A governed GET does not retry 500; test_governed_get_does_not_retry_500 asserts times: 1 and has since Retry: honor the declared retry_on and max_attempts in Go and Python #486. All 247 operations in ruby/lib/basecamp/generated/metadata.json declare retryOn: [429, 503] — I counted, there is no second distinct set.
  • Third one, four lines up: "the raw upload and download paths skip the retry loop entirely (the upload path is strictly one request; the download hop keeps only the one-shot 401 replay below)". get_downloadrequest_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, ...) (http.rb:179). The download hop-1 does retry. Only the upload path and the signed-URL hop-2 (client.rb:618, a bare Net::HTTP request) are single-request. This one is pre-existing on main too.

All three rewritten to distinguish governed from ungoverned.

While cross-checking I found scripts/check-retry-metadata-parity.py's docstring carrying the same dead claim its own executable table refutes — :39-40 said "Ruby: consumes NONE (GET-only retry); every emitted retry field is inert", while RUNTIME_CONSUMPTION classifies Ruby as max + retry_on and enforces it. Corrected the docstring; the table is unchanged.

A3 — http.rb:172-173 no longer says the floor is a download property.

A4 — did not leave it. SPEC.md:663 and :3355 said ungoverned traffic "keeps the pre-metadata contract", which is now imprecise about the budget half: the floor applies there too, which is the whole point of this PR. Scoped both to the status half and named the floor. No counts touched.

Verification (real exit code written into the log under a marker and grepped back, not read off mid-run text):

make rb-check doc-constants-check check-retry-metadata-parity check-readme-env-vars
MARKER_RB532_EXIT=0
1362 runs, 30397 assertions, 0 failures, 0 errors, 0 skips
✓ Ruby metadata.json: 247 operations match behavior-model.json (full tuple)
✓ Ruby consumes max + retry_on — http.rb retries governed GETs with min(caller cap, maxAttempts) and gates status retry on the declared retryOn

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 4, 2026

Copy link
Copy Markdown
Member Author

Merging on independent adversarial review; both bot reviewers were unavailable (Codex never reviewed at head, Copilot's check is failing repo-wide on its own infrastructure).

The review found that my own SPEC fix introduced a false claim, which is worth recording. I wrote "The floor is universal — it does not depend on the operation declaring a retry block" into a paragraph whose subject is Go, Python, Kotlin and Ruby. Kotlin computes minOf(config.maxRetries.coerceAtLeast(1), opRetry?.maxRetries ?: config.maxRetries) — the second argument takes the floor straight back off, so an ungoverned operation at a cap of 0 yields minOf(1, 0) = 0. I replaced a true-but-misleadingly-scoped sentence with a false one, in the normative spec. Now corrected to state the formula for the three SDKs that hold it and to name Kotlin's different expression, which still lands on one attempt because its loop consults the budget only after the first request has gone out. Filed as #661 — Kotlin's floor currently depends on loop structure rather than on the budget expression, and check-retry-metadata-parity.py passes vacuously there because it greps for the literal string coerceAtLeast(1) (its own words: "TOKEN-SMOKE classification, NOT a behavioral proof").

Three false claims in ruby/README.md also fixed, all pre-existing but all about the subsystem this PR changes:

  • :451 said the declared retry policy is "inert in Ruby" — false since Retry ceilings everywhere: Kotlin honors the caller cap, Ruby enforces the per-op block #515; generated services pass canonical operation IDs, so essentially every SDK GET is governed and gated on retryOn: [429, 503].
  • :446 said 500 retries — it does not on a governed GET, as this repo's own test_governed_get_does_not_retry_500 proves.
  • :444 said the download path "skips the retry loop entirely" — get_download calls request_with_retry(retry_on: DOWNLOAD_RETRY_ON), and conformance confirms it retries 503 and not 500 on hop 1.

Plus scripts/check-retry-metadata-parity.py, whose docstring said "Ruby: consumes NONE" while its own table classifies and enforces Ruby as max + retry_on.

Added the breaking label. The precedent is unambiguous — #461, #486 and especially #515, which applied this same floor to Ruby's governed path, all carried breaking + bug. Without it the release notes would not say that max_retries: 0 now puts a request on the wire where it previously made none.

Review independently confirmed the code change: across the full 24-cell (op_retry, retry_on, cap) space exactly two cells move, both ungoverned — cap 0 and cap -1, each 0 → 1 attempt. No governed, download, mutation, network-error or status-retry cell changed, and retry_on's removed arm was bit-identical to the new else.

@jeremy
jeremy merged commit 0fa8b46 into main Aug 4, 2026
43 of 44 checks passed
@jeremy
jeremy deleted the fix/532-ruby-zero-attempt branch August 4, 2026 19:06
jeremy added a commit that referenced this pull request Aug 4, 2026
Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.
jeremy added a commit that referenced this pull request Aug 4, 2026
* MIGRATING.md: the v0.13.0 upgrade guide, silent breaks first

v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile
error, no exception, no decoder failure. Label-generated release notes list
what merged; they cannot say what a consumer must react to or what wrong
behaviour they get if they ignore it. That had no home in this repo.

Adds MIGRATING.md at the root, linked from the root README and all six
per-SDK READMEs. Silent breaks lead the document, then one section per SDK
ordered by severity, plus an operator checklist, a "coverage: corrected and
re-scoped" section for what did not ship, and known gaps.

No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115
as superseded by auto-generated notes, and every release body since is
machine-built. CONTRIBUTING records the resulting rule: label-generated notes
say what merged, MIGRATING says what to do about it.

Corrections to the source drafts, each re-derived rather than repeated:

- TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit
  update destroy]`; DELETE /todos/:id returned 204 and set status to
  "archived", so every caller was archiving. It is the one #619 removal that
  takes away a working call, and it now carries its own carve-out.
- #619 removed three operations, not nine. Nine were re-pathed. Fusing the
  two sets is what made the blanket 404 reassurance look safe.
- Hook operation identity differs by SDK: Go and Ruby emit a short verb,
  the other four emit the wire operation ID, where the todolist pair kept
  its names — so an allowlist holding UpdateTodolistOrGroup passes the write
  and denies the new read.
- 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed.
- Kotlin binary compatibility is already disclaimed in kotlin/README.md;
  Swift has no written policy. Both are now stated rather than left unsaid.

recordings.get is documented as a known gap with a list-and-filter recipe
and its honest cost. The Go recipe compiles against this tree.

#637, #629 and #635/#641 were open at the time of writing and are recorded
under "Not in this release" rather than described as shipped.

* Fix the Go pagination advice, cut the raw-wire workaround, absorb #637/#643

Addresses both P1 review threads on #642 and folds in the two PRs that landed
since the first draft.

Pagination (P1). Cross-SDK item 1 claimed `page` was a starting offset in every
SDK and told readers to drop it to restore the old walk. For Go that was
actively harmful: `git show v0.12.0:go/pkg/basecamp/bookmarks.go` returns before
followPagination whenever page > 0, so a positive Page already meant one
request, and dropping it converts a bounded call into a full account-wide
traversal. The item is now scoped to the five SDKs where it holds — re-checked
at the tag rather than assumed, since the universal claim had already failed
once — with a Go subsection splitting the two real cases: services where the
page number was already honored (Bookmarks, Drafts, Everything*, request
unchanged) and the fourteen carrying the "not yet honored" doc, which sent no
page at all and returned page 1's rows. Gauges is in neither; it had no page.

Raw wire (P1). The Forwards().CreateReply example built a path with fmt.Sprintf
and called the raw AccountClient.Post against a route with no upstream
coverage, which is what AGENTS.md "Never Do These" 4 and 5 forbid. Removed
rather than softened, and replaced with a known-gap section stating what a
hand-built path gives up. Swept the document: the one other hit documents a real
change to the raw client's error codes, so it stays, but its fabricated path is
gone and it now says it is not a suggestion to reach for the escape hatch.

#643 landed, so basecamp.Ptr and basecamp.Deref replace the hand-rolled ptr
helper throughout, the Go section opens with the 300-pointer census and a
command that reproduces it, and ParticipantIDs *[]int64 gets its own note: nil
leaves participants alone, a pointer to an empty slice removes every one.

#637 landed and does NOT add a break to any SDK. color and comments_app_url did
not exist on Todolist at v0.12.0 in any of the six — both arrived with #628
earlier in this same release — so from the guide's baseline nothing turned from
optional to required. Counts stay 27/20/16/14/16/14. Documented where it bites:
color is required-and-nullable so explicit null decodes, comments_app_url
rejects null and absence alike.

Also: kotlin/README's append-only source-compat promise contradicted this
release repeatedly, so it now describes documented pre-1.0 breaking correctness
releases; the binary-compat disclaimer is kept and sharpened. release-github.yml
links MIGRATING.md from every release body, guarded on the file, so the link
cannot be forgotten at tag time. "Silent" is defined as source/runtime-silent
against a live server, since a suite pinning request paths does catch some.

Counts are stated as-of 51d0d86 with derivations inline, and each in-flight
change names the numbers it invalidates so the pre-tag pass is arithmetic.

* Split silent breaks into no-signal and fails-at-runtime; absorb #629 and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.

* Audit class A across all six SDKs; add Ruby's missing download retry

Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.

* Correct four claims in the v0.13.0 guide that do not match the source

The opening warning said the runtime failures need a payload where a field is
absent. That holds for the three Go entries; Ruby's single class-B entry has the
opposite trigger. Draft#scheduled_posting_at and MyNote#created_at/#updated_at
run through parse_datetime, which returns nil for nil and a Time otherwise, so
.start_with? and Time.parse raise only when the field is populated. A reader
following the old text builds the wrong fixture and concludes they are
unaffected. Both directions are now named, here and in the root README.

Class A was described as breaking on every response. Most of it does, but two
groups do not: the error-message and validation entries need an error status to
reach the code at all, and the field-map half needs a body of a particular
shape; downloadURL's hop-1 retry changes nothing until a network error or one of
429/502/503/504 occurs. Stated as preconditions rather than as a blanket claim.

The Go pointer example said only the field selector panics. types.Date.String
has a value receiver, so Go rewrites t.DueOn.String() to (*t.DueOn).String() and
the nil dereference panics before String is entered. The same holds for IsZero,
Before, After and Weekday on Date and for Format, Sub, Unix and Year on
time.Time. The summary bullet already said both panic; the example contradicted
it.

The Accept-header note credited only Python. Ruby dropped it on the same hop:
get_download passes accept: nil, and request_headers sets the header only when
accept is truthy. Both are named, with the observation that the other four never
sent it on that hop at v0.12.0 either.

No counts are touched.

* Re-derive every count against the final release commit

Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.

* State the schedule-entry clear value per field instead of universally

The Swift Behavioural bullet said an explicit "" clears any of the five
full-state fields. Only description does. "" on summary is accepted and
reads back "Untitled"; starts_at and ends_at are under
validates_presence_of in Schedule::Entry, so "" is rejected rather than
cleared; allDay is a boolean in every SDK, so "" does not typecheck at
all. The carve-out half grouped notify with the three clearable fields
even though it is a send directive with no state to clear.

* Re-derive the per-SDK README banners against the final class A/B table

The six SDK README banners still carried the counts from before the Go
reclassification and the recount that followed it, summing to 51 where
MIGRATING.md and the root README say 61. Each banner now matches its row
in the class A/B table: Go 12+4, Swift 10, TypeScript 9, Python 8, Ruby
10+1, Kotlin 6+1. Kotlin also gains the runtime clause it was missing,
since its one class B entry throws on a present field carrying a JSON
number or boolean where the model declares a string.

* Correct the merged-PR count and the two claims the reviewers caught

The release spans 55 merged pull requests, not 67. The 67 came from comparing
GitHub's Z-formatted mergedAt against a git timestamp formatted with a local
offset, using jq's string >, which is lexicographic rather than temporal; it
wrongly swept in twelve PRs merged in the hours before the v0.12.0 tag instant.
The derivation embedded in the guide taught that same broken comparison, so it
now uses %ct and fromdateiso8601 and says why. The breaking count of fifteen is
unchanged, since all fifteen merged after the tag, so the class A/B split, the
per-SDK tables and the six README banners are untouched.

The header no longer calls 2afc977 the commit the release is cut from. That
commit is the last of the release content and the baseline the counts were
measured against, but it predates this guide; the tag is cut from main after
this merges, on a tree that contains the file the release body links to.

The release-body teaser claimed the guide covers only breaks with no exception
and no decoder failure. The guide documents six breaks that do fail at runtime,
including Ruby and Kotlin raises and a Kotlin decoder failure, so the teaser now
names both the silent class and the runtime one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API bug Something isn't working ruby Pull requests that update the Ruby SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ruby: ungoverned GET with max_retries: 0 makes zero requests and raises "Request failed after 0 attempts"

2 participants