Files
coder/aibridge/keypool/keymark_test.go
T
Susana Ferreira f1155ac4d7 feat: add automatic key failover for AI Bridge Anthropic (#24836)
## Description

Adds automatic key failover for centralized Anthropic provider. When a key pool is configured, each upstream call walks the pool and tries keys in order until one succeeds or the pool is exhausted. Keys are marked **temporary** on 429 (with cooldown from `Retry-After`) and **permanent** on 401/403. Errors that aren't key-specific don't trigger failover. Each agentic-loop iteration gets its own fresh walker, so a tool-call continuation can fail over independently of the initial request.

BYOK is unchanged: BYOK requests run as a single attempt with no failover.

## Changes

- `config.Anthropic` carries a `KeyPool`. `Key` remains for BYOK X-Api-Key set per interception.
- Blocking interceptor: walks the pool, marks keys on key-specific failures, returns on first success or non-failover error.
- Streaming interceptor: per-iteration walker. Pre-stream failures fail over to the next key; mid-stream errors are relayed as SSE events.
- New `keypool` error types: `TransientExhaustionError` (carries soonest cooldown) and `ErrPermanentExhaustion`. Replace the prior `ErrAllKeysExhausted`.
- Error responses now consistently include the outer `"type": "error"` field.

## Related Issues

Related to: https://github.com/coder/internal/issues/1446
Related to: https://linear.app/codercom/issue/AIGOV-197/aibridge-automatic-key-failover-for-bridged-and-passthrough-routes

## Follow-up PRs

- Bedrock multi-key support.
- Refactor provider vs interceptor config separation.
- Record the actually-used key in the interception credential hint after failover.

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
2026-05-07 14:57:44 +01:00

128 lines
3.3 KiB
Go

package keypool_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/quartz"
)
func TestMarkKeyOnStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
statusCode int
headers map[string]string
expectedReturn bool
expectedState keypool.KeyState
expectedCooldown time.Duration
}{
{
// 429 with standard Retry-After header (seconds).
name: "429_with_retry_after_seconds",
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"Retry-After": "5"},
expectedReturn: true,
expectedState: keypool.KeyStateTemporary,
expectedCooldown: 5 * time.Second,
},
{
// 429 with retry-after-ms header (milliseconds).
name: "429_with_retry_after_ms",
statusCode: http.StatusTooManyRequests,
headers: map[string]string{"retry-after-ms": "1500"},
expectedReturn: true,
expectedState: keypool.KeyStateTemporary,
expectedCooldown: 1500 * time.Millisecond,
},
{
// 429 without headers falls back to default cooldown.
name: "429_no_headers_uses_default",
statusCode: http.StatusTooManyRequests,
expectedReturn: true,
expectedState: keypool.KeyStateTemporary,
expectedCooldown: 60 * time.Second,
},
{
name: "401_marks_permanent",
statusCode: http.StatusUnauthorized,
expectedReturn: true,
expectedState: keypool.KeyStatePermanent,
},
{
name: "403_marks_permanent",
statusCode: http.StatusForbidden,
expectedReturn: true,
expectedState: keypool.KeyStatePermanent,
},
{
name: "200_does_not_mark",
statusCode: http.StatusOK,
expectedReturn: false,
expectedState: keypool.KeyStateValid,
},
{
name: "500_does_not_mark",
statusCode: http.StatusInternalServerError,
expectedReturn: false,
expectedState: keypool.KeyStateValid,
},
{
// 529 is the Anthropic overloaded status, handled by
// the circuit breaker, not key failover.
name: "529_does_not_mark",
statusCode: 529,
expectedReturn: false,
expectedState: keypool.KeyStateValid,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
clk := quartz.NewMock(t)
pool, err := keypool.New([]string{"key-0"}, clk)
require.NoError(t, err)
key, err := pool.Walker().Next()
require.NoError(t, err)
resp := &http.Response{
StatusCode: tc.statusCode,
Header: make(http.Header),
}
for k, v := range tc.headers {
resp.Header.Set(k, v)
}
got := keypool.MarkKeyOnStatus(
context.Background(),
key,
resp,
// 401 and 403 cases legitimately log at error
// level when marking a key permanent.
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
"test",
)
assert.Equal(t, tc.expectedReturn, got)
assert.Equal(t, tc.expectedState, key.State())
// Verify cooldown was set to the expected duration:
// advancing by exactly that amount returns the key
// to valid.
if tc.expectedCooldown > 0 {
clk.Advance(tc.expectedCooldown)
assert.Equal(t, keypool.KeyStateValid, key.State())
}
})
}
}