Le problème
AS-ChatIA relaie les messages utilisateur vers l'API DeepSeek et streame la réponse token par token. En conditions normales, tout fonctionne. Mais sous rafale de requêtes (plusieurs onglets ouverts, tests rapides), l'API a renvoyé des erreurs 429 Too Many Requests. Côté front, le flux SSE se coupait net, sans que l'utilisateur comprenne pourquoi la réponse s'arrêtait en plein milieu d'une phrase.
Pourquoi c'est piégeux avec du streaming
Avec un appel HTTP classique, gérer un 429 est trivial : on catch l'erreur, on retry, on répond. Avec du streaming SSE, le problème est différent : soit l'erreur survient avant le premier chunk (facile à gérer), soit elle survient en cours de flux — après que le client a déjà reçu et affiché une partie de la réponse. Il faut alors décider quoi faire avec ce qui a déjà été streamé.
Première version, naïve
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;
}
Aucune gestion d'erreur : si streamReply lançait une exception à cause d'un 429, le générateur PHP s'arrêtait et le flux SSE se fermait brutalement, sans que le front sache pourquoi.
Le correctif : retry avant le premier chunk, message d'erreur explicite pendant le flux
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)));
}
}
}
Côté front, un chunk contenant {"error": "rate_limited"} déclenche un message clair dans l'UI (« Trop de requêtes, réessaie dans quelques secondes ») au lieu d'un flux qui s'arrête sans explication.
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);
Ce que je retiens
- Le retry avec backoff exponentiel ne doit s'appliquer qu'avant le premier chunk envoyé au client — une fois le streaming commencé, on ne peut plus « rejouer » la requête proprement.
- Un flux SSE qui s'arrête sans message d'erreur explicite est pire qu'un flux lent : l'utilisateur ne sait pas s'il doit attendre ou réessayer.
- Encoder les erreurs comme des chunks JSON typés (
{"error": "..."}) permet au front de les distinguer des deltas de contenu sans changer le protocole SSE. - Tester le comportement sous charge (même artificiellement, via plusieurs onglets) révèle des cas limites invisibles en usage normal.