Brievios öffentliches SLO lautet 99,95% monatliche Verfügbarkeit der Chat-API. Das klingt nach einer Marketing-Zahl, ist aber eine echte — sie deckelt unser Fehlerbudget auf 21 Minuten pro Monat, und wir haben es seit dem Start jeden Monat eingehalten. Dieser Beitrag zeigt, was tatsächlich dahintersteckt: das Upstream-Failover, der First-Byte-Watchdog, der Abrechnungs-Abakus und die unspektakulären operativen Dinge, die mehr zählen als alles andere zuvor.
Die Realität: Jeder einzelne Upstream hat schlechte Tage
Hinter den Kulissen sprechen wir mit rund 12 Upstreams. In jedem beliebigen Monat erleben wir bei jedem von ihnen mindestens eine partielle Degradierung von 5 bis 30 Minuten. Manchmal ist es ein Regionalausfall (Anthropics us-east-1 hatte in diesem Quartal zwei Vorfälle). Manchmal ist es eine stille Token-Bucket-Drosselung (Vertex AIs Rate-Limiter ist montags knauserig). Manchmal ist es ein kompletter Provider-Ausfall (kie.ai war im März für 47 Minuten weg).
Wenn ein einzelner Upstream ausfällt und du kein Failover hast, ist dein SLO genau das, was dessen SLO ist — abzüglich deines Transit-Overheads. Damit liegt die harte Obergrenze bei rund 99,5%. Die 99,9%-Marke zu knacken erfordert, dass kein einzelner Upstream-Ausfall dich lahmlegen kann, und 99,95% erfordert, dass auch zwei gleichzeitige Ausfälle es nicht können.
Schicht 1: Gewichtetes Candidate-Routing
Für jedes Modell, das wir hosten, gibt es 1 bis 4 Upstream-Pfade. Der Dispatcher durchläuft sie in gewichteter Reihenfolge, mit Gewichten, die in Echtzeit anhand der laufenden Erfolgsrate zerfallen (die letzten 100 Calls pro Upstream pro Region). Wenn us-east-1 anfängt, 5xx zu liefern, fällt sein Gewicht innerhalb von rund 5 Sekunden unter us-west-2, und neue Requests gehen nicht mehr dorthin, bevor es ein Kunde bemerkt.
// Pseudocode für den Upstream-Dispatcher. Die echte Implementierung
// (TypeScript, nur serverseitig) ist diesem Code sehr ähnlich.
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
const candidates = pickCandidates(req.model);
// candidates = [{provider: "anthropic-direct", weight: 100},
// {provider: "google-vertex", weight: 80},
// {provider: "kie-wholesale", weight: 60}]
// Geringeres Gewicht = Backup. Die Anfangsgewichte stammen aus den laufenden Erfolgsraten.
const deadline = Date.now() + 540_000; // harte 540s-Grenze
let lastError: Error | null = null;
for (const candidate of candidates) {
const budgetMs = chunkBudget(deadline, candidates.length);
try {
return await Promise.race([
callUpstream(candidate, req),
timeout(budgetMs),
]);
} catch (err) {
if (!isRetryable(err)) throw err; // 4xx → kein Failover
recordFailure(candidate, err); // Gewicht zerfällt in Echtzeit
lastError = err;
}
}
throw lastError ?? new Error("all upstreams exhausted");
}Zwei Dinge sind hier wichtig, die man leicht falsch macht:
- Bei 4xx kein Failover. Ein 400 vom Upstream bedeutet, dass der Request fehlerhaft war — ihn gegen einen anderen Upstream erneut zu versuchen, produziert denselben 400, nur langsamer. Nur 408, 429, 5xx und Netzwerkfehler lösen ein Failover aus.
- Begrenze das Budget pro Kandidat. Wird eine 540s-Frist auf 3 Kandidaten aufgeteilt, gib jedem rund 150s, um entweder zu streamen oder zu sterben. Gib dem ersten nicht die vollen 540 — hängt er, bleibt dir keine Zeit fürs Backup.
Schicht 2: First-Byte-Watchdog (50ms)
Die Hälfte der Upstream-Vorfälle sind keine harten Ausfälle — die Verbindung öffnet sich, der Request geht raus, und dann nichts. Keine Antwort, kein Fehler. Nur Stille. Ein naiver Retry wartet das volle Timeout ab und versucht dann den nächsten, was die für den Nutzer sichtbare Latenz verdoppelt.
Unser Dispatcher schaltet einen aggressiven 50ms-First-Byte-Timer scharf, sobald der Request auf der Leitung ist. Sehen wir in diesem Fenster kein einziges Antwort-Byte, brechen wir ab und gehen weiter. 50ms liegen unter der Wahrnehmungsschwelle, sodass der Kunde auf dem Happy Path keine Verzögerung bemerkt. Auf dem Fehlerpfad macht der Request des Kunden ein Failover auf den Backup-Upstream innerhalb eines einzigen für Menschen wahrnehmbaren Frames.
// First-Byte-Erkennung — bei hängendem Upstream SCHNELL abbrechen.
async function callUpstream(c: Candidate, req: ChatRequest) {
const ac = new AbortController();
// Sehen wir innerhalb von 50ms, nachdem der Request auf der Leitung ist,
// kein einziges Antwort-Byte, gilt der Upstream als stumm und wir gehen weiter zum nächsten.
const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);
const res = await fetch(c.url, {
method: "POST",
body: JSON.stringify(req),
signal: ac.signal,
});
clearTimeout(firstByteWatchdog);
if (!res.body) throw new Error("no-body");
// Jetzt haben wir das erste Byte. Wir geben den Stream zurück; der Client erhält
// jeden Chunk, sobald er eintrifft — kein Buffering.
return new Response(res.body, {
headers: { "content-type": "text/event-stream" },
});
}Auf die 50ms sind wir gekommen, indem wir das TTFB-P99 unserer eigenen Upstreams gemessen haben: Selbst der langsamste Upstream liefert sein erstes Antwort-Byte in 99% der Fälle unter 40ms. Alles über 50ms ist Signal, nicht Rauschen. Stimme diese Zahl auf das P99 deiner eigenen Upstreams ab.
Schicht 3: Der Abrechnungs-Abakus
Zuverlässigkeit hat nicht nur mit Uptime zu tun — es geht auch darum, ob die Abrechnung unter Last korrekt bleibt. Früh hatten wir einen Race-Condition, bei dem zwei gleichzeitige Requests beide die Guthabenprüfung bestanden, dann beide committeten und das Konto des Nutzers um 0,12 $ ins Minus rutschte. Die Lösung war eine Reservierung zum Schreibzeitpunkt:
// Der "Abrechnungs-Abakus" — die geschätzten Kosten vorab reservieren, damit zwei
// gleichzeitige große Requests nicht beide die Guthabenprüfung bestehen und dann überziehen.
async function reserveBalance(userId: string, estCents: number) {
return await db.transaction(async (tx) => {
const bal = await tx.balance.findUnique({ where: { userId } });
if (bal.balanceCents - bal.reservedCents < estCents) {
throw new InsufficientQuotaError();
}
await tx.balance.update({
where: { userId },
data: { reservedCents: { increment: estCents } },
});
return { release: () => releaseReservation(userId, estCents) };
});
}Jeder Call schätzt seine maximalen Kosten vorab (Input-Tokens × Input-Tarif + max_tokens × Output-Tarif), reserviert diesen Betrag in einer einzigen Transaktion auf dem Wallet und gibt den ungenutzten Teil frei, sobald der tatsächliche Verbrauch bekannt ist. Gleichzeitige Requests serialisieren auf der Transaktionsebene. Keine Überziehungen, niemals.
Das Unspektakuläre, das mehr zählt
Jeder Ingenieur, der das liest, nickt bei den Dispatcher-Diagrammen und denkt sich „cool, gewichteter Retry, First-Byte-Watchdog, hab ich". Aber die Dinge, die unser SLO tatsächlich Monat für Monat haben halten lassen, sind weniger aufregend:
- Heartbeats auf jedem Cron. Token-Rotation, Guthaben-Sweeps, Usage-Rollups — jeder davon pingt beim Abschluss einen Healthchecks.io-Endpunkt an. Ein ausbleibender Heartbeat alarmiert innerhalb von 5 Minuten. Wir haben mehr Ausfälle über ausbleibende Heartbeats erwischt als über explizite Alarme.
- Eine Statusseite, die tatsächlich stimmt. brievio.com/status fährt alle 90 Sekunden synthetische Checks gegen jedes Modell. Wenn ein Upstream regrediert, wird sie gelb, bevor Kunden es bemerken.
- Runbooks, bevor Runbooks gebraucht werden. Jeder Vorfall, den wir hatten — auch die, die wir vor jeglicher Kundenwirkung erwischt haben — hat einen Runbook-Eintrag erzeugt. Der 4-Uhr-Pager hat eine Checkliste; der Ingenieur muss nicht nachdenken.
- Sentry auf allem, Profiling auf den heißen Pfaden. Der Dispatcher protokolliert jede Failover-Entscheidung mit dem Kandidaten, dem Grund und dem verbleibenden Budget. Schleicht sich eine Regression ein, lautet die Suche „zeig mir Failover mit reason=ttfb-50ms gruppiert nach Upstream in dieser Stunde".
Was wir nicht tun
Ein paar Dinge, die wir bewusst vermieden haben und die man von einem 99,95%-Gateway vielleicht erwarten würde:
- Multi-Region-Active-Active-Datenbank. Unsere Datenbank läuft in einer einzigen Region (iad). Replikations-Lag würde Abrechnungs-Inkonsistenzen erzeugen, die wir nicht debuggen wollen. Geht iad hart down, wechseln wir in einen Read-Only-Modus — Kunden können weiterhin Modelle aufrufen (der Dispatcher ist am Edge zustandslos), aber eine Stunde lang keine Verbrauchshistorie sehen. Dieser Kompromiss ist für uns der richtige.
- Modelle stillschweigend austauschen. Manche Aggregatoren leiten, wenn sie Claude Opus nicht erreichen, auf Haiku um. Wir tun das nicht — uns ist lieber, du bekommst einen 503 als ein anderes Modell, für das du nicht bezahlt hast. Der Dispatcher macht nur ein Failover auf dasselbe Modell auf einem anderen Upstream.
- Präventiv erneut versuchen. Anthropics 429er bedeuten etwas. Sofort erneut zu versuchen, macht das Problem für alle schlimmer. Unser Backoff ist exponentiell mit Jitter, gedeckelt auf 5 Versuche und 30 Sekunden Gesamtwartezeit — dieselbe Policy, die wir in unserem Fehler-Guide empfehlen.
Wohin das Budget tatsächlich fließt
Unser Fehlerbudget für Mai 2026 betrug 21 Minuten 36 Sekunden. Verbraucht haben wir:
- 3 Minuten 12 Sekunden: ein us-east-1-Anthropic-Aussetzer während eines Deploys. Failover wurde ausgelöst, kein Request ging verloren, aber die P99-Latenz schoss im Fenster über das Ziel.
- 1 Minute 50 Sekunden: ein kie.ai-5xx-Schub auf einem Video-Modell. Alle Retries waren erfolgreich, aber die Latenz pro Call verletzte das SLO.
- 0 harte Downtime. Kein Request lieferte einen 5xx ohne Retry-Option zurück.
Das sind 5:02 verbranntes Budget gegen ein 21:36-Budget — 23% genutzt, deutlich im Zielbereich. Die übrigen 77% sind das, was uns aggressives Deployen erlaubt und die nächste Überraschung abfedert.
Was das für dich bedeutet
Wenn du auf Brievio baust, musst du weder dein eigenes Provider-Failover noch deinen eigenen TTFB-Watchdog noch dein eigenes Anthropic-vs-Vertex-Routing implementieren. Genau das ist der Sinn: eine Base-URL, ein Bearer-Token, das echte Modell — und das SLO zu verteidigen, ist unsere Aufgabe.
Wenn du ein anderes Gateway baust und das hier nach Ideen liest: Der Dispatcher umfasst 600 Zeilen TypeScript. Das ist nicht der komplizierte Teil. Die komplizierten Teile sind die Runbooks für die Fehlermodi, die Heartbeats, die Statusseite und die Monate kleiner operativer Narben, die sich zu einem zuverlässigen System summieren. Plane diese ein, bevor du ausrollst.
Die aktuellen Uptime-Zahlen und die Aufschlüsselung pro Region findest du unter brievio.com/status, oder die vollständige Fehler-Taxonomie in unseren Fehler-Docs.