Skip to content

fix(gateway): skip failover sweep when request context is done#453

Merged
SantiagoDePolonia merged 1 commit into
mainfrom
fix/fallback-skip-on-context-cancel
Jul 1, 2026
Merged

fix(gateway): skip failover sweep when request context is done#453
SantiagoDePolonia merged 1 commit into
mainfrom
fix/fallback-skip-on-context-cancel

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a correctness bug in the translated-route failover path (surfaced during pre-release testing of #444/#450/#451): a client disconnect or expired deadline triggers a doomed failover sweep that trips healthy providers' circuit breakers.

When the client goes away mid-request, http.Client.Do returns context.Canceled, which the provider client maps to a 502. ShouldAttemptFailover treats any >=500 as retryable and never consulted the context, so the primary failure kicked off a full failover sweep over every configured target — on an already-dead context. Each attempt failed instantly but still:

  • recorded a ProviderAttempt and published a live audit event, and
  • charged a failure to that failover provider's circuit breaker (recordCircuitBreakerCompletion records a failure for any non-nil error).

So a burst of client disconnects could open circuit breakers on providers that were never actually unhealthy. Streaming is always affected; non-streaming is affected when retries are exhausted/disabled.

Fix

Guard both tryFailoverResponse and tryFailoverStream on ctx.Err():

  • short-circuit to the primary error before the sweep, and
  • break out of the loop if the client disconnects partway through.

A failover call on a done context can never succeed, so this removes no working behavior — it only avoids doomed attempts and the circuit-breaker pollution they cause.

User-visible impact

  • Client disconnects / deadline expiries no longer degrade failover-provider health or flood the audit/live-log layer with phantom failed attempts.
  • No change to failover behavior for genuine upstream failures on a live request.

Tests

  • TestTryFailoverResponseSkipsWhenContextCanceled / TestTryFailoverStreamSkipsWhenContextCanceled — assert no provider call happens on a canceled context (both fail without this patch, verified).
  • TestTryFailoverResponseAttemptsWhenContextLive — positive control: a live context still attempts failover (guards against over-blocking).

Verified locally: full build, go test -race ./internal/gateway/..., golangci-lint (0 issues), and the hot-path perf guard all pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Failover now stops immediately when a request has been canceled or has expired, returning the original error instead of continuing retries.
    • Streaming and non-streaming requests both now stop checking additional fallback targets once the client disconnects.
    • This helps avoid unnecessary work and reduces delays after a request is no longer active.

When a client disconnects (or the request deadline passes) mid-request,
`http.Client.Do` returns `context.Canceled`, which the provider client maps
to a 502. `ShouldAttemptFailover` treats any >=500 as retryable and never
consulted the context, so the primary failure triggered a full failover
sweep over every configured target on an already-dead context. Each doomed
attempt failed instantly but still recorded a provider attempt, published a
live audit event, and — because the circuit breaker records a failure for
any non-nil error — charged a spurious failure to each healthy failover
provider's breaker. A burst of client disconnects could therefore trip
breakers on providers that were never actually unhealthy. Streaming was
always affected.

Guard both `tryFailoverResponse` and `tryFailoverStream` on `ctx.Err()`:
short-circuit to the primary error before sweeping, and also break out of
the loop if the client disconnects partway through the sweep. A failover
call on a done context can never succeed, so this loses no working behavior.

Adds regression tests that fail without the guard and a positive control
confirming a live context still attempts failover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds context cancellation checks to tryFailoverResponse and tryFailoverStream in the gateway failover logic. Both functions now return the original error immediately if the context is already canceled and stop mid-loop if cancellation occurs during failover iteration. New tests validate these behaviors.

Changes

Failover context cancellation handling

Layer / File(s) Summary
Non-streaming failover context checks
internal/gateway/failover.go
tryFailoverResponse returns immediately with primaryErr when context is done, and breaks the failover loop early if context becomes done mid-iteration.
Streaming failover context checks
internal/gateway/failover.go
tryFailoverStream returns immediately with primaryErr when context is done, and breaks the failover loop early if context becomes done mid-iteration.
Test fixtures and cancellation/live-context tests
internal/gateway/failover_test.go
Adds imports, a stub failover resolver, a fixture constructor, and tests verifying failover is skipped on canceled context and attempted on live context for both response and stream paths.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Gateway
    participant tryFailoverResponse
    participant FailoverTarget

    Client->>Gateway: Request (context)
    Gateway->>tryFailoverResponse: primaryErr, ctx
    alt context canceled
        tryFailoverResponse-->>Gateway: return primaryErr immediately
    else context live
        loop for each failover target
            tryFailoverResponse->>FailoverTarget: attempt failover call
            alt context becomes done mid-loop
                tryFailoverResponse-->>Gateway: stop, return primaryErr
            else success
                FailoverTarget-->>tryFailoverResponse: response
                tryFailoverResponse-->>Gateway: return response, didFailover=true
            end
        end
    end
Loading

Poem

A rabbit checks the clock mid-hop,
if context's done, we simply stop.
No more burrows dug in vain,
we honor cancel, skip the pain.
Hop, test, and merge — no more delay! 🐇⏱️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: skipping gateway failover sweeps once the request context is done.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fallback-skip-on-context-cancel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 50.00000% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/gateway/failover.go 50.00% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge with minimal risk.

The change is small and scoped to failover control flow. Normal failover is preserved for live requests. Tests cover the canceled response path, canceled streaming path, and live response path.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • The team compared the base and head failover behavior across three scenarios under a canceled context, documenting that the base invoked response/stream failover on a canceled context while the head did not, that mid-sweep cancellation caused the base to continue to a second provider while the head stopped after the first provider once ctx.Err() became non-nil, and that live failover control invoked live-context failover once for both commits and returned a successful response.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Gateway as Gateway failover path
participant Primary as Primary provider
participant Failover as Failover providers

Client->>Gateway: Request with context
Gateway->>Primary: Primary provider call
Primary-->>Gateway: Error
Gateway->>Gateway: Check ctx.Err()
alt Context canceled or deadline expired
    Gateway-->>Client: Return primary error
    Note over Gateway,Failover: Skip failover sweep and provider attempt recording
else Context still live and error is retryable
    loop Failover selectors
        Gateway->>Gateway: Re-check ctx.Err()
        alt Context still live
            Gateway->>Failover: Attempt failover provider
            Failover-->>Gateway: Success or error
        else Context done mid-sweep
            Gateway-->>Client: Return last error
        end
    end
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant Gateway as Gateway failover path
participant Primary as Primary provider
participant Failover as Failover providers

Client->>Gateway: Request with context
Gateway->>Primary: Primary provider call
Primary-->>Gateway: Error
Gateway->>Gateway: Check ctx.Err()
alt Context canceled or deadline expired
    Gateway-->>Client: Return primary error
    Note over Gateway,Failover: Skip failover sweep and provider attempt recording
else Context still live and error is retryable
    loop Failover selectors
        Gateway->>Gateway: Re-check ctx.Err()
        alt Context still live
            Gateway->>Failover: Attempt failover provider
            Failover-->>Gateway: Success or error
        else Context done mid-sweep
            Gateway-->>Client: Return last error
        end
    end
end
Loading

Reviews (1): Last reviewed commit: "fix(gateway): skip failover sweep when r..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/gateway/failover_test.go`:
- Around line 84-108: Add a symmetric positive-control test for the stream
failover path by creating a new test alongside
TestTryFailoverStreamSkipsWhenContextCanceled that exercises tryFailoverStream
with a live context and verifies failover providers are attempted. Reuse
failoverTestFixture and a mock call function to assert called becomes true, the
returned stream is non-nil, and err is nil when the primary error is a retryable
ProviderError. This should mirror TestTryFailoverResponseAttemptsWhenContextLive
and cover the loop-break behavior in tryFailoverStream.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7f0707a9-abb9-4732-89ca-f7bc5adafa1d

📥 Commits

Reviewing files that changed from the base of the PR and between 3573295 and 5618768.

📒 Files selected for processing (2)
  • internal/gateway/failover.go
  • internal/gateway/failover_test.go

Comment on lines +84 to +108
func TestTryFailoverStreamSkipsWhenContextCanceled(t *testing.T) {
o, workflow := failoverTestFixture()

ctx, cancel := context.WithCancel(context.Background())
cancel()

primaryErr := core.NewProviderError("openai", http.StatusBadGateway, "context canceled", context.Canceled)
called := false
call := func(core.ModelSelector, string, string) (io.ReadCloser, string, string, error) {
called = true
return nil, "", "", core.NewProviderError("openai", http.StatusBadGateway, "unexpected failover call", nil)
}

stream, _, _, _, _, err := tryFailoverStream(ctx, o, workflow, "openai/gpt-4o", "openai", primaryErr, call)

if called {
t.Fatal("tryFailoverStream invoked a failover provider on a canceled context; it must short-circuit")
}
if stream != nil {
t.Fatal("tryFailoverStream returned a stream on a canceled context")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want the primary error wrapping context.Canceled", err)
}
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing symmetric positive-control test for stream failover.

Response path has both a canceled-context test and a live-context test (TestTryFailoverResponseAttemptsWhenContextLive), but the stream path only has the canceled-context test here. There's no TestTryFailoverStreamAttemptsWhenContextLive verifying tryFailoverStream still sweeps failover targets on a live context — leaving the loop-break guard at failover.go Lines 174-177 unexercised by tests.

✅ Suggested additional test
func TestTryFailoverStreamAttemptsWhenContextLive(t *testing.T) {
	o, workflow := failoverTestFixture()

	primaryErr := core.NewProviderError("openai", http.StatusInternalServerError, "primary boom", nil)
	called := false
	call := func(core.ModelSelector, string, string) (io.ReadCloser, string, string, error) {
		called = true
		return io.NopCloser(strings.NewReader("ok")), "openai", "gpt-5", nil
	}

	stream, _, _, _, _, err := tryFailoverStream(context.Background(), o, workflow, "openai/gpt-4o", "openai", primaryErr, call)

	if !called {
		t.Fatal("tryFailoverStream did not attempt failover on a live context")
	}
	if stream == nil || err != nil {
		t.Fatalf("failover result = (stream:%v err:%v), want (non-nil <nil>)", stream, err)
	}
}

As per path instructions, **/*_test.go: "Add or update tests for behavior changes, and ensure tests cover request translation, response normalization, error handling, default configuration, and provider-specific parameter mapping."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/gateway/failover_test.go` around lines 84 - 108, Add a symmetric
positive-control test for the stream failover path by creating a new test
alongside TestTryFailoverStreamSkipsWhenContextCanceled that exercises
tryFailoverStream with a live context and verifies failover providers are
attempted. Reuse failoverTestFixture and a mock call function to assert called
becomes true, the returned stream is non-nil, and err is nil when the primary
error is a retryable ProviderError. This should mirror
TestTryFailoverResponseAttemptsWhenContextLive and cover the loop-break behavior
in tryFailoverStream.

Source: Path instructions

@SantiagoDePolonia SantiagoDePolonia merged commit 4a951ae into main Jul 1, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants