Files
coder/aibridge/keypool/keymark_test.go
T
Susana Ferreira dba45cede7 fix: remove 403 from key failover and cooldown on 401 (#27419)
## Problem

When a key returned 401 or 403, the pool marked it permanently
unavailable for the lifetime of that in-memory pool. This is bad UX: a
transient auth failure or a briefly-misconfigured key could take a key
out of rotation until the operator either restarted Coder or
reconfigured the key (even re-saving the same working value).

## Changes

- **403 removed from key failover**: it's a per-request authorization
failure, not a key-level problem, so it's surfaced to the caller as-is
without marking the key or failing over.
- **401 now applies a temporary cooldown** (like 429) so the key
recovers on its own instead of staying blocked.
- When every key is in an auth-failure cooldown, the pool reports a
`502` with no `Retry-After`, but the keys still recover automatically
once the cooldown elapses.

Closes
https://linear.app/codercom/issue/AIGOV-421/ai-gateway-a-quarantined-centralized-key-never-recovers-without-a
Closes
https://linear.app/codercom/issue/AIGOV-533/403s-misclassifying-keys-as-permanently-down-in-ai-gateway

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by
@ssncferreira
2026-07-27 12:06:01 +01:00

154 lines
4.4 KiB
Go

package keypool_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"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/coder/v2/aibridge/metrics"
codertestutil "github.com/coder/coder/v2/testutil"
"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
// expectedReason is the transition metric's reason label, or
// empty when no transition is expected.
expectedReason string
}{
{
// 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,
expectedReason: "rate_limited",
},
{
// 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,
expectedReason: "rate_limited",
},
{
// 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,
expectedReason: "rate_limited",
},
{
name: "401_marks_temporary",
statusCode: http.StatusUnauthorized,
expectedReturn: true,
expectedState: keypool.KeyStateTemporary,
expectedCooldown: 60 * time.Second,
expectedReason: "unauthorized",
},
{
name: "200_does_not_mark",
statusCode: http.StatusOK,
expectedReturn: false,
expectedState: keypool.KeyStateValid,
},
{
// 403 is a per-request authorization failure, so the key
// is not marked.
name: "403_does_not_mark",
statusCode: http.StatusForbidden,
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,
},
}
const providerName = "test-provider"
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
clk := quartz.NewMock(t)
reg := prometheus.NewRegistry()
m := metrics.NewMetrics(reg)
pool, err := keypool.New(providerName, []string{"key-0"}, clk, m)
require.NoError(t, err)
key, keyPoolErr := pool.Walker().Next()
require.Nil(t, keyPoolErr)
resp := &http.Response{
StatusCode: tc.statusCode,
Header: make(http.Header),
}
for k, v := range tc.headers {
resp.Header.Set(k, v)
}
got := pool.MarkKeyOnStatus(
context.Background(),
key,
resp,
slogtest.Make(t, nil),
)
assert.Equal(t, tc.expectedReturn, got)
assert.Equal(t, tc.expectedState, key.State())
gathered, err := reg.Gather()
require.NoError(t, err)
// A state transition records one event under its reason,
// and other reasons record none.
for _, reason := range []string{"rate_limited", "unauthorized"} {
if reason == tc.expectedReason {
assert.True(t, codertestutil.PromCounterHasValue(t, gathered, 1, "key_pool_state_transitions_total", providerName, reason))
} else {
assert.False(t, codertestutil.PromCounterGathered(t, gathered, "key_pool_state_transitions_total", providerName, reason))
}
}
// 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())
}
})
}
}