Repository navigation
Conversation
The shared token bucket was charged once per SDK operation, so the pages of an --all walk, retries, raw `basecamp api get` calls and name lookups went out unpaced, however many processes were running. Each HTTP request the CLI's SDK client sends now takes a token from the same cross-process bucket, waiting for a refill rather than failing; the operation gate's token pays for that operation's first request so nothing is charged twice.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Cancellation during pacing leaves request bodies unclosed, violating the transport’s cleanup contract.
1 open finding
What changed in this PR
Extends the CLI’s shared rate limiter to charge each SDK HTTP request, including pagination, raw calls, and retries.
Changes:
- Adds transport-level pacing with cancellation support.
- Reuses the operation’s token for its first request.
- Adds token-accounting and pacing tests.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
| File | Description |
|---|---|
| internal/resilience/transport.go | Adds pacing and prepaid-token handling. |
| internal/resilience/transport_test.go | Tests request accounting, refill waits, and cancellation. |
| internal/resilience/rate_limiter.go | Adds cancellable token pacing. |
| internal/resilience/hooks.go | Marks the first request as prepaid. |
| internal/appctx/context.go | Wraps the SDK transport with pacing. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Collaborator
Author
|
🤖 Review summary for head 15065d0:
Open for a person, not this PR: whether to lower the defaults (50 burst, 10/s) or to cap in-flight requests. The PR body has the numbers. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What
Every HTTP request the CLI's SDK client sends now takes a token from the shared, cross-process token bucket in
internal/resilience(50 burst, 10/s refill, unchanged). Until now the bucket was charged once per SDK operation, inOnOperationGate. The operation's token now pays for its first request, so a one-request command costs one token, the same as before.A
RoundTripper(internal/resilience/transport.go) wraps the transport the SDK client already uses. When the bucket is empty it waits for a refill and does not fail. It stops if the caller cancels or the request's own timeout expires. The operation gate, the bulkhead, the circuit breaker and the Retry-After block are all unchanged.Why
The bucket's own docs say it limits requests (
TokensPerRequest, "client limit 10/s"). In practice, these requests never reached it:--allwalk. The SDK follows theLinkheader with rawdoRequestURLcalls that never pass through the operation gate (client.go:640 @ SDK 35f3d72d).basecamp api get(api.go:55) and the/projects.jsonwalk that--in <id>makes on every invocation (resolver.go:432). The SDK's rawGet/GetAllaren't gated operations (client.go:355).So a page walk ran as fast as the server answered, and parallel
api getloops had no limit at all. That matches the traffic shape behind the 15 Octoberrecordings.jsonload (an old CLI build, OAuth client 19503): about 12.8 requests/s with about 6 in flight on average, walking projects × types × pages.Measured against a local mock server
All runs used real CLI binaries against a synthetic HTTP server with a fresh state directory each time.
--all, 80 ms server--all, 0 ms serverapi getloops × 40 pages, 470 ms serverapi getloops × 60 pages, 0 ms serverNormal commands don't change. Bulk walks and agent loops now hold to the bucket's 10/s across every
basecampprocess sharing a cache directory. That means one OS user's processes on one machine, across all of that user's profiles.Testing
bin/ciis green locally, including e2e.internal/resilience/transport_test.gorun a real SDK client againsthttptestand count tokens spent:Gets spend 3Transportreturning the bare base transport, the walk, raw and retry tests fail (spent 1, 0 and 0). If the prepaid marker is removed, the walk and single-page tests fail (spent 6 and 2), which shows nothing is double-charged.Pacewaits for a refill on a held clock (2 waits, 1 s for 3 tokens at 2/s) and returnscontext.Canceledwhen the caller cancels mid-wait.Trade-offs and what this does not do
api getduring a block is now paced, but not blocked.api getand name lookups still don't take a bulkhead slot. The bulkhead counts processes, not requests.--in <numeric id>still walks/projects.jsonon every invocation. That's a separate, cheap follow-up.Summary by cubic
Spends one rate-limit token on every HTTP request the CLI's SDK client sends, instead of once per SDK operation. Requests that previously bypassed the shared cross-process token bucket — pages 2..N of
--allwalks, rawbasecamp api getcalls, and retries — are now paced at the documented 10/s across every process sharing a cache directory.Behavior notes
pacedTransportwraps the SDK client's existing transport and waits for a refill when the bucket is empty instead of failing; it stops on caller cancellation or the request timeout, closing the request body and sending nothing when canceled mid-wait.api getis paced but still not subject to the shared Retry-After block or the bulkhead; under heavy contention a paced request can reach the SDK's 30 s timeout and fail as a network error.Written for commit 15065d0. Summary will update on new commits.