Il problema
AS-ChatIA inoltra i messaggi dell'utente all'API DeepSeek e trasmette la risposta in streaming token per token. In condizioni normali funziona tutto. Ma sotto raffiche di richieste (più schede aperte, test rapidi), l'API ha restituito errori 429 Too Many Requests. Lato front, il flusso SSE si interrompeva di netto, senza che l'utente capisse perché la risposta si fermava a metà frase.
Perché è insidioso con lo streaming
Con una chiamata HTTP classica, gestire un 429 è banale: si intercetta l'errore, si fa retry, si risponde. Con lo streaming SSE il problema è diverso: o l'errore si verifica prima del primo chunk (facile da gestire), oppure si verifica durante il flusso — dopo che il client ha già ricevuto e mostrato una parte della risposta. Bisogna allora decidere cosa fare con ciò che è già stato trasmesso in streaming.
Prima versione, ingenua
public function chat(Request $request, ChatService $chat): StreamedResponse
{
$message = json_decode($request->getContent(), true)['message'] ?? '';
$response = new StreamedResponse(function () use ($chat, $message) {
foreach ($chat->streamReply($message) as $chunk) {
echo 'data: ' . json_encode(['delta' => $chunk]) . "\n\n";
ob_flush();
flush();
}
});
return $response;
}
Nessuna gestione degli errori: se streamReply lanciava un'eccezione a causa di un 429, il generatore PHP si fermava e il flusso SSE si chiudeva bruscamente, senza che il front sapesse perché.
La correzione: retry prima del primo chunk, messaggio di errore esplicito durante il flusso
public function streamReply(string $userMessage): \Generator
{
$attempts = 0;
$maxAttempts = 3;
while (true) {
try {
foreach ($this->client->stream($this->buildMessages($userMessage)) as $chunk) {
yield $chunk;
}
return;
} catch (RateLimitException $e) {
$attempts++;
if ($attempts >= $maxAttempts) {
// on a peut-être déjà yield des chunks : on informe proprement le front
yield json_encode(['error' => 'rate_limited'], JSON_THROW_ON_ERROR);
return;
}
// backoff exponentiel avant de retenter, uniquement si rien n'a encore été streamé
usleep((int) (300_000 * (2 ** $attempts)));
}
}
}
Lato front, un chunk contenente {"error": "rate_limited"} attiva un messaggio chiaro nell'UI («Troppe richieste, riprova tra qualche secondo») invece di un flusso che si interrompe senza spiegazione.
const { delta, error } = JSON.parse(data);
if (error === 'rate_limited') {
showToast('Trop de demandes en même temps, réessaie dans un instant.');
return;
}
onDelta(delta);
Cosa ne ricavo
- Il retry con backoff esponenziale deve applicarsi solo prima del primo chunk inviato al client — una volta iniziato lo streaming, non si può più «rigiocare» la richiesta in modo pulito.
- Un flusso SSE che si interrompe senza un messaggio di errore esplicito è peggio di un flusso lento: l'utente non sa se deve aspettare o riprovare.
- Codificare gli errori come chunk JSON tipizzati (
{"error": "..."}) permette al front di distinguerli dai delta di contenuto senza cambiare il protocollo SSE. - Testare il comportamento sotto carico (anche artificialmente, tramite più schede) rivela casi limite invisibili in uso normale.