Zurück zum Blog
Engineering·24. Mai 2026·9 Min. Lesezeit

So führt Kunavo Failover durch: die vier Ebenen zwischen einem Upstream-Fehler und Ihrem Client

Der vollständige Failover-Pfad mit der Konstante hinter jeder Zahl: ein Circuit Breaker, der einen fehlerhaften Kanal vor dem Senden der Anfrage überspringt, bis zu drei kostenmäßig geordnete Sprünge innerhalb einer Anfrage, ein Stream-Gate, das verhindert, dass ein 200-Status festgeschrieben wird, der sich in einen Fehler verwandelt, und eine vierminütige Absicherung für einen Upstream, der nie antwortet.

Jeder Gateway-Artikel, den Sie lesen, nennt einen Verfügbarkeitsprozentsatz. Dieser nicht, weil wir kein SLA veröffentlichen und lieber einen überprüfbaren Mechanismus beschreiben als eine Zahl, die Sie nicht überprüfen können. Im Folgenden wird der gesamte Weg zwischen einem Fehler des Upstreams und dem Fehler bei Ihrem Client beschrieben: vier Ebenen, vier Konstanten und die Gründe für deren jeweilige Werte.

Warum ein einzelner Upstream nicht ausreicht

Zwanzig schlechte Minuten bei einem Modellanbieter sind normal. Regionale Störungen, verschärfte Rate-Limits oder ein Kapazitätsengpass bei einem neu eingeführten Modell sind nichts Ungewöhnliches; für Sie werden sie jedoch erst sichtbar, wenn Ihre eigene Fehlerrate steigt. Wenn Ihre Anwendung mit nur einem Endpunkt kommuniziert, entspricht Ihre Verfügbarkeit der Verfügbarkeit dieses Endpunkts. Auf Clientseite können Sie dann nur wiederholen – und bis dahin hat der Benutzer bereits gewartet.

Die Alternative besteht darin, einen anderen Weg zu haben. Kunavo löst für jedes Modell eine Routingkette auf – bis zu drei unabhängige Kanäle, die es bereitstellen können, nach Kosten geordnet. Die folgenden vier Ebenen bestimmen, wie sich eine Anfrage durch diese Kette bewegt, ohne dass Sie eine eigene Wiederholungslogik schreiben müssen.

Ebene 1: der Circuit Breaker (vor dem Senden der Anfrage)

Der günstigste Failover ist derjenige, der niemals eine Roundtrip-Zeit kostet. Jeder Gateway-Prozess führt pro Kanal eine gleitende Fehlerzählung über fünf Minuten. Drei Fehler innerhalb dieses Zeitfensters öffnen den Kanal: In den folgenden sechzig Sekunden wird er aus der Kette entfernt, bevor überhaupt eine Anfrage gesendet wird.

provider-health.ts
// Layer 1, simplified from lib/providers/provider-health.ts.
// A channel that has been failing is skipped BEFORE we send anything,
// so the cost of avoiding it is zero — no request, no timeout, no wait.
const WINDOW_MS   = 300_000;  // failures older than 5 minutes are forgotten
const FAIL_THRESHOLD = 3;     // 3 failures inside that window opens the circuit
const COOLDOWN_MS = 60_000;   // and the channel is bypassed for 60 seconds

function isOpen(channel: string, now = Date.now()): boolean {
  const s = state.get(channel);
  return s != null && s.openUntil > now;
}

function recordFailure(channel: string, now = Date.now()) {
  const s = state.get(channel) ?? { failures: [], openUntil: 0 };
  s.failures = s.failures.filter((t) => now - t < WINDOW_MS);
  s.failures.push(now);
  if (s.failures.length >= FAIL_THRESHOLD) s.openUntil = now + COOLDOWN_MS;
}

