Skip to content

fix(failover): static-models allowlist providers never become unhealthy — no failover/circuit-breaking on repeated 429s #6

Description

@weselben

Summary

When a provider is configured with a static model list (providers.<name>.models) and models.configured_provider_models_mode: allowlist, that provider — and every model under it — can never become unhealthy based on observed failures. Repeated 429 Too Many Requests (quota exhausted, upstream rate limit, etc.) neither trigger configured failover rules nor trip any request-time health signal. The 429 is passed straight back to the client even when a working fallback model is configured and even when the failure is sustained for a long period.

In short, allowlist mode makes the provider permanently "healthy" from GoModel's perspective, regardless of what is happening at request time.

Real-world scenario

Using the Kimi provider: Kimi's upstream /models endpoint does not list embedding models, so the only way to expose them through GoModel is to declare them statically.

models:
  configured_provider_models_mode: allowlist

providers:
  kimi:
    base_url: https://api.moonshot.ai/v1
    api_key: ${KIMI_API_KEY}
    models:
      - kimi-k2-0711-preview
      - moonshot-v1-8k
      - kimi-embedding-001   # only reachable via static list

failover:
  rules:
    kimi-embedding-001:
      - openai/text-embedding-3-small

Observed behavior: when Kimi starts returning sustained 429s (e.g. usage quota exhausted), every request to kimi-embedding-001 keeps returning 429 to the client. The configured fallback openai/text-embedding-3-small is never attempted, and the provider never flips unhealthy, so the situation never self-heals.

Expected behavior: after the primary model returns 429, GoModel should attempt the configured failover chain; and a provider that consistently fails at request time should degrade in health independently of how its model inventory was discovered.

Root-cause analysis

Two cooperating gaps:

1. Static inventory is unconditionally marked healthy (and stays that way)

internal/providers/registry_init.go — in allowlist mode the upstream ListModels call is skipped entirely and the inventory is synthesized from the configured list (registry_init.go:400-419, configured_models.go:46-74). registry_init.go:213-216 then sets fetchedInventory.runtimeUpdates[*].lastModelFetchSuccessAt, which the status classifier reads as "healthy" (see TestClassifyProviderStatus_HealthyForAllowlistInventory in internal/admin/handler_providers_test.go:10-16).

Health, in the current model, is derived from model-fetch success — not from request success. In allowlist mode the model fetch is a local no-op that always "succeeds", so the health signal is decoupled from reality by construction.

2. Request-time safety nets don't cover 429, and the failover chain can be silently emptied

  • Circuit breaker (internal/llmclient/client.go:302-307, :330-333): shouldTripCircuitBreaker explicitly returns false for 429, and RecordFailure is skipped for 429 unless half-open. By design, repeated 429s never trip the breaker.
  • Failover selector resolution (internal/failover/resolver.go:328-349 resolveSelector): every failover candidate is validated against registry.GetModel(model), and the registry is allowlist-filtered (internal/app/app.go:602 wires the resolver with the already-filtered registry). Any failover target not present in the statically-listed inventory is silently dropped from the chain (manualSelectorsFor, resolver.go:154-172). With an empty chain, tryFailoverResponse short-circuits at internal/gateway/failover.go:69 (len(failovers) == 0) and the primary 429 is returned untouched — even though ShouldAttemptFailover (failover.go:262-307) correctly treats 429 as failover-eligible.

Net effect: 429 says "fail over", but the chain is empty and nothing ever marks the primary unhealthy.

Reproduction

  1. Configure a provider with a static models: list and configured_provider_models_mode: allowlist.
  2. Add a failover rule from one of those static models to a model on another provider.
  3. Have the primary return 429 (exhaust quota, or point base_url at a mock that always 429s — tests/e2e/failover_test.go:58-160 already has the harness; TestRateLimitFullySaturatedAliasReturns429_E2E is close but does not cover static-models mode).
  4. Observe: client gets raw 429, fallback never attempted, provider stays "healthy" in admin status endpoints indefinitely.

Proposed direction

Reframe health measurement around observed requests rather than inventory discovery:

  • Primary health signal = request outcomes. Provider / model health should be driven primarily by recent request outcomes (successes vs. sustained failures including 4xx/5xx, with 429 included by default or opt-in). Configurable thresholds should determine when a sustained failure pattern flips a model (or the provider's view of that model) to unhealthy.
  • Model-fetch health check used only as a fallback. When there are no request outcomes in the measurement window, fall back to inventory-fetch health. Static-list inventories naturally land in this branch and can keep their current "always healthy" default — that part is fine and is not the bug.
  • Known failure overrides the static default. When the request-time path does observe failures, it should override the always-healthy default and degrade the model/provider's health. The system should never ignore a known, recent failure just because the inventory came from a static list.
  • 429 in the breaker / failover decision. A 429 is a strong signal that the current route is saturated or the upstream is rate-limiting; repeated 429s should contribute to the request-time health signal and the per-model route gate, not be silently dropped before the failover chain.
  • Failover selector resolution must not silently empty the chain. If a configured failover target is dropped because it is missing from the source provider's allowlist-filtered inventory, log at warn level and/or surface it in the dashboard. Silently producing an empty chain from a user-configured rule is the worst failure mode here.
  • Configurable. A new flag (e.g. failover.treat_429_as_failure: true and/or a health.request_failure_threshold for 4xx-class errors) so users keep opt-out control.

Acceptance criteria

  • With static models + allowlist mode, N consecutive 429s on the primary model cause the configured failover chain to be attempted (e2e test extending tests/e2e/failover_test.go).
  • Provider / model health in admin status endpoints reflects request-time failures, not just inventory fetch success; static-list providers with no observed requests in the window keep their current "healthy" default.
  • A 429 in the request-time path contributes to model/route health and can trigger the failover chain.
  • Dropped failover selectors (inventory-filtered) surface in logs and/or the dashboard, never silently.
  • Behavior is documented in docs/advanced/configuration.mdx alongside configured_provider_models_mode.

Environment

  • GoModel @ 7c16a72 (main)
  • Kimi provider (Moonshot API), embedding model reachable only via static models: list

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions