fix: remove 403 from key failover and cooldown on 401 - #27419
Conversation
f8b5dd7 to
a65dddc
Compare
a65dddc to
ee868a8
Compare
| } | ||
| switch keyPoolErr.Kind { | ||
| case keypool.ErrorKindPermanent: | ||
| case keypool.ErrorKindPermanent, keypool.ErrorKindUnauthorized: |
There was a problem hiding this comment.
When all the keys have failed with 401, we return a 502 Bad Gateway without a Retry-After. If every key is failing auth at the same time, it's almost always a real problem with the keys (revoked, rotated, or misconfigured), so a retry won't help and a Retry-After would just send clients back on a timer when nothing has changed.
We return 502 rather than the 401 because the auth failure is between the gateway and the upstream, on the centralized keys we manage on the server and the client never sees. Passing the 401 back would read as "your request was unauthorized" when the client's request was fine, and there's nothing on their end to fix. Resolving it is on the administrator, who rotates or reconfigures the keys.
A 401 used to take a key out of rotation until the server restarted, so a key that was only temporarily rejected would be blocked permanently. Now each key is retried once its cooldown expires and recovers on its own, transparent to the user and with no restart needed.
Documentation CheckUpdates Needed
Automated review via Coder Agents |
ee868a8 to
9636b15
Compare
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
| }, | ||
| expectedStatus: http.StatusForbidden, | ||
| expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid, keypool.KeyStateValid}, | ||
| expectedSeenKeys: []string{k0}, |
There was a problem hiding this comment.
On StatusForbidden we don't cycle to next key?
I may lack knowledge what provider use StatusForbidden and StatusUnauthorized for but I would assume next key would be tried if first failed with either of those statuses.
There was a problem hiding this comment.
This is what we agreed to: completely remove 403 Forbidden as a key failover status. A 403 is a per-request authorization decision (the key is valid but not permitted for this action/resource), not a key-health problem, so this status code is no longer handled, and we return the upstream error to the client. This is exactly the issue customers are seeing.
| const ( | ||
| // cooldownRateLimited means the current cooldown was triggered by a | ||
| // rate-limit response (HTTP 429). | ||
| cooldownRateLimited cooldownReason = iota |
There was a problem hiding this comment.
Is cooldownReason and this enum needed? If I understand correctly cooldownReason could simply be string. reasonRateLimited and reasonUnauthorized consts would be just used directly.
| // MarkTemporary marks the key unavailable for the given cooldown. Returns | ||
| // true on the valid -> temporary transition. | ||
| func (k *Key) MarkTemporary(cooldown time.Duration) bool { | ||
| return k.applyCooldown(cooldown, cooldownRateLimited) |
There was a problem hiding this comment.
Every time key is Marked Temporary it sets reason RateLimited?
There was a problem hiding this comment.
This is a bit of a hack: MarkTemporary is exported, but currently no production code needs it, all runtime marking goes through applyCooldown now with the correct reason. It's currently only used by the tests, which is why it stays exported for now. I kept it to keep this backport minimal and will unexport it (and move the tests internal) in a follow-up PR.
| expectedErr: &keypool.Error{Kind: keypool.ErrorKindRateLimited, RetryAfter: 60 * time.Second}, | ||
| }, | ||
| { | ||
| // Given: key-0: temporary (401), key-1: temporary (401). |
There was a problem hiding this comment.
I'm not sure how those 2 new test cases differ from all_permanent_exhausted and mixed_states_exhausted test cases?
The difference is I assume how setup is done, by status using markNextByStatus but how key become in given state is not important form test perspective?
I feel like those should be part of test that checks pool.MarkKeyOnStatus not here?
There was a problem hiding this comment.
All three validate the exhaustion Error.Kind, which is what differs between them:
all_permanent_exhausted: all keys permanent. Permanent is no longer a reachable state (nothing marks a key permanent anymore), but I'm keeping the status to avoid bigger changes, because this will need to be backported to previous versions.mixed_states_exhausted: mix of permanent and temporary status.all_unauthorized_exhausted: all temporary, all with the unauthorized reason. This is distinct frommixed_unauthorized_and_rate_limited_exhausted, where keys are all temporary but with mixed reasons, so rate-limit wins.
9636b15 to
74f9900
Compare

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
502with noRetry-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