Skip to content

Queue for a resilience slot instead of failing parallel invocations - #712

Merged
jeremy merged 6 commits into
mainfrom
authux-bh
Sep 13, 2026
Merged

Queue for a resilience slot instead of failing parallel invocations#712
jeremy merged 6 commits into
mainfrom
authux-bh

Conversation

@jeremy

@jeremy jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member

What

  • OnOperationGate queues instead of rejecting: the rate limiter waits for the refill it needs and the bulkhead polls (25–37ms, jittered) for a slot, within one shared 10s budget (Config.MaxWait, DefaultMaxWait) before failing.
  • Server Retry-After blocks are still honoured: a block that lifts within the budget is waited out; one that outlasts it is reported immediately with the remaining time rather than burning the budget.
  • Cancellation and the deadline are checked before every attempt, so an expired or canceled gate reserves nothing, and cancellation outranks the deadline error; a cancellation that lands while the slot is being taken releases it before the circuit breaker can reserve a half-open attempt. The give-up message reports the whole gate wait, not only the phase that gave up. With the circuit already open the gate fails fast (CircuitBreaker.Tripped, a read) before queueing for a token it would only be refused with; the reserving check still runs last.
  • A gate that gives up returns a resilience.GateError carrying the message and hint the user sees; it unwraps to the SDK sentinel (ErrRateLimited / ErrBulkheadFull) so errors.Is checks, the rate_limit code and exit 5 are unchanged. output.AsError converts it centrally (output.AsGateError), so commands that return SDK errors straight to the root render it the same way as the convertSDKError helpers. The files auto-detection probes no longer dereference a nil basecamp.AsError on a non-SDK first error.
  • State-file lock budget 100ms → 2s. The lock covers one read-modify-write of a small JSON file, so a wait that long means a wedged holder, not contention; timing out falls open and can write back a stale copy that resurrects a slot another live process had just released.
  • Circuit breaker logic untouched; every store error still fails open; Allow() / Acquire() keep their non-waiting semantics for callers that want them.

Why

The smoke test card "CLI bulkhead hard-fails parallel invocations: 10 of 80 calls survive at 10 workers" ran the CLI as 10 parallel workers × 8 sequential calls: 10 of 80 calls succeeded and the server never answered 429. The failures were the CLI's own gate.

Mechanism, from a re-exec'd reproduction of the same shape against a temp state dir (each child is one CLI invocation through the real GatingHooks with DefaultConfig):

ok=54 rejects=map[REJECT rate limit exceeded:26] total=80   (0.46s wall)
final state: tokens=0.41 pids=[]

