SSE приходит одним куском вместо потока
События Server-Sent Events прилетают пачкой вместо потока. Их держат три слоя: proxy_buffering, gzip и само приложение. Как проверить каждый.
Что это значит
Сервер пишет события по одному. Браузер тридцать секунд не получает ничего, а
потом сразу выдаёт двадцать message. На тот же адрес curl -N показывает
ровный поток, значит байты из приложения выходят, а копят их между приложением
и браузером. Почти всегда копит nginx.
Как исправить
Поток держат три слоя. Проверяйте их в таком порядке.
Слой 1: nginx. proxy_buffering включён по умолчанию. nginx забирает ответ
целиком, освобождает бэкенд и отдаёт клиенту в своём темпе. Для потока это
ровно то, чего быть не должно:
location /events {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_read_timeout 1h;
}
По строкам:
proxy_buffering offи есть то самое исправление. Каждый кусок от бэкенда уходит клиенту сразу.proxy_cache offстоит и так по умолчанию, но кэш, унаследованный от родительского блока, собрал бы поток целиком. Лучше написать явно.chunked_transfer_encoding onповторяет значение по умолчанию, и его надо оставить. В скопированных конфигах часто стоитoff, и это ломает SSE: у потока нетContent-Length, а границы кускам задаёт именно chunked-кодирование.proxy_read_timeoutпо умолчанию равен60s. Поток, который замолчал, обрывается на минуте. Это отдельная причина.proxy_http_version 1.1вместе сproxy_set_header Connection ''лечит другое. nginx по умолчанию ставит бэкендуConnection: close, а старые версии проксируют по HTTP/1.0. Эти две строки дают пулуupstream … keepaliveпереиспользовать соединение, то есть ту самую постоянную связь из RFC 9112, пункт 9.3. На буферизацию они не влияют.
Конфиг править нельзя? Отправьте заголовок из приложения:
X-Accel-Buffering: no
nginx его читает и выключает буферизацию для этого ответа. Так переносимее,
потому что заголовок едет вместе с кодом. Работает, только если прокси действительно nginx
и заголовок не выброшен через proxy_ignore_headers.
Слой 2: сжатие
(Content-Encoding).
nginx жмёт только типы из gzip_types, а по умолчанию там
один text/html, так что SSE обычно не страдает. Но если кто-то добавил
text/event-stream (или *), gzip копит байты до целого блока. Убрать один тип
из списка нельзя, поэтому выключите gzip в location с потоком:
gzip off; # внутри location, который отдаёт поток
Слой 3: само приложение. Многие фреймворки держат ответ до возврата из обработчика. Заголовки надо отправить первыми, а каждое событие писать по мере появления.
// Node.js
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
function send(data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
Событие заканчивает пустая строка, поэтому нужны оба \n: с одним браузер
продолжает ждать.
Node не буферизует res.write, а вот middleware compression в Express
буферизует. Отключите его для этого маршрута или вызывайте res.flush() после
каждого события.
# Flask
return Response(
generate(),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
generate() должен отдавать через yield целые события ("data: …\n\n") по
одному и по мере появления. Если сначала собрать список, буферизацию вы написали
сами.
Вторая ловушка в Python живёт в синхронном воркере gunicorn: он обслуживает одно
соединение за раз и убивает запрос по --timeout, а это 30 секунд по умолчанию.
Для WSGI берите воркеры gevent или eventlet, для ASGI подойдёт uvicorn.
Как проверить, какой слой виноват
Два запроса:
# прямо в приложение, мимо nginx
curl -N http://127.0.0.1:8000/events
# через nginx
curl -N https://example.com/events
- Первый ровный, второй пачкой: копит nginx.
- Оба пачкой: копит приложение.
- Оба ровные, а браузер всё равно ждёт: проверьте
Content-Type.EventSourceтребует ровноtext/event-stream; на любом другом типе он выдаётerrorи закрывает соединение.
Как увидеть это у себя
В ответе text/event-stream время прихода важно не меньше содержимого. Прокси
показывает тело по мере поступления. События идут по одному с паузами, значит
поток живой; всё тело приходит сразу через сорок секунд, значит его где-то
копили. Тот же ответ, что дают два curl выше, но без доступа к серверу.
Как Solpuga записывает стрим: Перехват и просмотр трафика.
Инструмент к этой странице
Разбор SSE-потокаВставьте сырой поток Server-Sent Events и увидите события, id, retry и ошибки формата.Рядом
- Стрим обрывается через 60 секунд: proxy_read_timeoutОбрыв на одной и той же секунде означает таймаут, а не сеть. Значения по умолчанию в nginx, ALB, Cloudflare, Heroku и Envoy и почему пинг надёжнее.
- Ромбики «�» в стриме: TextDecoder и stream: trueМногобайтовый символ разрезан на границе чанка. Чинят один декодер и один флаг, а такая же ошибка прячется уровнем выше, в разборе SSE.