🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

fix: remove 403 from key failover and cooldown on 401 - #27419

Merged
ssncferreira merged 4 commits into
mainfrom
ssncf/aibridge-keypool-permanent-fix
Jul 27, 2026
Merged

fix: remove 403 from key failover and cooldown on 401#27419
ssncferreira merged 4 commits into
mainfrom
ssncf/aibridge-keypool-permanent-fix

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@ssncferreira
ssncferreira force-pushed the ssncf/aibridge-keypool-permanent-fix branch 3 times, most recently from f8b5dd7 to a65dddc Compare July 23, 2026 11:57
@linear-code

linear-code Bot commented Jul 23, 2026

Copy link
Copy Markdown

AIGOV-421

AIGOV-533

@ssncferreira
ssncferreira force-pushed the ssncf/aibridge-keypool-permanent-fix branch from a65dddc to ee868a8 Compare July 23, 2026 12:11
}
switch keyPoolErr.Kind {
case keypool.ErrorKindPermanent:
case keypool.ErrorKindPermanent, keypool.ErrorKindUnauthorized:

@ssncferreira ssncferreira Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@ssncferreira
ssncferreira marked this pull request as ready for review July 23, 2026 12:19
@coder-tasks

coder-tasks Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Documentation Check

Updates Needed

  • docs/ai-coder/ai-gateway/providers.md - The Key failover section now matches the new key-pool behavior. Verified in this PR: the outdated WARNING callout was removed; 401 is documented as a temporary cooldown that recovers automatically; 403 is documented as returned to the caller unchanged (no failover); and the 502 exhaustion response now reads "when every key is in an authentication-failure cooldown" with the note that keys recover automatically and no Retry-After is sent.

Automated review via Coder Agents

@ssncferreira
ssncferreira force-pushed the ssncf/aibridge-keypool-permanent-fix branch from ee868a8 to 9636b15 Compare July 23, 2026 13:04
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Docs preview

Check 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.

@ssncferreira
ssncferreira requested review from johnstcn and pawbana July 23, 2026 13:05
},
expectedStatus: http.StatusForbidden,
expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid, keypool.KeyStateValid},
expectedSeenKeys: []string{k0},

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread aibridge/keypool/keypool.go Outdated
const (
// cooldownRateLimited means the current cooldown was triggered by a
// rate-limit response (HTTP 429).
cooldownRateLimited cooldownReason = iota

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.

Is cooldownReason and this enum needed? If I understand correctly cooldownReason could simply be string. reasonRateLimited and reasonUnauthorized consts would be just used directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, addressed in 74f9900

// 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)

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.

Every time key is Marked Temporary it sets reason RateLimited?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

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.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All three validate the exhaustion Error.Kind, which is what differs between them:

  1. 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.
  2. mixed_states_exhausted: mix of permanent and temporary status.
  3. all_unauthorized_exhausted: all temporary, all with the unauthorized reason. This is distinct from mixed_unauthorized_and_rate_limited_exhausted, where keys are all temporary but with mixed reasons, so rate-limit wins.

@ssncferreira
ssncferreira force-pushed the ssncf/aibridge-keypool-permanent-fix branch from 9636b15 to 74f9900 Compare July 27, 2026 09:04
@ssncferreira
ssncferreira requested a review from a team as a code owner July 27, 2026 09:04
@ssncferreira
ssncferreira requested a review from pawbana July 27, 2026 09:10

@johnstcn johnstcn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Stamp

@ssncferreira
ssncferreira merged commit dba45ce into main Jul 27, 2026
33 checks passed
@ssncferreira
ssncferreira deleted the ssncf/aibridge-keypool-permanent-fix branch July 27, 2026 11:06
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants