Skip to content

Fix failover and health-monitor recovery for 429/5xx outages - #1373

Open
andreasfoo wants to merge 1 commit into
mainfrom
claude/fallback-primary-backup-test-rcxb1l
Open

Fix failover and health-monitor recovery for 429/5xx outages#1373
andreasfoo wants to merge 1 commit into
mainfrom
claude/fallback-primary-backup-test-rcxb1l

Conversation

@andreasfoo

@andreasfoo andreasfoo commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes critical bugs in the failover and health-monitor recovery paths that prevented traffic from returning to recovered services after transient outages (429 rate limits, 5xx errors, and Anthropic's 529 overloaded status). The issues affected both streaming and non-streaming request modes.

Key Changes

Failover Circuit Breaker Recovery

  • failover_dispatch.go: Changed isRetryableStatus() from an enumerated set to a range check (429 || 5xx), ensuring provider-specific codes like Anthropic's 529 and Cloudflare's 52x family trigger failover instead of being silently dropped
  • failover_dispatch.go: Added success recording for buffered 2xx responses in DispatchWithPriorityFailover(). Previously, only committed gates recorded successes, leaving half-open probe slots claimed forever on non-streaming traffic — preventing breaker closure and traffic return to T0
  • generic_stream_interceptor.go: Introduced sendEvent() helper that commits the failover gate before forwarding each client-bound SSE event. Streaming producers must signal on first real chunk; without this, multi-service rules buffered entire streams and never saw committed gates, making successful attempts invisible to the breaker

Health Monitor Recovery Window

  • health_monitor.go: Fixed NewHealthMonitor() to apply default recovery timeout when config specifies zero. Previously, absent health_monitor config sections yielded zero recovery windows, causing 429-marked services to "auto-recover" on the next request, defeating the documented rate-limit window
  • health_monitor.go: Updated UpdateConfig() to apply the same zero-value defaults for consistency

Service ID Parsing

  • service_id.go: Added ParseServiceID() function to split canonical "provider/model" IDs (inverse of FormatServiceID)
  • server.go: Fixed health probe function to use ParseServiceID() instead of splitting on ":" (which never matched the "/" format). This bug caused probes to fail for every service, pushing recovery windows forward indefinitely

Test Coverage & Virtual Models

  • failover_dispatch_test.go: Updated TestIsRetryableStatus() to verify the full 5xx range (529, 520, 599) and 429 are retryable
  • defaults_shared.go: Added virtual-fail-529 mock spec for Anthropic's overloaded_error, enabling end-to-end testing of the 529 failover path
  • error_integration_test.go: Updated count assertion for extended error specs (5 → 6)

End-to-End Timeline Tests

  • failover_timeline_test.go (new): Comprehensive e2e test suite scripting wall-clock failover scenarios using a fake clock:
    • TestFailoverTimeline_PrimaryDownThenRecover: Two-tier failover with 429/500/529 outages in streaming and non-streaming modes, verifying traffic returns after breaker recovery
    • TestFailoverTimeline_ThreeTierCascade: Three-tier cascade with multiple simultaneous failures, confirming requests flow through all tiers and honest error propagation when all are down
  • failover_timeline.go (new): SwitchableUpstream fixture providing runtime-flippable vmodel upstreams and SetupTimelineFailoverRoute() helper for multi-tier test harnesses

Documentation

  • .design/tier-routing.md: Clarified health recovery probe behavior and serviceID parsing format

Implementation Details

The root causes were:

  1. Enumerated status codes silently dropped provider-specific 5xx variants (529, 52x), breaking failover for exactly the outages they signal
  2. Buffered gate success recording only on commit, leaving non-streaming responses (c.JSON, buffering stream producers) invisible to the breaker
  3. Streaming gate commit timing in MCP interceptor — entire streams buffered before any commit, preventing half-open probes from closing breakers
  4. Zero-value health monitor defaults never applied, causing

…urn-to-primary

Timeline e2e test (vm1/vm2/vm3 switchable vmodel upstreams over a fake
clock: all up at 00:00 → T0 down at 00:05 → T0 back up → recovery)
exposed four bugs in the direct+fallback (tier) path; all fixed:

- isRetryableStatus enumerated 429/500/502/503/504 only, while error
  forwarding propagates the upstream status verbatim — Anthropic's 529
  overloaded_error (and any other 5xx like Cloudflare 52x) terminated
  the request instead of failing over. Retryable is now 429 + the whole
  5xx range.
- Breaker success was recorded only when the failover gate committed
  (streaming first chunk). Non-streaming 200s and the MCP interceptor
  path never fed a success, so half-open probe slots stayed claimed and
  a recovered primary could never close its breaker — traffic never
  returned to T0. Terminal buffered 2xx now records success.
- The MCP generic stream interceptor never raised CommitFirstChunk, so
  on multi-service rules the A→A v1 streaming path buffered the whole
  stream (no incremental delivery) and successes were invisible to the
  breaker. Its sendEvent seam now commits on the first client-bound
  event.
- The health-probe func split serviceID on ":" but the format is
  "provider/model" — every probe failed and pushed the rate-limit
  recovery window forward, so one 429 excluded a service forever.
  Added loadbalance.ParseServiceID and fixed the parse. Also default
  zero HealthMonitorConfig values (an absent health_monitor config
  section yielded a 0s rate-limit window).

Also: recognize Zhipu GLM 1305 (request-rate limit) alongside 1302 in
the rate-limit matchers; add vmodel virtual-fail-529 to the extended
error catalog; document the new behavior in .design/tier-routing.md.

New coverage: internal/protocoltest/failover_timeline{,_test}.go —
SwitchableUpstream fixtures + timeline scenarios for 429/500/529 ×
stream/nonstream on 2 tiers, plus a 3-tier cascade (all-down degrade,
recovery back to T0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmgavW26d4LwBi14PA3kjF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants