#1262·freellmapi

Reliability: per-endpoint TTFB-aware retry budget (kill slow-but-alive free endpoints prematurely)

Author: suanteaCreated Sep 17, 2026Updated Sep 17, 2026

Summary

The default 45-second retry budget kills slow-but-alive endpoints on every request, wasting the full failover budget before giving them a chance to respond.

Evidence from a single production install (7-day window, 9/15):

platform successful requests avg TTFB max TTFB
ollama 28 56,954 ms 95,485 ms
nvidia 6 56,654 ms 194,586 ms
requesty 6 48,699 ms 68,472 ms
kilo 8 7,431 ms 17,804 ms
custom relay 72 4,299 ms 79,572 ms

Meanwhile 12 requests were exhausted with timeout and the message "retry time budget 45000ms expired with no first byte". A subsequent request to the same model on the same endpoint succeeded at 47s — proving the endpoint was never dead, only slow.

The operator already found a workaround: setting fallback_time_budget_ms=120000. But this is a global sledgehammer — it makes every endpoint wait 120s even when most are fast.

Root cause

getFallbackTimeBudgetMs() returns a single global budget for the entire failover ladder. The loop checks this budget before starting each retry, and aborts in-flight attempts when the budget expires. This works for fast providers (OpenAI, Anthropic, most commercial relays) where a stalled attempt is truly dead. It fails for free-tier endpoints that are alive but throttled — they spend 50-90s warming up, then succeed. The router kills them at 45s, wastes the budget, and the next request repeats the same failure.

Proposed fix: per-endpoint TTFB-aware budget

Each (platform, endpoint_scope) bucket tracks its own observed TTFB distribution (P50, P95 over a sliding 7-day window with 2-day half-life decay, same as the existing reliability bandit). The loop uses this to dynamically widen the budget for slow endpoints:

base = getFallbackTimeBudgetMs()  // 45000ms default
slow = endpointTtfbP95(platform, endpointScope)
if slow > 0:
    effective = max(base, slow + buffer_ms)   // buffer = 10s by default
else:
    effective = base

buffer_ms is tunable (SLOW_ENDPOINT_BUFFER_MS, default 10000). The existing abortInFlight() hedge logic applies unchanged — it just gets a longer timer for slow endpoints.

What stays the same

  • Fast endpoints keep their 45s budget — no behavioral change for them
  • Budget checking still happens before each retry and mid-attempt via abortInFlight()
  • The same environment variable / settings table override is respected as the floor (effective = max(base, calculated))
  • Exhaustion messages still name the budget (now per-endpoint-aware)

Configuration

Setting Default Purpose
fallback_time_budget_ms 45000 Base budget for "normal" endpoints
slow_endpoint_buffer_ms 10000 Extra headroom added to slow endpoints
ttfb_budget_window_ms 604800000 (7d) Sliding window for TTFB stats
ttfb_budget_half_life_ms 172800000 (2d) Exponential decay half-life

Kill switch: TTFB_BUDGET_DISABLED=1 (same convention as ENDPOINT_HEALTH_DISABLED).

PR plan

Single PR: server/src/lib/fallback-loop.ts + server/src/lib/ttfb-budget.ts (new module) + tests + env docs.

Why this matters for free-tier aggregation

Free endpoints are inherently slower — they run on consumer hardware, share resources with other users, and throttle under load. A one-size-fits-all budget treats "slow" and "dead" identically. The per-endpoint TTFB adaptation lets free-tier endpoints compete on equal footing with commercial providers without forcing operators to set a global 120s budget (which would make fast endpoints unnecessarily slow on exhaustion).

中文备注:此问题 #1218 Gap 2。生产数据来自本机实例(127.0.0.1:31415),错误率约30%,失败请求平均挂7.3s。

Source: tashfeenahmed/freellmapi