Chaque article sur les passerelles que vous lisez cite un pourcentage de disponibilité. Celui-ci ne le fait pas, car nous ne publions pas de SLA et préférons décrire un mécanisme que vous pouvez vérifier plutôt qu’un chiffre que vous ne pouvez pas contrôler. Voici le parcours complet entre le moment où un fournisseur amont renvoie une erreur et celui où votre client en reçoit une : quatre couches, quatre constantes et les raisons pour lesquelles chacune possède cette valeur.
Pourquoi un seul fournisseur amont ne suffit pas
Qu’un fournisseur de modèles connaisse vingt mauvaises minutes est normal. Incidents régionaux, durcissement du limiteur de débit, manque de capacité sur un modèle récemment lancé : rien de tout cela n’est inhabituel, et tout cela vous reste invisible jusqu’à ce que votre propre taux d’erreur augmente. Si votre application communique avec un seul endpoint, votre disponibilité est celle de cet endpoint et, côté client, vous ne pouvez rien faire d’autre que réessayer ; à ce moment-là, l’utilisateur a déjà attendu.
L’alternative consiste à disposer d’une autre destination. Kunavo résout une chaîne de routage pour chaque modèle — jusqu’à trois canaux indépendants capables de le servir, classés par coût — et les quatre couches ci-dessous décrivent comment une requête parcourt cette chaîne sans que vous ayez à écrire la moindre logique de nouvelle tentative.
Couche 1 : le coupe-circuit (avant l’envoi de la requête)
Le basculement le moins coûteux est celui qui n’impose jamais d’aller-retour. Chaque processus de passerelle conserve, pour chaque canal, un compteur glissant des échecs sur cinq minutes. Après trois échecs dans cette fenêtre, le canal est considéré comme ouvert : pendant les soixante secondes suivantes, il est retiré de la chaîne avant même l’envoi d’une requête.
// 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;
}C’est la seule couche véritablement instantanée, et il est important d’être précis sur la raison : rien n’est mesuré rapidement. La décision a été prise jusqu’à cinq minutes auparavant par une requête précédente qui a subi l’échec à votre place. L’état est propre à chaque processus, de sorte que chaque machine apprend indépendamment ; en trafic réel, un mauvais canal se déclenche sur toutes les machines à quelques secondes d’intervalle, tandis qu’un canal brièvement défaillant récupère automatiquement à l’expiration de la période de refroidissement.
Couche 2 : basculement pendant la requête (jusqu’à trois sauts)
Lorsqu’un canal qui semblait sain renvoie malgré tout une erreur, la requête passe au suivant dans la chaîne. La limite est de trois sauts, ROUTING_CHAIN_MAX, et la chaîne est classée par coût ; la première tentative utilise donc toujours le canal le moins cher capable de servir le modèle.
// 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");
}Les erreurs client ne déclenchent pas de basculement. Un 400 pour une requête mal formée ou un 404 pour un modèle inexistant constitue la même réponse sur tous les canaux ; le réessayer trois fois ne ferait que vous faire attendre trois fois plus longtemps le même message. Seuls les échecs amont pouvant être réessayés parcourent la chaîne.
Le coût réel de cette couche est d’un aller-retour échoué par saut. Si un canal accepte votre connexion puis renvoie une erreur 503 après deux secondes, ces deux secondes s’écoulent avant le début de la deuxième tentative. C’est la véritable raison d’être de la couche 1 : elle empêche qu’un canal qui échoue systématiquement fasse payer ce péage de deux secondes à chaque requête.
Couche 3 : la barrière de flux (30 secondes)
La diffusion en continu rend le basculement plus difficile d’une manière facile à manquer. Un fournisseur amont peut renvoyer 200 OK, ouvrir un flux, puis seulement émettre un événement d’erreur. À ce moment-là, un proxy naïf a déjà envoyé un en-tête et quelques octets à votre client, et il n’est plus possible de passer au canal suivant ; la requête est engagée sur un canal qui ne va pas y répondre.
// 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)La passerelle ne se décide donc pas sur le code d’état. Elle retient les premières trames jusqu’à observer un élément décisif : du contenu réel, auquel cas le flux est engagé puis transmis sans modification ; ou une erreur, auquel cas rien ne vous est parvenu et la requête poursuit son parcours dans la chaîne. Trente secondes est la limite de cette décision, car les modèles de raisonnement peuvent légitimement réfléchir longtemps avant d’émettre leur premier token. Au-delà, nous engageons le flux, car un modèle lent n’est pas un modèle défaillant.
Couche 4 : délai d’attente des en-têtes (240 secondes)
La dernière couche est celle que personne n’apprécie. Si un fournisseur amont accepte la connexion puis ne renvoie aucun en-tête de réponse, la requête reste bloquée ; la seule chose qui y mette fin est un délai d’attente — UPSTREAM_HEADERS_TIMEOUT_MS, quatre minutes.
Quatre minutes, c’est long, et c’est volontaire. La génération vidéo et la génération d’images prennent légitimement plusieurs minutes ; un délai suffisamment court pour rendre le chat réactif annulerait des tâches vidéo qui allaient aboutir. Un fournisseur amont bloqué est également rare par rapport à un fournisseur qui échoue explicitement, et les cas explicites sont déjà traités par les couches 1 à 3. Nous préférons être lents dans le cas rare plutôt que nous tromper dans le cas courant.
Si vous souhaitez une limite plus stricte pour votre propre application, définissez un délai d’attente côté client. Le nôtre est une protection de dernier recours, pas un objectif de latence.
Ce que nous ne prétendons pas
- Aucun chiffre de basculement en millisecondes. La couche 1 n’impose aucun aller-retour, car la décision précède votre requête. Les couches 2 à 4 durent exactement le temps nécessaire au fournisseur amont défaillant pour échouer. Aucun de ces éléments ne peut honnêtement être réduit à un chiffre affiché sur une page marketing.
- Aucun pourcentage de disponibilité. Il n’existe ni SLA ni système de crédit ; un chiffre ne serait donc qu’un ornement. La page d’état affiche le taux de réussite réellement observé, y compris les périodes où il n’était pas bon.
- Aucune substitution silencieuse de modèle. Le basculement déplace une requête entre des canaux qui servent le même modèle. Nous ne répondons jamais avec un modèle moins cher que celui demandé ; c’est une pratique qu’il vaut la peine de vérifier directement auprès de toute passerelle, car elle est invisible dans la réponse.
Ce que cela signifie lorsque vous construisez dessus
Essentiellement, cela signifie que vous pouvez supprimer une boucle de nouvelle tentative. Les erreurs amont réessayables sont déjà retentées sur des canaux indépendants avant même que vous ne voyiez un échec ; une nouvelle tentative côté client après un 5xx de notre part répéterait donc quelque chose qui a déjà été tenté trois fois. Conservez votre délai d’attente et supprimez le backoff.
Le seul élément que vous devez gérer vous-même est un 402 insufficient balance, qui n’est pas du tout un cas de basculement : aucune quantité de nouvelles tentatives ne recharge un portefeuille. Consultez la référence des erreurs pour savoir quels statuts justifient une seconde tentative et lesquels sont définitifs.