Torna al blog
Ingegneria·24 maggio 2026·9 min di lettura

Come Kunavo gestisce il failover: i quattro livelli tra un errore upstream e il tuo client

L'intero percorso di failover, con la costante alla base di ogni numero: un circuit breaker che salta un canale in errore prima dell'invio della richiesta, fino a tre passaggi ordinati per costo all'interno di una singola richiesta, uno stream gate che rifiuta di confermare un 200 che si trasforma in errore e una rete di sicurezza di quattro minuti per un upstream che non risponde mai.

Ogni post sui gateway che leggi riporta una percentuale di disponibilità. Questo no, perché non pubblichiamo un SLA e preferiamo descrivere un meccanismo che puoi verificare invece di un numero che non puoi verificare. Quello che segue è l'intero percorso tra un upstream che restituisce un errore e il tuo client che ne riceve uno: quattro livelli, quattro costanti e le ragioni per cui ciascuna costante ha quel valore.

Perché un singolo upstream non basta

Che un provider di modelli attraversi venti minuti difficili è normale. Incidenti regionali, limiti di velocità più rigidi, una carenza di capacità su un modello appena lanciato: nulla di tutto questo è insolito e tutto ti rimane invisibile finché non aumenta il tuo stesso tasso di errore. Se la tua applicazione comunica con un solo endpoint, la tua disponibilità è quella dell'endpoint e dal client non puoi fare altro che ritentare; a quel punto l'utente ha già aspettato.

L'alternativa è avere un'altra destinazione. Kunavo risolve una catena di instradamento per ogni modello: fino a tre canali indipendenti in grado di servirlo, ordinati per costo. I quattro livelli seguenti spiegano come una richiesta attraversa quella catena senza che tu debba scrivere alcuna logica di retry.

Livello 1: il circuit breaker (prima dell'invio della richiesta)

Il failover più economico è quello che non costa mai un round trip. Ogni processo del gateway mantiene per ciascun canale un conteggio mobile degli errori negli ultimi cinque minuti. Dopo tre errori in quella finestra, il canale viene considerato aperto: per i successivi sessanta secondi viene rimosso dalla catena prima ancora che venga inviata una richiesta.

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;
}

Questo è l'unico livello veramente istantaneo, ed è importante essere precisi sul perché: non si sta misurando qualcosa rapidamente. La decisione è stata presa fino a cinque minuti prima da una richiesta precedente che ha subito l'errore al posto tuo. Lo stato è specifico del processo, quindi ogni macchina apprende indipendentemente; con traffico reale, un canale difettoso scatta su tutte nel giro di pochi secondi, mentre un canale rimasto indisponibile solo brevemente recupera autonomamente allo scadere del cooldown.

Livello 2: failover durante la richiesta (fino a tre passaggi)

Quando un canale che sembrava integro restituisce comunque un errore, la richiesta passa al successivo nella catena. Il limite è di tre passaggi, ROUTING_CHAIN_MAX, e la catena è ordinata per costo, quindi il primo tentativo viene sempre inviato al canale meno costoso in grado di servire il modello.

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");
}

Gli errori del client non attivano il failover. Un 400 per una richiesta malformata o un 404 per un modello inesistente produce la stessa risposta da ogni canale, quindi ritentare tre volte ti farebbe solo aspettare tre volte tanto per lo stesso messaggio. Solo gli errori upstream ritentabili attraversano la catena.

Il costo reale di questo livello è un round trip fallito per ogni passaggio. Se un canale accetta la connessione e poi restituisce 503 dopo due secondi, aspetti quei due secondi prima che inizi il secondo tentativo. Questo è il vero motivo per cui il livello 1 è importante: impedisce a un canale che fallisce sistematicamente di far pagare quel pedaggio di due secondi a ogni singola richiesta.

Livello 3: il blocco dello stream (30 secondi)

Lo streaming rende il failover più difficile in un modo facile da non notare. Un upstream può restituire 200 OK, aprire uno stream e solo dopo emettere un evento di errore. Quando questo arriva, un proxy ingenuo ha già inviato al client un'intestazione e alcuni byte, e non è più possibile passare al canale successivo: la richiesta è ormai vincolata a un canale che non risponderà.

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)

Perciò il gateway non esegue il commit in base al codice di stato. Trattiene i frame iniziali finché non vede qualcosa di decisivo: contenuto reale, nel qual caso lo stream viene confermato e inoltrato senza modifiche da quel momento in poi; oppure un errore, nel qual caso nulla ti è ancora arrivato e la richiesta prosegue lungo la catena. Trenta secondi sono il limite massimo per questa decisione e servono per i modelli di ragionamento che impiegano legittimamente molto tempo prima di emettere il primo token. Oltre quel limite confermiamo, perché un modello lento non è un modello guasto.

Livello 4: timeout delle intestazioni (240 secondi)

L'ultimo livello è quello che nessuno ama. Se un upstream accetta la connessione e poi non restituisce alcuna intestazione di risposta, la richiesta resta bloccata e l'unica cosa che la termina è un timeout: UPSTREAM_HEADERS_TIMEOUT_MS, quattro minuti.

Quattro minuti sono molti da aspettare, e la scelta è intenzionale. La generazione di video e immagini richiede legittimamente minuti; un timeout abbastanza breve da sembrare reattivo in chat annullerebbe lavori video destinati a riuscire. Inoltre, un upstream bloccato è raro rispetto a uno che fallisce esplicitamente, e i casi espliciti sono già gestiti dai livelli da 1 a 3. Preferiamo essere lenti nel caso raro piuttosto che sbagliare in quello comune.

Se per la tua applicazione vuoi un limite più stretto, imposta un timeout lato client. Il nostro è una protezione finale, non un obiettivo di latenza.

Ciò che non sosteniamo

  • Nessun tempo di failover espresso in millisecondi. Il livello 1 non costa alcun round trip perché la decisione precede la tua richiesta. I livelli da 2 a 4 durano esattamente quanto ha impiegato l'upstream a fallire. Nessuna delle due cose è un numero che possiamo riportare onestamente in una pagina di marketing.
  • Nessuna percentuale di disponibilità. Non esistono SLA né programmi di accredito, quindi una cifra sarebbe solo decorativa. La pagina di stato mostra il tasso di successo che osserviamo realmente, comprese le finestre in cui non è stato buono.
  • Nessuna sostituzione silenziosa del modello. Il failover sposta una richiesta tra canali che servono lo stesso modello. Non rispondiamo mai con un modello più economico di quello richiesto: è una pratica che vale la pena verificare direttamente con qualsiasi gateway, perché dalla risposta è invisibile.

Cosa significa quando ci costruisci sopra

In gran parte significa che puoi eliminare un ciclo di retry. Gli errori upstream ritentabili vengono già ritentati su canali indipendenti prima che tu veda un errore, quindi un retry lato client su un 5xx restituito da noi ritenta qualcosa che è già stato provato tre volte. Mantieni il timeout e rimuovi il backoff.

L'unica cosa che vale la pena gestire autonomamente è un 402 insufficient balance, che non è affatto un caso di failover: nessun numero di tentativi può ricaricare un portafoglio. Consulta il riferimento degli errori per sapere quali stati meritano un secondo tentativo e quali sono definitivi.