Dies ist die einzige Ebene, die wirklich sofort reagiert. Entscheidend ist jedoch: Es wird nicht besonders schnell gemessen. Die Entscheidung wurde bis zu fünf Minuten zuvor von einer früheren Anfrage getroffen, die den Fehler für Sie übernommen hat. Der Status gilt pro Prozess, daher lernt jede Maschine unabhängig. Unter realem Datenverkehr wird ein fehlerhafter Kanal innerhalb weniger Sekunden auf allen Maschinen ausgelöst; ein nur kurzzeitig gestörter Kanal erholt sich selbst, sobald die Abkühlzeit abgelaufen ist.

Ebene 2: Failover innerhalb der Anfrage (bis zu drei Sprünge)

Wenn ein scheinbar gesunder Kanal trotzdem einen Fehler zurückgibt, fällt die Anfrage auf den nächsten Kanal der Kette zurück. Das Limit beträgt drei Sprünge, ROUTING_CHAIN_MAX, und die Kette ist nach Kosten geordnet. Der erste Versuch erfolgt daher immer über den günstigsten Kanal, der das Modell bereitstellen kann.

dispatch.ts
// Layer 2. The routing chain is resolved from the catalog, cost-ordered,
// and capped at ROUTING_CHAIN_MAX hops (3). It is static configuration, not
// a learned weighting: you can read the exact chain for any model in the
// admin UI, and it is the same chain for every customer.
async function dispatch(req: ChatRequest) {
  const chain = routingChain(req.model)          // up to 3 channels, cheapest first
    .filter((c) => !isOpen(c.id));               // layer 1 removes the known-bad

  let lastError: UpstreamError | null = null;
  for (const channel of chain) {
    try {
      return await callUpstream(channel, req);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // a 400 is yours, not ours
      recordFailure(channel.id, Date.now());
      lastError = err;
    }
  }
  throw lastError ?? new Error("all channels exhausted");
}

Clientfehler lösen kein Failover aus. Ein 400-Fehler wegen einer fehlerhaften Anfrage oder ein 404-Fehler wegen eines nicht vorhandenen Modells liefert von jedem Kanal dieselbe Antwort. Drei Wiederholungen würden daher nur dazu führen, dass Sie dreimal so lange auf dieselbe Meldung warten. Nur wiederholbare Upstream-Fehler durchlaufen die Kette.

Die tatsächlichen Kosten dieser Ebene bestehen in einem fehlgeschlagenen Roundtrip pro Sprung. Wenn ein Kanal Ihre Verbindung annimmt und nach zwei Sekunden mit 503 antwortet, warten Sie diese zwei Sekunden, bevor der zweite Versuch beginnt. Genau deshalb ist Ebene 1 wichtig: Sie verhindert, dass ein zuverlässig fehlschlagender Kanal jede einzelne Anfrage mit dieser zweisekündigen Verzögerung belastet.

Ebene 3: das Stream-Gate (30 Sekunden)

Streaming erschwert das Failover auf eine leicht zu übersehende Weise. Ein Upstream kann 200 OK zurückgeben, einen Stream öffnen und erst danach ein Fehlerereignis ausgeben. Wenn dieses eintrifft, hat ein naiver Proxy Ihrem Client bereits einen Header und einige Bytes gesendet; ein Zurückfallen auf den nächsten Kanal ist dann nicht mehr möglich. Die Anfrage ist an einen Kanal gebunden, der sie nicht beantworten wird.

stream-gate.ts
// Layer 3, from lib/providers/stream-gate.ts. An upstream can answer 200 OK
// and then put the error inside the stream. Committing that to your client
// means you get a truncated answer and we cannot fail over any more, so the
// gateway holds the first frames back until it knows which it is.
export const STREAM_GATE_TIMEOUT_MS = 30_000;

// Outcomes:
//   a decisive content frame  -> commit, stream the rest through untouched
//   a decisive error frame    -> discard, fall through to the next channel
//   nothing decisive in 30s   -> commit anyway (a slow model is not an error)

