Стрим обрывается через 60 секунд

Обрыв на одной и той же секунде означает таймаут, а не сеть. Значения по умолчанию в nginx, ALB, Cloudflare, Heroku и Envoy и почему пинг надёжнее.

Что это значит

Поток работает, события идут, а потом соединение закрывается, и всегда на одной и той же секунде, чаще всего на шестидесятой. Сеть рвётся когда попало. Таймаут срабатывает по расписанию, значит это таймаут; остаётся выяснить, чей.

Как исправить

Поднимите таймаут на каждом прокси, до которого дотягиваетесь. Значения по умолчанию собраны ниже.

nginx ставит proxy_read_timeout в 60 секунд. Он считает паузу между двумя чтениями из бэкенда, а не время жизни соединения. Поэтому поток с событием раз в десять секунд живёт сколько угодно, а поток, замолчавший на минуту, обрывается.

location /events {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    send_timeout 3600s;
}

Три директивы считают три разные вещи. proxy_read_timeout следит за паузой в ответе бэкенда, proxy_send_timeout — за отправкой запроса бэкенду, send_timeout — за записью клиенту. У всех трёх по умолчанию 60 секунд.

Kubernetes Ingress (nginx) принимает те же числа аннотациями:

nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"

AWS ALB держит атрибут idle_timeout.timeout_seconds на 60 секундах.

aws elbv2 modify-load-balancer-attributes \
  --load-balancer-arn <arn> \
  --attributes Key=idle_timeout.timeout_seconds,Value=3600

Apache наследует ProxyTimeout от Timeout, а он в версии 2.4 равен 60 секундам. Поставьте ProxyTimeout 3600.

Envoy требует внимания. Таймаут маршрута timeout по умолчанию равен 15 секундам и ограничивает весь ответ, а не паузы в нём. Пинг его не обманет. Поставьте 0s и опирайтесь на stream_idle_timeout, где стоит 5 минут.

Cloudflare отдаёт ошибку 524, если origin молчит 125 секунд. Поднять лимит до 6000 секунд можно только на Enterprise. Остальным нужен пинг.

Heroku не настраивается. У приложения есть 30 секунд, чтобы отдать хоть что-то, а дальше остаётся 55 секунд между байтами, иначе роутер обрывает соединение (H12, H15, H28).

gunicorn ставит timeout в 30 секунд, и это не таймаут прокси: мастер убивает воркера, который столько молчал. Для sync-воркеров долгий стрим и есть молчание. Берите gevent или eventlet, либо поставьте --timeout 0.

У uvicorn таймаута на запрос нет вообще. --timeout-keep-alive (5 секунд) относится только к простаивающим keep-alive соединениям, так что винить в обрыве тут нечего.

Пинг надёжнее, чем настройка

Поднять таймаут можно только на тех прокси, о которых вы знаете. Между вашим nginx и браузером пользователя может оказаться корпоративный прокси, домашний роутер и мобильный оператор. У каждого свой таймаут, и его конфиг вы не увидите никогда. RFC 9112, пункт 9.5 разрешает любому из них в любой момент закрыть простаивающее соединение.

Отсюда правило: не молчать подолгу. В SSE для этого есть строка-комментарий. Она начинается с двоеточия, событий не порождает, и клиент её игнорирует.

const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
req.on('close', () => clearInterval(heartbeat));

Каждые 15 секунд, а не каждые 59: таймауты в цепочке прокси бывают короче минуты. Стоит это восемь байт.

Одна оговорка. Пинг помогает только против таймаутов простоя. Ограничение на общую длительность ответа обрежет поток независимо от того, сколько данных вы успели отправить: так считает timeout маршрута в Envoy и так ведут себя многие API-шлюзы. Такие лимиты приходится поднимать руками.

Заодно скажите клиенту, сколько ждать перед переподключением. EventSource применит это сам:

retry: 3000

Как отличить таймаут от других причин

Что видно Что это
Обрыв на одной и той же секунде таймаут на одном из прокси
Обрыв через случайное время, все клиенты разом перезапуск или падение приложения
Обрыв через случайное время у одного клиента сеть клиента
Данные приходят пачкой, а потом обрыв буферизация плюс таймаут

С WebSocket происходит то же и по той же причине. Только выглядит это как Close-код 1006, в котором не написано ничего.

Как увидеть это у себя

Нужны два числа: когда соединение открылось и когда закрылось. Прокси пишет длительность каждого запроса: колонка, забитая ровно шестьюдесятью секундами, означает таймаут. Там же видно, шли ли данные перед обрывом: если последнее событие пришло на пятьдесят девятой секунде, таймаут ни при чём и искать надо в приложении. Как Solpuga записывает стрим: Перехват и просмотр трафика.

Посмотреть в Solpuga

Инструмент к этой странице

Разбор SSE-потокаВставьте сырой поток Server-Sent Events и увидите события, id, retry и ошибки формата.

Рядом