-
Notifications
You must be signed in to change notification settings - Fork 3
Failover and Hotel Routing
Model Hotel has two related but distinct routing concepts: transparent failover and hotel routing. Together they ensure requests succeed even when individual providers are unreliable.
- Architecture Overview
- Database Schema
- Failover Groups
- Hotel Routing
- Provider Selection Algorithm
- Transparent Failover
- Circuit Breaker
- API Reference
- Request Flow Sequence
┌────────────────────────────────────────────────────────────────┐
│ CLIENT REQUEST │
│ POST /v1/chat/completions │
│ model: "hotel/gpt-4o" │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ PROXY LAYER (internal/proxy) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 1. Model Resolution │ │
│ │ - Detect "hotel/" prefix │ │
│ │ - Extract base model name (e.g. "gpt-4o") │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 2. Failover Group Lookup (internal/failover) │ │
│ │ - Query groups WHERE display_model = 'gpt-4o' │ │
│ │ - Check cache first (5-minute TTL) │ │
│ │ - Validate: group_enabled = true │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 3. Provider Selection │ │
│ │ - Load priority_order [uuid1, uuid2, uuid3] │ │
│ │ - Load entry_enabled {uuid1: true, uuid2: false} │ │
│ │ - Filter: skip disabled entries/models/providers │ │
│ │ - Filter: skip open circuit breakers │ │
│ │ - Decrypt API keys (AES-256-GCM + Argon2id) │ │
│ │ - Build candidate list in priority order │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 4. Sequential Failover Loop │ │
│ │ - Try #1 -> retriable error -> #2 -> error -> #3 │ │
│ │ - Exponential backoff (100ms -> 200ms -> 400ms...) │ │
│ │ - RecordFailure on 5xx/429/401/403/404 │ │
│ │ - RecordSuccess on any non-failover (incl. 400) │ │
│ └────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────┐
│ UPSTREAM PROVIDER │
│ (OpenAI, Anthropic,│
│ Ollama, etc.) │
└────────────────────┘
CREATE TABLE model_failover_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
display_model TEXT NOT NULL UNIQUE, -- Base model name (e.g. "gpt-4o")
display_name TEXT, -- Human-readable label (optional)
description TEXT DEFAULT '', -- Human-readable description
priority_order JSONB NOT NULL, -- [uuid1, uuid2, uuid3] - ordered list of model UUIDs
entry_enabled JSONB DEFAULT '{}', -- {uuid1: true, uuid2: false} - per-entry toggle
group_enabled BOOLEAN DEFAULT true, -- Master toggle for entire group
auto_created BOOLEAN DEFAULT false, -- true if auto-generated during discovery sync
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);Field Details:
| Field | Type | Description |
|---|---|---|
display_model |
TEXT | Exact base model name used in hotel/ requests. Matched after stripping org prefixes (e.g. openai/, deepseek/). |
priority_order |
JSONB | Array of model UUIDs in sequential failover order. First UUID = highest priority. |
entry_enabled |
JSONB | Map of model_uuid → boolean. Disabled entries are skipped during routing but preserved in priority order. |
group_enabled |
BOOLEAN | Master switch. When false, the entire group is skipped during hotel/ resolution (returns 404). |
auto_created |
BOOLEAN |
true if generated by SyncAllModels during discovery. Manual groups have false. |
Indexes:
- Unique index on
display_model(enforces one group per base model name) - No additional indexes needed (lookups by
display_modelorid)
-- models table (references providers)
CREATE TABLE models (
id UUID PRIMARY KEY,
provider_id UUID REFERENCES providers(id) ON DELETE CASCADE,
model_id TEXT NOT NULL, -- Full model ID (e.g. "openai/gpt-4o")
enabled BOOLEAN DEFAULT true,
UNIQUE(provider_id, model_id)
);
-- providers table
CREATE TABLE providers (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
encrypted_key BYTEA NOT NULL, -- AES-256-GCM encrypted API key
key_nonce BYTEA NOT NULL, -- Argon2id-derived nonce
enabled BOOLEAN DEFAULT true
);A failover group represents one logical model backed by multiple providers. When you request hotel/gpt-4o, the proxy looks up the failover group with display_model = 'gpt-4o' and tries providers in the configured priority order.
Auto-Generation (Discovery Sync):
Failover groups are automatically created during model discovery sync (SyncAllModels):
- Query all enabled models from enabled providers
- Strip org prefixes (
openai/,deepseek/,meta-llama/, etc.) to get base name - Group providers that share the same base name
- Require 2+ providers - single-provider groups are automatically disabled
- Set
auto_created = true
// internal/failover/failover.go:SyncAllModels
func (r *Repository) SyncAllModels(ctx context.Context) (*SyncResult, error) {
// 1. Query enabled models
rows, _ := r.pool.Query(ctx, `
SELECT m.id, m.model_id, m.provider_id, p.name
FROM models m
JOIN providers p ON m.provider_id = p.id
WHERE m.enabled = true AND p.enabled = true
ORDER BY m.model_id, p.created_at ASC
`)
// 2. Strip prefixes and group by base name
baseToModels := make(map[string][]modelInfo)
for rows.Next() {
base := stripPrefix(modelID) // "openai/gpt-4o" → "gpt-4o"
baseToModels[base] = append(...)
}
// 3. Create groups with 2+ providers
for base, models := range baseToModels {
if len(models) <= 1 {
r.disableAutoGroup(ctx, base) // Disable single-provider groups
continue
}
priorityOrder := extractUUIDs(models)
r.UpsertWithConfig(ctx, base, priorityOrder, ...)
}
}Manual Creation (API):
POST /api/failover-groups
Content-Type: application/json
Authorization: Bearer $ADMIN_TOKEN
{
"display_model": "gpt-4o",
"display_name": "GPT-4 Omni",
"description": "Multi-provider failover for GPT-4o",
"entry_ids": [
"uuid-of-openai-gpt4o-model",
"uuid-of-deepseek-gpt4o-model",
"uuid-of-zai-gpt4o-model"
]
}Validation Rules:
- Minimum 2 entries required for creation
-
display_modelmust be unique (conflict if exists) -
display_nameanddescriptionare optional
Priority Ordering:
Providers are tried sequentially in priority_order array order. Reorder via API or UI drag-and-drop:
PUT /api/failover-groups/{group_id}
Content-Type: application/json
{
"priority_order": [
"uuid-3", // Now first (highest priority)
"uuid-1",
"uuid-2" // Now last
]
}Per-Entry Enable/Disable:
Individual entries can be disabled without removing them from the group:
{
"entry_enabled": {
"uuid-1": true,
"uuid-2": false, // Skipped during routing
"uuid-3": true
}
}Disabled entries:
- Appear strikethrough in UI
- Skipped during candidate selection
- Preserved in priority order for re-enablement
Group-Level Toggle:
Master switch disables entire group:
{
"group_enabled": false
}When group_enabled = false:
-
hotel/{display_model}requests return 404 - Group appears in UI but marked as disabled
- Auto-generated groups are disabled if provider count drops below 2
DELETE /api/failover-groups/{group_id}
Authorization: Bearer $ADMIN_TOKENCascade Behavior:
- Deleting a failover group does NOT delete underlying models or providers
- Only the failover configuration is removed
- Direct provider routing (e.g.
openai/gpt-4o) continues to work
Hotel routing is explicit multi-provider routing via the hotel/ prefix. It gives you fine-grained control over which providers are used and in what order.
Prefix any model ID with hotel/:
curl -X POST http://localhost:8081/v1/chat/completions \
-H "Authorization: Bearer $PROXY_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hotel/gpt-4o",
"messages": [{"role": "user", "content": "Hello!"}]
}'This resolves to the failover group named gpt-4o and tries providers in priority order.
Exact Base Name Matching:
Hotel routing matches exact base names after stripping organization prefixes:
| Request Model | Strips To | Matches Group |
|---|---|---|
hotel/gpt-4o |
gpt-4o |
display_model = 'gpt-4o' |
hotel/llama-3.1-8b |
llama-3.1-8b |
display_model = 'llama-3.1-8b' |
hotel/gpt-4o-mini |
gpt-4o-mini |
display_model = 'gpt-4o-mini' (different group) |
Base Name Extraction:
The code does not maintain a hardcoded prefix list. Instead, normalizeBaseModel() in internal/failover/failover.go strips everything before the last / in the model ID, then lowercases the result:
func normalizeBaseModel(modelID string) string {
if idx := strings.LastIndex(modelID, "/"); idx >= 0 {
return strings.ToLower(modelID[idx+1:])
}
return strings.ToLower(modelID)
}This handles any org prefix depth (e.g. zai-org/glm-5.1, zai-org/anthracite-org/magnum-v4-72b) without needing to enumerate known providers. The SQL in SyncForModel uses SUBSTRING(m.model_id FROM '[^/]+$') to match on the same leaf segment.
Example: openai/gpt-4o, deepseek/gpt-4o, and zai-org/gpt-4o all map to the same failover group gpt-4o.
// internal/proxy/resolve.go:resolveHotelModel
func (h *Handler) resolveHotelModel(ctx context.Context, displayModel string) ([]modelCandidate, resolveTimings, error) {
// 1. Lookup failover group (with cache)
fg, err := h.failoverRepo.GetByModel(ctx, displayModel)
if err != nil {
return nil, t, err // 404 to client
}
// 2. Check group enabled
if !fg.GroupEnabled {
return nil, t, fmt.Errorf("failover group disabled")
}
// 3. Collect enabled model UUIDs
enabledModelIDs := make([]uuid.UUID, 0, len(fg.PriorityOrder))
for _, modelUUID := range fg.PriorityOrder {
entryEnabled := true
if val, ok := fg.EntryEnabled[modelUUID.String()]; ok {
entryEnabled = val
}
if entryEnabled {
enabledModelIDs = append(enabledModelIDs, modelUUID)
}
}
// 4. Batch load models and providers
models, _ := h.modelRepo.GetByIDs(ctx, enabledModelIDs)
providers, _ := h.providerRepo.GetByIDs(ctx, uniqueProviderIDs)
// 5. Build candidate list (filtering disabled, circuit breakers)
candidates := make([]modelCandidate, 0)
for _, modelUUID := range fg.PriorityOrder {
// Skip if entry disabled
if !entryEnabled { continue }
// Skip if model/provider disabled
if !m.Enabled || !prov.Enabled { continue }
// Skip if circuit breaker open
if cbEnabled && h.circuitBreaker.IsOpen(prov.ID) { continue }
// Decrypt API key
apiKey, _ := auth.DecryptCached(prov.EncryptedKey, ...)
candidates = append(candidates, modelCandidate{model: m, provider: prov, apiKey: apiKey})
}
return candidates, t, nil
}
Failover Groups page - showing groups with enabled status, model entries, and priority badges
The provider selection algorithm builds an ordered list of candidates from a failover group:
┌─────────────────────────────────────────────────────────────────────────────┐
│ INPUT: FailoverGroup with priority_order = [uuid1, uuid2, uuid3, uuid4] │
│ entry_enabled = {uuid1: true, uuid2: false, uuid3: true, uuid4: true}│
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 1: Filter by entry_enabled │
│ Candidates: [uuid1, uuid3, uuid4] (uuid2 skipped - disabled) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 2: Batch load models & providers │
│ - Load models: GetByIDs([uuid1, uuid3, uuid4]) │
│ - Load providers: GetByIDs([prov1, prov3, prov4]) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 3: Filter by enabled flags │
│ - Skip if model.enabled = false │
│ - Skip if provider.enabled = false │
│ Candidates after filter: [uuid1, uuid3] (uuid4 skipped - provider disabled)│
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 4: Filter by circuit breaker │
│ - Skip if circuitBreaker.IsOpen(providerID) = true │
│ Candidates after filter: [uuid1] (uuid3 skipped - circuit open) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 5: Decrypt API keys │
│ - Decrypt each provider's encrypted_key (AES-256-GCM + Argon2id) │
│ - Skip if decryption fails (wrong MASTER_KEY) │
│ Final candidates: [uuid1 with decrypted API key] │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ OUTPUT: []modelCandidate in priority order │
│ - If empty: return error "no available provider" │
│ - If non-empty: pass to failover loop │
└─────────────────────────────────────────────────────────────────────────────┘
Key Properties:
-
Priority order preserved: Candidates are always in
priority_ordersequence - Graceful degradation: Disabled/failed entries are skipped, not fatal
- Circuit breaker integration: Unhealthy providers filtered before failover loop
- Batch efficiency: Single DB query for all models, single query for all providers
Transparent failover happens automatically when an upstream provider request fails with a retriable error. It is the proxy's mechanism to ensure requests succeed even when individual providers are flaky or misconfigured.
Failover is sequential - providers are tried one at a time, in order:
- Client requests a model (e.g.
hotel/gpt-4ooropenai/gpt-4o) - The proxy resolves a list of candidate providers
- The first candidate is tried
- If the upstream returns a retriable error, the proxy moves to the next candidate
- This continues until the request succeeds or all candidates are exhausted
- If all candidates fail, a generic 502 error is returned to the client
Failover triggers on:
| Condition | Behavior | Source |
|---|---|---|
| Any 5xx status (status >= 500) | Always triggers failover | Server errors indicate upstream problems |
| HTTP 429 (rate limit) | Triggers failover if failover_on_rate_limit=true (default) |
Configurable via settings |
| HTTP 401 / 403 (auth errors) | Always triggers failover | Stale or rotated API keys |
| HTTP 404 (model not found at provider) | Always triggers failover | Stale DB entry, overloaded provider returning not_found |
| Timeouts | Always triggers failover | Network timeout, connection refused, DNS failure |
Failover does not trigger on:
| Condition | Behavior | Reason |
|---|---|---|
| HTTP 400 (bad request / param rejection) | Triggers auto-retry on SAME provider with stripped parameters | Parameter rejection may be due to unsupported/invalid parameters; retry removes rejected params and tries again on the same provider |
| HTTP 404 (model not found at provider) | Always triggers failover | Stale DB entry, overloaded provider returning not_found |
| Other 4xx errors (422, etc.) | Returned to client as-is | Client errors (validation errors) are not retriable |
| Slow responses | No latency-based failover exists | There is no response-time threshold that triggers failover |
Each failover attempt includes an exponential backoff delay to prevent overwhelming failing providers:
- Base delay: 100ms
- Maximum delay: 2s
- Jitter: [0, base) (adds 0 to base-1ms random offset, not symmetric)
- First attempt (attempt=0): No delay
- Client disconnection during backoff: Returns HTTP 499
The delay doubles with each subsequent attempt (100ms → 200ms → 400ms → 800ms → 1.6s → capped at 2s), with jitter applied to prevent thundering herd issues.
// internal/proxy/proxy.go:failoverBackoff
func failoverBackoff(base, capacity time.Duration, attempt int) time.Duration {
exp := time.Duration(float64(base) * math.Pow(2, float64(attempt-1)))
if exp > capacity {
exp = capacity
}
jitter := time.Duration(rand.Int64N(int64(base)))
return exp + jitter
}By default, a 429 rate-limit response from one provider triggers failover to the next. This is controlled by the failover_on_rate_limit setting (default: true). If you prefer to return 429 errors directly to the client rather than retrying on another provider, set this to false.
The proxy evaluates whether an upstream response should trigger failover based on the HTTP status code:
// internal/proxy/resolve.go:shouldFailover
func (h *Handler) shouldFailover(ctx context.Context, statusCode int) bool {
if statusCode >= 500 {
return true // 5xx → failover immediately
}
if statusCode == 429 {
enabled := h.settingsRepo.GetBool(ctx, "failover_on_rate_limit", true)
return enabled // 429 → failover only if setting enabled
}
if statusCode == 401 || statusCode == 403 {
return true // Auth errors → failover immediately
}
if statusCode == 404 {
return true // Model not found at provider → failover (stale DB, overloaded provider)
}
return false // Other status codes → no failover
}Note: failover only proceeds if there are remaining candidates. If the last candidate returns a retriable error, that error is returned to the client directly.
The proxy uses the request_timeout setting (default: 1m) for all upstream requests:
- Non-streaming requests: Uses the setting value directly
- Streaming requests: Uses 10× the timeout value (10 minutes by default)
This setting is configurable at runtime via the Settings UI or the admin API.
The request body is cached in the context at the start of the failover loop. This allows the body to be re-read for each retry without requiring the client to resend it. The cached body is used when building upstream requests for each failover attempt.
For streaming requests, the proxy reads ahead to confirm the first token arrives before committing the stream to the client. This prevents the client from receiving a broken or partial stream from a provider that responded 200 but fails to produce content.
-
Timeout: Configurable via
ttft_timeoutsetting (default:1m0s). If the provider fails to produce a token within this timeout, the request fails over to the next provider. -
Measurement: The actual TTFT measured during the probe is stored in
logEntry.ttftMsfor request logging. -
Disable: Set
ttft_timeoutto0sto disable the probe (immediate stream commit, backward-compatible behavior).
Once streaming begins, a stall watchdog monitors for silence in the SSE stream:
-
Timeout: Configurable via
stream_stall_timeoutsetting (default:30s). If no data arrives within this window, the connection is terminated and the circuit breaker records a failure. - Progressive extension: After 50 chunks, the effective stall timeout is multiplied by 3 (default: 30s × 3 = 90s) to tolerate tool-call pauses and long reasoning chains.
-
Disable: Set
stream_stall_timeoutto0sto disable the watchdog.
The resolved_model_id field in request logs records the actual upstream model ID used (e.g. openai/gpt-4o), which may differ from the requested hotel/ name (e.g. hotel/gpt-4o). For non-hotel requests where the requested and resolved model are the same, this field is NULL.
Each request produces one request_logs row containing the final attempt's data:
-
Intermediate failover attempts are logged to stdout only (via
log.Printf), not to the database - The final
request_logsrecord includes thefailover_attemptnumber indicating how many attempts were made (0-indexed) - A request that succeeds on the first try has
failover_attempt = 0 - The
resolved_model_idfield records the actual upstream model ID used
This means the request_logs table always has exactly one row per incoming request, and the failover_attempt field tells you whether failover occurred and how many providers were tried before the final result.
Intermediate attempts appear in stdout logs like:
[proxy] failover triggered: attempt=1 provider=<uuid> status=429
[proxy] failover triggered: attempt=2 provider=<uuid> status=502
During proxy requests, token usage is tracked against the virtual key used for authentication. Token counts (prompt tokens, completion tokens, and total tokens) are extracted from the upstream provider's response and recorded in the request_logs table. This enables usage tracking and quota enforcement per virtual key.
The proxy includes a per-provider circuit breaker that prevents sending requests to providers that are consistently failing, giving them time to recover.
Success Recording: The circuit breaker records success for any response that does NOT trigger failover. This includes 400 errors (parameter rejections), since these indicate the provider successfully processed the request but rejected it due to invalid parameters - not a provider health issue. Only 5xx, 429 (when enabled), 401/403, and timeouts are recorded as failures.
The circuit breaker operates as a three-state machine:
┌───────────────────────────────┐
│ CLOSED │ ◄─────────┐
│ (Normal) │ │
│ │ │
│ • Requests flow through | │
│ • Failures increment counter | │
│ • Success resets counter | │
└──────┬────────────────────────┘ │
│ │
│ failures >= threshold (default: 5) │
▼ │
┌────────────────────────────────┐ │
│ OPEN │ │
│ (Failing) │ │
│ │ │
│ • All requests blocked | │
│ • Skipped during selection | │
│ • After cooldown -> HALF-OPEN | │
│ • Cooldown: 60s (configurable) | │
└──────┬─────────────────────────┘ │
│ │
│ Cooldown elapsed │
▼ │
┌───────────────────────────────────────┐ │
│ HALF-OPEN | │
│ (Testing) | │
│ | │
│ • One probe request allowed | │
│ • Success -> CLOSED (resets counter) | │
│ • Failure -> OPEN (fresh cooldown) | │
└──────┬────────────────────────────────┘ │
└────────────────────────────────────┘
// internal/failover/circuitbreaker.go
type CircuitBreaker struct {
mu sync.RWMutex
circuits map[string]*circuit // provider UUID → circuit state
Threshold int // Default: 5 consecutive failures
Cooldown time.Duration // Default: 60s
HalfOpenMaxProbes int // Default: 1 success to close
}
type circuit struct {
state State // Closed, Open, or HalfOpen
consecutiveFails int
openedAt time.Time // When transitioned to Open
halfOpenProbes int // Successful probes in HalfOpen
}State Transitions:
| Current State | Trigger | Next State | Action |
|---|---|---|---|
| Closed | Failure count >= threshold | Open | Block all requests, start cooldown |
| Closed | Success | Closed | Reset failure counter |
| Open | Cooldown elapsed | HalfOpen | Allow one probe request |
| HalfOpen | Probe success | Closed | Resume normal operation |
| HalfOpen | Probe failure | Open | Restart cooldown |
Open circuits are filtered out during provider resolution:
// internal/proxy/resolve.go:resolveHotelModel
cbEnabled := h.settingsRepo.GetBool(ctx, "circuit_breaker_enabled", true)
if cbEnabled && h.circuitBreaker.IsOpen(prov.ID) {
debuglog.Info("resolve: skipping candidate: circuit breaker open")
continue // Skip this provider entirely
}This means providers with open circuits are never tried at all - they are skipped during the initial provider selection phase, not during the failover process.
Client Disconnections:
Client disconnections (context.Canceled) and deadlines (context.DeadlineExceeded) do NOT count as provider failures:
// internal/proxy/proxy.go:ChatCompletions
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
if circuitBreakerEnabled {
h.circuitBreaker.RecordFailure(candidate.provider.ID)
}
} else {
debuglog.Info("proxy: client disconnected - not counting as provider failure")
}These are treated as user-side cancellations rather than provider health issues.
| Setting | Type | Default | Description |
|---|---|---|---|
circuit_breaker_enabled |
bool | true |
Global kill switch. When disabled, no circuit tracking occurs. |
circuit_breaker_threshold |
int | 5 |
Number of consecutive failures before a circuit opens. Only effective when value > 0. |
circuit_breaker_cooldown |
duration | 60s |
How long an open circuit stays open before transitioning to half-open. |
ttft_timeout |
duration | 1m0s |
Time-to-first-token probe timeout for streaming requests. Set to 0s to disable. |
stream_stall_timeout |
duration | 30s |
Maximum silence during streaming before termination. After 50 chunks, timeout is multiplied by 3. Set to 0s to disable. |
All three settings are runtime-configurable via the database and take effect immediately.
Note: These settings are stored in the
settingstable but currently have no UI controls in the Settings page. They can be changed via direct API calls toPUT /api/settingsor direct database updates.
The circuit breaker publishes real-time events via the SSE event bus:
| Event | When |
|---|---|
circuit_breaker.open |
A provider's circuit transitions from Closed to Open |
circuit_breaker.half-open |
Circuit transitions from Open to Half-Open |
circuit_breaker.closed |
Circuit recovers (Half-Open → Closed) |
These events appear in the real-time sidebar and dashboard.
// internal/failover/circuitbreaker.go:publishEvent
func (cb *CircuitBreaker) publishEvent(providerID uuid.UUID, state string, c *circuit) {
events.Publish(events.Event{
Type: "circuit_breaker." + state,
Severity: cb.severityForState(state),
Message: fmt.Sprintf("Provider %s circuit breaker: %s", providerID, state),
Metadata: map[string]interface{}{
"provider_id": providerID.String(),
"state": state,
"consecutive_fails": c.consecutiveFails,
},
})
}GET /api/failover-groups
Returns all failover groups with their entries and configuration.
Response (200):
{
"groups": [
{
"id": "uuid-of-group",
"display_model": "gpt-4o",
"display_name": "GPT-4 Omni",
"description": "Multi-provider failover",
"group_enabled": true,
"auto_created": true,
"entries": [
{
"model_uuid": "uuid-1",
"model_id": "openai/gpt-4o",
"provider_id": "uuid-prov-1",
"provider_name": "OpenAI",
"display_name": "GPT-4o",
"enabled": true,
"context_length": 128000,
"owned_by": "openai"
},
{
"model_uuid": "uuid-2",
"model_id": "deepseek/gpt-4o",
"provider_id": "uuid-prov-2",
"provider_name": "DeepSeek",
"display_name": "GPT-4o",
"enabled": false,
"context_length": 64000,
"owned_by": "deepseek"
}
],
"total_tokens": 1234567,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-20T14:45:00Z"
}
],
"last_synced_at": "2024-01-20T14:45:00Z"
}GET /api/failover-groups/{id}
Response (200): Same structure as single group in list response.
Response (404): {"error": "failover group not found"}
POST /api/failover-groups
Request:
{
"display_model": "llama-3.1-8b",
"display_name": "Llama 3.1 8B",
"description": "Custom failover group for Llama 3.1 8B",
"entry_ids": [
"uuid-model-1",
"uuid-model-2",
"uuid-model-3"
]
}Validation:
- Minimum 2 entries required
-
display_modelmust be unique - All
entry_idsmust be valid model UUIDs
Response (201): Full group object (same as GET response).
Response (400): Validation error (e.g. "at least 2 entries required").
Response (409): {"error": "failover group with display_model '...' already exists"}
PUT /api/failover-groups/{id}
Request:
{
"display_name": "Updated Name",
"description": "Updated description",
"group_enabled": true,
"priority_order": ["uuid-3", "uuid-1", "uuid-2"],
"entry_enabled": {
"uuid-1": true,
"uuid-2": false,
"uuid-3": true
}
}All fields are optional. Only provided fields are updated.
Response (200): Updated group object.
Response (400): Validation error (e.g. "at least one entry must be enabled").
Response (404): Group not found.
DELETE /api/failover-groups/{id}
Response (204): No content (success).
Response (404): Group not found.
POST /api/failover-groups/sync
Manually trigger failover group synchronization with current model discovery state.
Response (200):
{
"disabled_groups": [
{
"display_model": "gpt-4o-mini",
"reason": "only 1 enabled provider (need 2+ for failover)",
"provider_count": 1,
"provider_names": ["OpenAI"]
}
],
"sync_errors": []
}GET /api/failover-groups/by-model/{model_uuid}
Returns brief info about the failover group containing a specific model UUID.
Response (200):
{
"id": "uuid-of-group",
"display_model": "gpt-4o",
"position": 2,
"total_entries": 3
}Response (404): {"error": "model not in any failover group"}
GET /api/failover-groups/candidates
Returns all enabled models that can be added to failover groups.
Response (200):
[
{
"model_uuid": "uuid-1",
"model_id": "openai/gpt-4o",
"provider_id": "uuid-prov-1",
"provider_name": "OpenAI",
"display_name": "GPT-4o",
"context_length": 128000,
"owned_by": "openai"
}
]┌────────────┐
│ CLIENT │
└─────┬──────┘
│ POST /v1/chat/completions
│ model: "hotel/gpt-4o"
▼
┌───────────────────────────────────────────────────────┐
│ PROXY (internal/proxy/proxy.go) │
│ 1. Parse request body │
│ 2. Detect "hotel/" prefix -> extract "gpt-4o" │
│ 3. Call resolveHotelModel(ctx, "gpt-4o") │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ FAILOVER LOOKUP (internal/proxy/resolve.go) │
│ 1. Check cache: GetCachedFailoverByModel("gpt-4o") │
│ - Cache hit (TTL: 5min) -> return cached group │
│ - Cache miss -> query DB │
│ 2. DB: SELECT * FROM model_failover_groups │
│ WHERE display_model='gpt-4o' │
│ 3. Validate: group_enabled = true │
│ 4. Validate: priority_order not empty │
└───────────────────────────────────────────────────────┘
│
│ FailoverGroup{
│ priority_order: [uuid1, uuid2, uuid3],
│ entry_enabled: {uuid1: true, uuid2: false}
│ }
▼
┌───────────────────────────────────────────────────────┐
│ CANDIDATE SELECTION (internal/proxy/resolve.go) │
│ 1. Filter by entry_enabled -> [uuid1, uuid3] │
│ 2. Batch load models: GetByIDs([uuid1, uuid3]) │
│ 3. Batch load providers: GetByIDs([prov1, prov3]) │
│ 4. Filter: skip disabled models/providers │
│ 5. Filter: skip circuit breaker open │
│ 6. Decrypt API keys via DecryptCached(...) │
│ 7. Build candidates: []modelCandidate │
└───────────────────────────────────────────────────────┘
│
│ candidates = [
│ {model: m1, provider: p1, apiKey: "sk-..."},
│ {model: m3, provider: p3, apiKey: "sk-..."}
│ ]
▼
┌───────────────────────────────────────────────────────┐
│ FAILOVER LOOP (internal/proxy/proxy.go) │
│ for attempt, candidate := range candidates { │
│ │
│ if attempt > 0 { │
│ backoff = failoverBackoff(100ms, 2s, attempt) │
│ time.After(backoff) │
│ } │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ ATTEMPT 0: candidate #1 (uuid1) │ │
│ │ - Build upstream POST /chat/completions │ │
│ │ - Set auth headers (Bearer or x-api-key) │ │
│ │ - Inject stream_options for streaming │ │
│ │ - Send with timeout (1m/10m) │ │
│ │ │ │
│ │ Response: HTTP 502 Bad Gateway │ │
│ │ │ │
│ │ shouldFailover(502) = true (5xx) │ │
│ │ hasMoreCandidates = true │ │
│ │ -> Continue to next candidate │ │
│ │ │ │
│ │ Circuit breaker: RecordFailure(uuid1) │ │
│ │ consecutiveFails = 1, threshold = 5 │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ ATTEMPT 1: candidate #2 (uuid3) │ │
│ │ - Backoff: 100ms + jitter │ │
│ │ - Build upstream request │ │
│ │ - Send request │ │
│ │ │ │
│ │ Response: HTTP 200 OK │ │
│ │ │ │
│ │ shouldFailover(200) = false │ │
│ │ -> Dispatch to stream/non-stream handler │ │
│ │ │ │
│ │ Circuit breaker: RecordSuccess(uuid3) │ │
│ │ consecutiveFails = 0 (reset) │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ return (exit loop on success) │
│ } │
└───────────────────────────────────────────────────────┘
│
│ Response body streamed to client
▼
┌───────────────────────────┐
│ CLIENT │
│ Receives: │
│ - HTTP 200 │
│ - SSE stream + completion │
│ - Usage: {prompt: 10, │
│ completion_tokens:50} │
└───────────────────────────┘
Provider A receives 5 consecutive failures:
Attempt 0: 502 → RecordFailure → consecutiveFails=1 (Closed)
Attempt 1: 503 → RecordFailure → consecutiveFails=2 (Closed)
Attempt 2: 500 → RecordFailure → consecutiveFails=3 (Closed)
Attempt 3: 502 → RecordFailure → consecutiveFails=4 (Closed)
Attempt 4: 503 → RecordFailure → consecutiveFails=5 (Closed→Open)
├─ State: Open
├─ openedAt: now()
├─ SSE event: circuit_breaker.open
└─ Next 60s: all requests skip this provider
After 60s cooldown:
Next request: IsOpen() checks time.Since(openedAt) >= 60s
├─ Transition: Open→HalfOpen
├─ halfOpenProbes = 0
├─ SSE event: circuit_breaker.half-open
└─ Allow probe request through
Probe succeeds (HTTP 200):
RecordSuccess → halfOpenProbes=1 >= HalfOpenMaxProbes(1)
├─ Transition: HalfOpen→Closed
├─ consecutiveFails = 0
├─ halfOpenProbes = 0
└─ SSE event: circuit_breaker.closed
Probe fails (HTTP 502):
RecordFailure → Transition: HalfOpen→Open
├─ openedAt: now() (fresh cooldown)
├─ consecutiveFails = threshold (5)
└─ SSE event: circuit_breaker.open
Failover group lookups are cached with a 5-minute TTL:
// internal/failover/cache.go
const failoverCacheTTL = 5 * time.Minute
type failoverCacheEntry struct {
group FailoverGroup
expiresAt time.Time
}
var failoverByModelCache = make(map[string]failoverCacheEntry)Cache Operations:
| Operation | Function | When Called |
|---|---|---|
| Read | GetCachedFailoverByModel(displayModel) |
Every GetByModel query (before DB) |
| Write | cacheFailoverGroup(&fg) |
After successful DB query |
| Invalidate | InvalidateFailoverCache() |
On group create/update/delete |
| Warm | WarmFailoverCache(groups) |
On startup, periodic refresh |
Cache Invalidation:
The cache is invalidated on any mutation:
// internal/failover/failover.go:UpsertWithConfig
func (r *Repository) UpsertWithConfig(...) (*FailoverGroup, error) {
// ... INSERT/UPDATE query ...
cacheFailoverGroup(&fg) // Update cache entry
return &fg, nil
}
// internal/failover/failover.go:Delete
func (r *Repository) Delete(ctx context.Context, displayModel string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM model_failover_groups WHERE display_model = $1`, displayModel)
InvalidateFailoverCache() // Clear entire cache
return err
}Cache Warming:
On startup and periodic sync, the cache is pre-populated:
// internal/failover/cache.go:WarmFailoverCache
func WarmFailoverCache(groups []*FailoverGroup) {
for _, fg := range groups {
cacheFailoverGroup(fg)
}
}Transparent failover and hotel routing work together seamlessly:
- Client requests
hotel/gpt-4o - Hotel routing resolves the failover group and selects the first provider (highest priority)
- That provider returns a retriable error (5xx, 429, 401/403, 404, or timeout) → transparent failover kicks in
- Proxy tries the next provider in the group's priority order
- Retries continue sequentially through
provider-b,provider-c, etc. - If all providers in the group fail, a generic 502 error is returned to the client
This means hotel routing provides ordered preference (which provider to try first), while transparent failover provides exhaustive sequential retry - together they give you the best chance of a successful request.
"Failover group disabled" Error:
- Check
group_enabledflag in group configuration - Auto-created groups are disabled if provider count drops below 2
- Manual intervention required: re-enable after adding providers
"No available provider" Error:
- All entries in priority order are disabled (
entry_enabled: false) - All providers have open circuit breakers
- API key decryption fails (wrong
MASTER_KEY)
Circuit Breaker Opens Frequently:
- Check provider health (API status, rate limits)
- Increase
circuit_breaker_threshold(default: 5) - Reduce
circuit_breaker_cooldownfor faster recovery (default: 60s)
Single-Provider Groups Auto-Disabled:
- Expected behavior: groups need 2+ providers for failover
- Add another provider with the same base model name
- Or use direct provider routing (e.g.
openai/gpt-4o) instead
Enable debug logging via DEBUG_LOG=true environment variable:
[resolve] failover group found: model=gpt-4o entries=3 enabled=true
[resolve] building candidates from failover group: model=gpt-4o priority_order_count=3
[resolve] skipping candidate: circuit breaker open provider=uuid-xyz model=gpt-4o
[resolve] hotel model resolved: model=gpt-4o candidates=2 decrypt_failures=0
[proxy] failover triggered: attempt=1 provider=uuid-abc status=502
[proxy] failover backoff: backoff=150ms attempt=2
[circuit-breaker] provider state=closed→open provider=uuid-abc consecutive_failures=5
Batch Queries:
Provider selection uses batch queries to minimize DB round-trips:
// Single query for all models
models, err := h.modelRepo.GetByIDs(ctx, enabledModelIDs)
// Single query for all providers
providers, err := h.providerRepo.GetByIDs(ctx, providerIDs)Cache Hit Rate:
Failover group cache (5-minute TTL) should achieve >95% hit rate under normal load. Monitor cache misses in debug logs.
Circuit Breaker Overhead:
Circuit breaker state checks are O(1) with RWMutex-protected map lookups. The fast path (Closed state) uses only read locks.
- API Key Encryption: Provider keys are AES-256-GCM encrypted with Argon2id key derivation. Decryption happens in-memory only during request processing.
- Never Log Keys: Decrypted API keys are never logged or stored in request_logs.
- Virtual Key Hashing: Client virtual keys are SHA-256 hashed; only the hash is stored and compared.
- Configuration - Environment variables, database settings, runtime configuration
- Model Discovery - How models are discovered and failover groups are auto-generated
- Security - Provider key encryption and virtual key hashing
- API Reference - Proxy and admin API endpoints with examples
- Home - Architecture overview and feature summary
Last synced from hugalafutro/model-hotel@b0da0d8 on 2026-07-08 23:42 UTC. Edit these pages under wiki/ (and images under docs/screenshots/) in the main repo, not here.