Daher entscheidet das Gateway nicht anhand des Statuscodes. Es hält die ersten Frames zurück, bis etwas Eindeutiges eintrifft: echter Inhalt – dann wird der Stream fest an diesen Kanal gebunden und anschließend unverändert weitergeleitet – oder ein Fehler – dann ist noch nichts bei Ihnen angekommen und die Anfrage läuft in der Kette weiter. Dreißig Sekunden sind die Obergrenze für diese Entscheidung. Sie existiert für Reasoning-Modelle, die berechtigterweise lange nachdenken, bevor sie ein erstes Token ausgeben. Danach wird die Anfrage fest zugeordnet, denn ein langsames Modell ist nicht automatisch ein defektes Modell.

Ebene 4: das Header-Timeout (240 Sekunden)

Die letzte Ebene ist die, die niemand mag. Wenn ein Upstream die Verbindung akzeptiert und anschließend überhaupt keine Antwort-Header zurückgibt, hängt die Anfrage, und nur ein Timeout kann sie beenden — UPSTREAM_HEADERS_TIMEOUT_MS, vier Minuten.

Vier Minuten sind eine lange Wartezeit, und das ist Absicht. Die Generierung von Videos und Bildern dauert legitimerweise mehrere Minuten; ein Timeout, das sich bei Chats reaktionsschnell anfühlt, würde Vide05-Jobs abbrechen, die erfolgreich gewesen wären. Ein hängender Upstream ist außerdem selten im Vergleich zu einem Upstream, der lautstark fehlschlägt, und die lauten Fälle werden bereits von den Ebenen 1 bis 3 behandelt. Im seltenen Fall sind wir lieber langsam als im häufigen Fall falsch.

Wenn Sie für Ihre eigene Anwendung eine engere Grenze als diese wünschen, setzen Sie ein clientseitiges Timeout. Unser Timeout ist eine Notfallabsicherung, kein Latenzziel.

Was wir nicht behaupten

  • Keine Failover-Angabe im Millisekundenbereich. Ebene 1 verursacht keinen Roundtrip, weil die Entscheidung vor Ihrer Anfrage getroffen wird. Die Ebenen 2 bis 4 dauern genau so lange, wie der fehlschlagende Upstream zum Fehlschlagen benötigt. Keine dieser beiden Angaben können wir ehrlich auf eine Marketingseite schreiben.
  • Kein Verfügbarkeitsprozentsatz. Es gibt weder ein SLA noch ein Gutschriftsmodell, daher wäre eine Zahl reine Dekoration. Die Statusseite zeigt die Erfolgsquote, die wir tatsächlich beobachten, einschließlich der Zeiträume, in denen sie nicht gut war.
  • Keine stille Modellsubstitution. Beim Failover wird eine Anfrage zwischen Kanälen verschoben, die dasselbe Modell bereitstellen. Wir antworten niemals mit einem günstigeren Modell als dem von Ihnen angeforderten – eine Praxis, nach der Sie jedes Gateway direkt fragen sollten, da sie anhand der Antwort unsichtbar ist.

Was das beim Aufbau darauf bedeutet

Im Wesentlichen bedeutet es, dass Sie eine Retry-Schleife löschen können. Wiederholbare Upstream-Fehler werden bereits über unabhängige Kanäle erneut versucht, bevor Sie überhaupt einen Fehler sehen. Ein clientseitiger Retry bei einem 5xx von uns wiederholt also etwas, das bereits dreimal versucht wurde. Behalten Sie Ihr Timeout bei und entfernen Sie das Backoff.

Das Einzige, was Sie selbst behandeln sollten, ist ein 402 insufficient balance, der überhaupt kein Failover-Fall ist – kein noch so häufiges Wiederholen füllt ein Wallet wieder auf. Unter Fehlerreferenz erfahren Sie, bei welchen Statuscodes sich ein zweiter Versuch lohnt und welche endgültig sind.