Le SLO public de Brievio, c’est 99,95 % de disponibilité mensuelle sur l’API de chat. Ça ressemble à un chiffre marketing, mais il est bien réel — il plafonne notre budget d’erreur à 21 minutes par mois, et on le tient chaque mois depuis le lancement. Cet article, c’est ce qu’il y a vraiment derrière : la bascule entre upstreams, le chien de garde du premier octet, le boulier de facturation, et les corvées opérationnelles ennuyeuses qui comptent plus que tout le reste.
La réalité : chaque upstream a ses mauvais jours
On parle à ~12 upstreams en coulisses. Sur un mois donné, on voit chacun d’eux connaître au moins une dégradation partielle de 5 à 30 minutes. Parfois c’est une panne de région (l’us-east-1 d’Anthropic a eu deux incidents ce trimestre). Parfois c’est un serrage discret du seau à jetons (le limiteur de débit de Vertex AI est avare le lundi). Parfois c’est une panne complète du fournisseur (kie.ai est resté noir pendant 47 minutes en mars).
Si un seul upstream tombe et que tu n’as aucune bascule, ton SLO devient celui que lui affiche — moins ta surcharge de transit. Ça pose un plafond dur autour de 99,5 %. Franchir 99,9 % exige qu’aucune défaillance d’un seul upstream ne puisse te faire tomber, et 99,95 % exige que deux défaillances simultanées n’en soient pas non plus capables.
Couche 1 : routage pondéré entre candidats
Pour chaque modèle qu’on héberge, il existe 1 à 4 chemins d’upstream. Le dispatcher les parcourt par ordre de poids, des poids qui décroissent en temps réel selon le taux de succès glissant (les 100 derniers appels par upstream et par région). Quand us-east-1 commence à renvoyer des 5xx, son poids passe sous celui d’us-west-2 en ~5 secondes, et les nouvelles requêtes cessent d’y aller avant qu’un client ne le remarque.
// Pseudocode du dispatcher d'upstream. L'implémentation réelle
// (TypeScript, côté serveur uniquement) en est très proche.
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}]
// Poids plus faible = secours. Les poids initiaux viennent des taux de succès glissants.
const deadline = Date.now() + 540_000; // mur dur de 540 s
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 → pas de bascule
recordFailure(candidate, err); // le poids décroît en temps réel
lastError = err;
}
}
throw lastError ?? new Error("all upstreams exhausted");
}Deux points comptent ici, et il est facile de se tromper :
- Ne bascule pas sur un 4xx. Un 400 de l’upstream signifie que la requête était mauvaise — la rejouer contre un autre upstream produira le même 400, juste plus lentement. Seuls les 408, 429, 5xx et les erreurs réseau déclenchent une bascule.
- Borne le budget par candidat. Si une échéance de 540 s est répartie sur 3 candidats, donne à chacun ~150 s pour soit démarrer le flux, soit mourir. Ne donne pas les 540 entières au premier — s’il se fige, il ne te reste plus de temps pour le secours.
Couche 2 : chien de garde du premier octet (50 ms)
La moitié des incidents d’upstream ne sont pas des échecs francs — la connexion s’ouvre, la requête part, puis plus rien. Pas de réponse, pas d’erreur. Juste le silence. Une reprise naïve attend tout le timeout, puis essaie le suivant, doublant la latence visible par l’utilisateur.
Notre dispatcher arme un chronomètre agressif de 50 ms sur le premier octet dès que la requête part sur le fil. Si on ne voit pas un seul octet de réponse dans cette fenêtre, on avorte et on passe à la suite. 50 ms se situe sous le seuil de perception, donc sur le chemin heureux le client ne voit aucun délai. Sur le chemin défaillant, la requête du client bascule vers l’upstream de secours en une seule image perceptible par un humain.
// Détection du premier octet — échoue VITE si l'upstream est figé.
async function callUpstream(c: Candidate, req: ChatRequest) {
const ac = new AbortController();
// Si on ne voit pas un seul octet de réponse dans les 50 ms après que la requête
// est partie sur le fil, on considère l'upstream comme muet et on passe au suivant.
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");
// On a maintenant le premier octet. On renvoie le flux ; le client reçoit chaque
// morceau dès qu'il arrive — aucune mise en tampon.
return new Response(res.body, {
headers: { "content-type": "text/event-stream" },
});
}On est arrivé à 50 ms en mesurant le TTFB P99 de nos propres upstreams : même le plus lent produit son premier octet de réponse en moins de 40 ms dans 99 % des cas. Tout ce qui dépasse 50 ms est du signal, pas du bruit. Cale ce nombre sur le P99 de tes propres upstreams.
Couche 3 : le boulier de facturation
La fiabilité ne se résume pas à la disponibilité — il s’agit aussi de savoir si la facturation reste correcte sous charge. Au début, on avait une course où deux requêtes concurrentes passaient toutes deux le contrôle de solde, puis validaient toutes deux, et le compte de l’utilisateur passait à -0,12 $. Le correctif, c’était une réservation au moment de l’écriture :
// Le « boulier de facturation » — on réserve le coût estimé en amont pour que deux
// grosses requêtes concurrentes ne puissent pas passer toutes deux le contrôle de solde et le dépasser.
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) };
});
}Chaque appel estime son coût maximal en amont (tokens d’entrée × tarif d’entrée + max_tokens × tarif de sortie), réserve ce montant sur le portefeuille en une seule transaction, et libère la part inutilisée une fois l’usage réel connu. Les requêtes concurrentes se sérialisent au niveau de la transaction. Aucun découvert, jamais.
Les corvées ennuyeuses qui comptent davantage
Tout ingénieur qui lit ça hoche la tête devant les schémas du dispatcher en se disant « cool, reprise pondérée, chien de garde du premier octet, c’est bon, j’ai compris ». Mais ce qui a réellement fait tenir notre SLO mois après mois est moins palpitant :
- Des battements de cœur sur chaque cron. Rotation des jetons, balayages de solde, agrégations d’usage — chacun d’eux ping un endpoint Healthchecks.io quand il se termine. Un battement manqué alerte en moins de 5 minutes. On a attrapé plus de pannes via des battements manqués que via des alarmes explicites.
- Une page de statut réellement exacte. brievio.com/status lance des vérifications synthétiques contre chaque modèle toutes les 90 secondes. Quand un upstream régresse, elle passe au jaune avant que les clients ne le remarquent.
- Des runbooks avant d’en avoir besoin. Chaque incident qu’on a eu, y compris ceux qu’on a attrapés avant tout impact client, a produit une entrée de runbook. Le bipeur de 4 h du matin a une checklist ; l’ingénieur n’a pas à réfléchir.
- Sentry sur tout, profilage sur les chemins chauds. Le dispatcher journalise chaque décision de bascule avec le candidat, la raison et le budget restant. Si une régression se glisse, la recherche devient « montre-moi les bascules avec reason=ttfb-50ms groupées par upstream sur cette heure ».
Ce qu’on ne fait pas
Quelques choses qu’on a délibérément évitées, alors qu’on pourrait attendre d’une passerelle à 99,95 % qu’elle les fasse :
- Une base de données active-active multi-région. Notre base est mono-région (iad). Le décalage de réplication créerait des incohérences de facturation qu’on n’a pas envie de déboguer. Si iad tombe franchement, on bascule en mode lecture seule — les clients peuvent toujours appeler les modèles (le dispatcher est sans état en périphérie) mais ne peuvent pas voir leur historique d’usage pendant une heure. Ce compromis est le bon pour nous.
- Échanger des modèles en douce. Certains agrégateurs, lorsqu’ils n’arrivent pas à joindre Claude Opus, routent vers Haiku. Nous non — on préfère que tu reçoives un 503 plutôt qu’un autre modèle que tu n’as pas payé. Le dispatcher ne bascule que vers le même modèle sur un upstream différent.
- Réessayer de façon préventive. Les 429 d’Anthropic veulent dire quelque chose. Réessayer immédiatement aggrave le problème pour tout le monde. Notre backoff est exponentiel avec gigue, plafonné à 5 tentatives et 30 secondes d’attente totale — la même politique qu’on recommande dans notre guide des erreurs.
Où part vraiment le budget
Notre budget d’erreur pour mai 2026 était de 21 minutes 36 secondes. On a consommé :
- 3 minutes 12 secondes : un soubresaut d’Anthropic sur us-east-1 pendant un déploiement. La bascule s’est déclenchée, aucune requête perdue, mais la latence P99 a dépassé la cible sur la fenêtre.
- 1 minute 50 secondes : une rafale de 5xx de kie.ai sur un modèle vidéo. Toutes les reprises ont réussi, mais la latence par appel a violé le SLO.
- 0 indisponibilité franche. Aucune requête n’a renvoyé un 5xx sans option de reprise.
Ça fait 5:02 de budget consommé sur un budget de 21:36 — 23 % utilisé, bien dans la cible. Les 77 % restants, c’est ce qui nous permet de déployer agressivement et d’absorber la prochaine surprise.
Ce que ça veut dire pour toi
Si tu construis sur Brievio, tu n’as pas besoin d’implémenter ta propre bascule de fournisseurs, ton propre chien de garde TTFB, ni ton propre routage Anthropic-vs-Vertex. C’est tout l’intérêt : une seule base URL, un seul jeton bearer, le modèle authentique, et le SLO, c’est à nous de le défendre.
Si tu construis une autre passerelle et que tu lis ça pour des idées : le dispatcher fait 600 lignes de TypeScript. Ce n’est pas la partie compliquée. Les parties compliquées, ce sont les runbooks de modes de défaillance, les battements de cœur, la page de statut, et les mois de petites cicatrices opérationnelles qui s’accumulent pour donner un système fiable. Prévois-les avant de livrer.
Consulte les chiffres de disponibilité actuels et le détail par région sur brievio.com/status, ou la taxonomie complète des erreurs dans notre documentation des erreurs.