54 = the 50-token bucket plus 0.46s of refill at 10/s; every rejection is the rate limiter, none the bulkhead (each worker's previous process is dead by the time its next one starts, so the PID table never fills). The token bucket is shared by every invocation on the machine through state.json, and the gate failed the instant it was empty. A real invocation spends several gated operations (account resolution, the command itself, pagination), which is how a 50-token bucket comes out as "10 of 80": the first wave drains it and everything after starves, each failing invocation having already consumed tokens for the operations it completed before the one that failed.

The SDK gives the hook exactly one call per operation and returns its error straight to the command (no retry, no backoff), so the CLI's hook is the only place waiting can happen.

Before / After

Before — a worker's later call, with the shared bucket already empty:

$ basecamp projects list
Error: Rate limit exceeded
Hint: Too many requests. Please wait before trying again.
$ echo $?
5

After — the same 80 calls all complete (the rate limiter paces the tail at 10/s, so the run takes ~3s instead of failing in 0.5s):

ok=80 rejects=map[] total=80   (3.08s wall)

After — when a gate does give up (15 workers pinned against 10 slots for longer than the budget):

$ basecamp projects list
Error: Too many concurrent basecamp processes (limit 10); waited 10s
Hint: Re-run, or lower parallelism.
$ echo $?
5

and for the rate limiter: Too many requests (client limit 10/s); waited 10s / Re-run, or lower parallelism.; for a server block that outlasts the budget: Rate limited by the server; retry after 42s / Wait 42s, then re-run.

Borrowed from Codex

Nothing here — Codex has no cross-process client-side gate; its rate limiting is the server's, retried with backoff inside the request loop, which is the same "wait, then fail with a reason" shape this adopts for the CLI's own gate.

Testing

Multi-process tests in internal/resilience/gate_test.go re-exec the test binary (TestHelperProcess) so each invocation is a real process with its own PID against a shared temp state dir:

  • TestGateQueuesTenParallelWorkersThroughTheDefaults — the smoke-test shape, 10 workers × 8 sequential calls through DefaultConfig: 80/80 succeed, peak live holders ≤ 10, under the 10s budget. Fails on main with 26 rate-limiter rejections.
  • TestGateQueuesOversubscribedInvocationsWithinTheSlotLimit — 15 simultaneous invocations holding 150ms each against 10 slots: 15/15 succeed, no child ever sees more than 10 holders, wall time ≥ 2 × hold (the overflow waited), slot table empty afterwards.
  • TestStoreLockContentionDoesNotLoseReleases — 12 processes churn acquire/release under the lock, then stay alive while the parent checks the slot table is empty (a leaked PID would otherwise be swept as dead). Verified to fail with the lock budget squeezed to 1ms; at the old 100ms it passed on this machine, so the raise is margin, not the smoke-test mechanism.
  • TestStoreLockTimeoutCoversAHeldLock holds the store lock from a second file description for 150ms: with the 2s budget an Update waits for the holder; with a 20ms budget it falls open before the holder releases.
  • Unit tests for RateLimiter.Wait (refill wait, give-up message with the effective request rate, short Retry-After waited out, long Retry-After reported at once and rounded up, jitter kept inside the budget, context cancellation without consuming a token, expired deadline without consuming a token), Bulkhead.Wait (queues until a slot frees, give-up message, cancellation and expired deadline without reserving), the hooks' shared budget with no leaked slot, fail-fast on an open circuit without spending a token, cancellation answered before the circuit, GateError unwrapping, and both convertSDKError and output.AsError carrying the gate message/hint with the rate-limit code and retryable.

Gates: make fmt-check vet lint (0 issues), go test -tags dev ./internal/..., -race -count=3 on internal/resilience, make check-surface check-skill-drift, make test-e2e check-naming check-bare-groups check-lint-lockstep check-smoke-coverage provenance-check tidy-check. make check stops at make test in this environment on pre-existing pty-dependent tests (internal/stdinarg, internal/tui/resolve, and their callers) that fail identically on a pristine d120b3a checkout here (/dev/ptmx is not a terminal in this shell); they do not depend on the changed packages and main CI is green.

A production smoke test ran the CLI as 10 parallel workers making 8
sequential calls each: 10 of 80 calls succeeded and the server never
answered 429. The failures were the CLI's own gate. Every invocation
shares one token bucket (50 tokens, refilled at 10/s) in the resilience
state file, and OnOperationGate rejected the moment the bucket was empty,
so the burst drained it in the first second and every later call exited
with "Rate limit exceeded". A re-exec'd reproduction of the same shape
against a temp state dir showed the mechanism directly: 80 calls in
0.46s, 54 allowed (50 tokens plus 0.46s of refill), 26 rejected by the
rate limiter, none by the bulkhead.

The rate limiter and bulkhead now queue instead of rejecting: a gated
operation waits for the refill it needs, or polls with jitter for a slot,
within one shared 10s budget before failing. A Retry-After block from the
server is still honoured; one that lifts within the budget is waited out,
one that outlasts it is reported at once with the remaining time. The
circuit breaker is unchanged, and every store error still fails open.

When a gate does give up it now says which limit it hit, how long it
waited, and what to do ("Too many concurrent basecamp processes (limit
10); waited 10s" / "Re-run, or lower parallelism."), carried by a
GateError that still unwraps to the SDK sentinel so errors.Is checks and
the rate-limit exit code hold.

The state-file lock budget rises from 100ms to 2s. The lock is held for
one read-modify-write of a small file, so a wait that long means a wedged
holder, not contention; a queue of polling invocations that timed out
would fall open and write back a stale copy, resurrecting a slot another
live process had just released.
Copilot AI balanced review requested due to automatic review settings September 13, 2026 01:24
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) labels Sep 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Jittered sleeps and lock acquisition can exceed the documented wait budget, and Retry-After guidance can understate the required delay.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Queues rate-limiter and bulkhead admission within a shared budget while preserving existing error codes and sentinel behavior.

Changes:

  • Adds bounded, jittered gate waiting and actionable GateError details.
  • Extends lock contention tolerance.
  • Adds unit and multi-process coverage.

[!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.

File summaries
File Description
internal/resilience/store.go Extends lock timeout.
internal/resilience/rate_limiter.go Adds token and Retry-After waiting.
internal/resilience/hooks.go Applies the shared wait budget.
internal/resilience/gate.go Defines gate errors and wait helpers.
internal/resilience/gate_test.go Tests queueing and contention.
internal/resilience/config.go Adds MaxWait configuration.
internal/resilience/bulkhead.go Adds slot polling.
internal/names/resolver.go Maps gate errors for resolvers.
internal/commands/projects.go Maps gate errors for commands.
internal/commands/projects_test.go Tests user-facing error conversion.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/resilience/rate_limiter.go Outdated
Comment thread internal/resilience/rate_limiter.go
Comment thread internal/resilience/store.go Outdated

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

ℹ️ 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 internal/resilience/rate_limiter.go Outdated
Comment thread internal/resilience/store.go Outdated
Comment thread internal/resilience/hooks.go Outdated
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T02:19:30.885143Z 836c236 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

The jitter that spreads retries could stretch a sleep past the gate's
deadline: a 9s Retry-After under a 10s budget passed the deadline check
and then slept up to 13.5s. The pause is now capped at the remaining
budget, as the bulkhead's already was.

A Retry-After reported to the user is rounded up to the next second, so
"Wait 40s, then re-run" never sends the re-run into the last 400ms of the
block.
Copilot AI review requested due to automatic review settings September 13, 2026 01:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Five unresolved moderate findings and one nit remain in deadline and cancellation handling.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (5)

internal/resilience/bulkhead.go:102

  • A poll that was capped to the remaining budget can wake at or after the deadline, and Acquire() is attempted before any deadline check. If a slot is then available, this returns nil after MaxWait has expired, so the shared budget is not actually enforced. Check the deadline before accepting a successful acquisition and release the slot if the acquisition completed after the deadline.
		if acquired, _ := b.Acquire(); acquired { //nolint:contextcheck // lock acquisition is context-independent by design
			return nil
		}

internal/resilience/bulkhead.go:100

  • When ctx is already canceled and a slot is free, Acquire() still reserves the PID and Wait returns nil because cancellation is only checked in pause. This violates the documented ctx.Err() result and consumes shared slot capacity even though the caller has canceled. Check ctx.Err() before each acquire attempt.
		if acquired, _ := b.Acquire(); acquired { //nolint:contextcheck // lock acquisition is context-independent by design

internal/resilience/bulkhead.go:105

  • If the bulkhead deadline expires while Acquire is blocked on the state lock, this branch returns GateError even when the caller's context has already been canceled. That reports a concurrency-limit failure instead of the cancellation error; give ctx.Err() precedence before constructing the gate error.
		if remaining <= 0 {
			return &GateError{

internal/resilience/hooks.go:79

  • The adjacent comment still describes this as having “no state reservation” and being safe to reject, but Wait now blocks and consumes a token when it succeeds. Update the comment so it does not mislead maintainers about the new queueing and token-consumption behavior.
	if h.rateLimiter != nil {
		if err := h.rateLimiter.Wait(ctx, deadline); err != nil {
			return ctx, err

internal/resilience/rate_limiter.go:123

  • When the jittered sleep is capped to remaining, it can sleep through the deadline even though the original refill delay fit within it (for example, a 95ms refill with a 100ms remainder and jitter over 5ms). The next loop calls take() before checking the deadline, so a token available at or after the deadline is consumed and this returns nil, violating the shared MaxWait bound. Reject an allowed result once the deadline has passed (and account for the consumed token).
		allowed, wait, blocked := rl.take() //nolint:contextcheck // lock acquisition is context-independent by design
		if allowed {
			return nil
  • Files reviewed: 10/10 changed files
  • Comments generated: 1
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

Comment thread internal/resilience/rate_limiter.go

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

ℹ️ 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 internal/resilience/bulkhead.go Outdated
Copilot AI review requested due to automatic review settings September 13, 2026 01:41
…adline

With the circuit open, an invocation no longer queues for a rate-limiter
token it would only be refused with: the gate reads the tripped state
first, without reserving anything, and the reserving check still runs
last as before.

A canceled caller now gets ctx.Err() before a slot is reserved or a token
consumed, and ahead of the deadline error when both apply, which is what
Wait documented. The test that guards the jitter cap pins the jitter to
its maximum instead of relying on scheduling slack.
@jeremy

jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

On the suppressed findings in the latest Copilot review:

  • bulkhead.go:100, bulkhead.go:105, and the equivalent in RateLimiter.Wait: fixed in 6d3796e. Both waits check ctx.Err() before reserving a slot or consuming a token, and cancellation takes precedence over the deadline error. Tests: TestBulkheadWaitReturnsCancellationWithoutReservingASlot, TestRateLimiterWaitReturnsCancellationWithoutConsumingAToken.
  • hooks.go:79: fixed in 6d3796e; the comment now describes the queueing and the token it consumes.
  • bulkhead.go:102 and rate_limiter.go:123 (a slot or token that becomes available at or just after the deadline is accepted): not doing this. The budget bounds how long the gate waits, not whether a late success counts; a wake that finds the slot free is a better outcome for the caller than a rejection for being one lock-wait late, and releasing a just-acquired slot to then fail would only add churn. The overshoot is bounded by one poll interval plus one lock acquisition.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical, moderate, and test-coverage findings remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/resilience/store.go:88

  • The new 2s value is not actually exercised by TestStoreLockContentionDoesNotLoseReleases: that test uses the production constant and completes its short churn, so it also passes with the old 100ms timeout (as the PR description notes). A regression here would stay green; make the timeout injectable or test-configurable and hold the lock for longer than 100ms but less than 2s before the competing update.

internal/resilience/rate_limiter.go:154

  • When TokensPerRequest is not 1, this reports the token refill rate as a request rate. For the supported configuration RefillRate=1 and TokensPerRequest=5, requests can be admitted at only 0.2/s, but the CLI reports a 1/s client limit. Compute RefillRate/TokensPerRequest here, or describe the value as tokens/s, so the diagnostic is accurate.
		Message:  fmt.Sprintf("Too many requests (client limit %g/s); waited %s", rl.config.RefillRate, waited.Round(time.Second)),
  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

Comment thread internal/resilience/gate.go
Comment thread internal/resilience/hooks.go

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

ℹ️ 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 internal/resilience/rate_limiter.go
Comment thread internal/resilience/bulkhead.go Outdated
… the circuit

The waits checked the budget only after a failed attempt, so a slot or
token that became free as the final capped sleep ended was taken just
past MaxWait. Both waits now check cancellation and the deadline before
each attempt; an expired gate reserves nothing. Jitter no longer pushes
a rate-limiter sleep to the deadline, which would have waited out a
refill or a Retry-After block only to reject at the wire.

OnOperationGate answers an already-canceled context before reading the
circuit, as the primitives already did.

The files auto-detection probes dereferenced basecamp.AsError on the
first probe error before converting it. A gate rejection is not a
*basecamp.Error (nor were the bare sentinels before this branch), so the
nil result panicked instead of rendering the message and hint; the probes
now treat a non-SDK error like any non-404 and convert it.
Copilot AI review requested due to automatic review settings September 13, 2026 01:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Gate-error conversion and lock-timeout test coverage require fixes; the rate display also needs correction.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

internal/resilience/gate.go:20

  • GateError is only handled by the two convertSDKError helpers, but the gating hook is installed for every SDK operation and several commands return SDK errors directly (for example, chat.go returns the list error). Those paths go through the root's output.AsError, which does not inspect GateError or its Hint; it can therefore lose the new hint and classify the wrapped sentinel as a generic API error instead of rate_limit/exit 5. Handle GateError in central conversion, or route every gated SDK error through convertSDKError.
    internal/resilience/rate_limiter.go:163
  • This reports RefillRate as requests per second, but that field is tokens per second and each request consumes TokensPerRequest. With a valid configuration such as 10 tokens/s and 5 tokens/request, the effective client limit is 2 requests/s, so the new user-facing limit is inaccurate. Report the effective request rate instead.

internal/resilience/store.go:88

  • The added contention test does not exercise the changed lock timeout: it uses the production constant and never holds .lock long enough to force acquireLock past either 100ms or 2s. As a result it also remains green if this change is reverted to 100ms, so it cannot catch the stale read-modify-write regression this constant is intended to prevent. Add a deterministic lock-holder/controllable timeout and assert the old budget fails while the new budget preserves the release.
const LockTimeout = 2 * time.Second
  • Files reviewed: 12/12 changed files
  • Comments generated: 0 new
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

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

ℹ️ 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 internal/commands/projects.go Outdated
A gate rejection reaches the user through any SDK operation, and many
commands return the SDK error straight to the root, whose output.AsError
knew nothing of GateError: the hint was dropped and the wrapped sentinel
rendered as a generic API error. AsError now converts it, and the two
convertSDKError helpers share that conversion.

The rate-limiter message reported RefillRate as requests per second; it
is tokens per second, so a request costing five tokens at ten per second
is a limit of two, which is what the message now says.

LockTimeout becomes a variable so a test can hold the store lock from a
second file description and show that the production budget waits for
the holder while a shorter one falls open over it.
Copilot AI review requested due to automatic review settings September 13, 2026 02:04
@jeremy

jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

On the three suppressed findings in the Copilot review of 2199275, all addressed in c6e10bb:

  • gate.go:20 (GateError only handled by convertSDKError): fixed. output.AsError now converts a GateError centrally (output.AsGateError), so commands that return the SDK error straight to the root render the same message and hint with the rate_limit code and exit 5; the two convertSDKError helpers share that conversion. TestAsErrorCarriesTheGateMessageAndHint covers the root path.
  • rate_limiter.go:163 (RefillRate reported as requests/s): fixed. The message reports RefillRate / TokensPerRequest; TestRateLimiterWaitReportsTheEffectiveRequestRate checks 10 tokens/s at 5 per request reads as a 2/s limit.
  • store.go:88 (contention test does not exercise the constant): fixed. LockTimeout is a variable and TestStoreLockTimeoutCoversAHeldLock holds the store lock from a second file description for 150ms: with the 2s budget an Update waits for the holder, with a 20ms budget it falls open before the holder releases. The existing multi-process test keeps covering lost releases end to end.

@github-actions github-actions Bot added the output Output formatting and presentation label Sep 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Two moderate findings remain around cancellation handling and total gate-wait reporting.

Review details

Suppressed comments (2)

internal/resilience/bulkhead.go:108

  • This start is local to the bulkhead phase, so the user-facing waited value omits any time already spent in RateLimiter.Wait under the shared deadline. For example, after 9s waiting for a token and 1s waiting on a full bulkhead, the error reports only waited 1s (or 0s after rounding), even though the gate exhausted its 10s budget. Carry the gate's original start/elapsed into the bulkhead error (or construct the final error in OnOperationGate) so the message reports the total gate wait.
	start := b.now()
	for {
		if err := ctx.Err(); err != nil {
			return err
		}
		remaining := deadline.Sub(b.now())
		if remaining <= 0 {
			return &GateError{
				Message:  fmt.Sprintf("Too many concurrent basecamp processes (limit %d); waited %s", b.config.MaxConcurrent, b.now().Sub(start).Round(time.Second)),

internal/resilience/hooks.go:103

  • After this queued bulkhead phase succeeds, OnOperationGate proceeds straight to the circuit breaker's reserving Allow() without another ctx.Err() check. If cancellation arrives while waiting for the slot, the gate can reserve a half-open circuit attempt and return success with a canceled context; release this slot and return the cancellation before the final reservation (and cover the transition with a test).
		if err := h.bulkhead.Wait(ctx, deadline); err != nil {
			return ctx, err
		}
		// Store marker in context so OnOperationEnd knows to release the slot
		ctx = context.WithValue(ctx, releaseKey{}, true)
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

The bulkhead's give-up message counted only its own phase, so a gate that
spent nine seconds queueing for a token and one on a full bulkhead said
it had waited a second. Both primitives now take the gate's start, and
the message covers the total.

A cancellation that lands while the slot is being taken no longer goes on
to reserve a half-open circuit attempt: the gate checks the context after
the slot phase, releases the slot, and returns the cancellation.
Copilot AI review requested due to automatic review settings September 13, 2026 02:15
@jeremy

jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

On the two suppressed findings in the Copilot review of c6e10bb, both addressed in 836c236:

  • bulkhead.go:108 (waited omits the rate-limiter phase): fixed. Both primitives take the gate start (waitSince) and the give-up message reports the whole gate wait. TestBulkheadWaitReportsTheWholeGateWait checks a gate that started 9s earlier reports "waited 9s".
  • hooks.go:103 (cancellation between the slot phase and the reserving circuit check): fixed. OnOperationGate checks the context after the slot phase, releases the slot, and returns the cancellation before Allow can reserve a half-open attempt. TestGatingHooksReleasesTheSlotWhenCancellationLandsBeforeTheCircuit drives that window with a context that reports cancellation from the moment this process holds a slot, and checks the slot is released and no attempt reserved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical and moderate findings affect cancellation safety, slot ownership, and error-chain preservation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (4)

internal/output/errors.go:75

  • This conversion drops the original GateError from Cause, so the central conversion severs the sentinel chain: errors.Is(output.AsError(gateErr), basecamp.ErrRateLimited) is false even though GateError.Unwrap promises that match. Other SDK conversions preserve their cause, and output.Error supports Unwrap; retain gateErr as the cause here.
	return &Error{
		Code:      CodeRateLimit,
		Message:   gateErr.Message,
		Hint:      gateErr.Hint,
		Retryable: true,
	}

internal/resilience/gate.go:12

  • The default rationale has incorrect rate math: a 50-token bucket refilling at 10 tokens/second takes about five seconds to refill, not “well under a second.” This understates the queueing time the 10-second bound is intended to absorb; please make the explanation quantitatively accurate.
// invocations (10 workers draining a 50-token bucket refill at 10/s in well
// under a second) and short enough that a genuinely saturated machine still

internal/resilience/hooks.go:110

  • After the post-bulkhead cancellation check, the gate calls Allow() without checking ctx.Err() again (and there is no such check when bulkhead is nil). A cancellation in this window can reserve a half-open circuit attempt while the gate still returns success, so a canceled operation may proceed and the reservation can remain outstanding if the SDK skips OnOperationEnd. Check cancellation immediately before the circuit reservation and release any acquired bulkhead slot on that path.
		// A cancellation that landed while the slot was being taken must not
		// go on to reserve a half-open attempt.
		if err := ctx.Err(); err != nil {
			_ = h.bulkhead.Release()
			return ctx, err
		}
		// Store marker in context so OnOperationEnd knows to release the slot
		ctx = context.WithValue(ctx, releaseKey{}, true)

internal/resilience/rate_limiter.go:137

  • The context/deadline check only happens before take, but take performs a context-independent Store.Update and can wait up to LockTimeout before committing the token. If cancellation or the gate deadline arrives while that update is blocked, allowed is true and this returns success anyway; with a bulkhead phase, the token is already consumed before the next phase rejects. Make the reservation conditional on a still-valid context/deadline at commit, or otherwise roll it back safely.
		allowed, wait, blocked := rl.take() //nolint:contextcheck // lock acquisition is context-independent by design
		if allowed {
			return nil
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

Comment thread internal/resilience/hooks.go
Comment thread internal/resilience/bulkhead.go
@jeremy
jeremy merged commit 7786d37 into main Sep 13, 2026
26 checks passed
@jeremy
jeremy deleted the authux-bh branch September 13, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations output Output formatting and presentation tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants