Байты приходят, события нет: проверяем кадры SSE и gzip

Сервер отправляет по SSE три коротких сообщения с паузой в четверть секунды, а обработчик на клиенте срабатывает только в конце: либо три раза подряд, либо один раз со всеми данными сразу. Логи сервера показывают, что каждая запись ушла вовремя. Где задержались данные?
Причин у такого симптома много: прокси, CDN, буфер приложения, медленный клиент. Я разобрал две из них на маленьком стенде, где все остальные слои убраны: незавершённый кадр SSE и gzip без промежуточного сброса. Обе причины описаны в документации. Задача стенда другая: показать, по каким наблюдениям их можно отличить друг от друга, не гадая.
Три точки наблюдения
Между записью на сервере и обработчиком события на клиенте данные проходят три этапа, и на каждом можно поставить отметку времени:
Байты тела HTTP. Чтение из сокета вернуло хоть что-то.
Декодированный текст. После распаковки
Content-Encodingпоявились строкиdata:.Готовое событие. Парсер SSE увидел конец блока и отдал событие дальше.
Если задержка возникает между первой и второй точкой, данные застряли в кодеке. Если между второй и третьей, текст пришёл, но парсер ещё не считает событие законченным.
Стенд
Один скрипт на стандартной библиотеке Python 3.15.0 (zlib 1.3.1) в контейнере python:3.15-slim. HTTP/1.0-сервер и клиент работают в одном процессе на 127.0.0.1, без TLS, прокси и браузера. Сервер пишет три строки data: 0, data: 1, data: 2 с паузой 250 мс, после каждой записи вызывает wfile.flush(). Клиент читает тело через HTTPResponse.read1(4096), при необходимости распаковывает и разбирает события. Каждый режим повторён три раза; ниже медианы, полный вывод с размерами каждого чтения лежит в журнале проверки.
Режимы отличаются одним параметром:
Режим | Что меняется |
|---|---|
| после каждой строки |
| между строками |
| кадры правильные, ответ сжат gzip, компрессор сбрасывается только в конце ( |
| то же сжатие, но после каждого кадра |
Ядро сервера:
for i in range(COUNT):
frame = f'data: {i}\n'.encode()
if mode != 'missing_separator':
frame += b'\n'
payload = encode_frame(codec, frame, mode == 'gzip_sync') if codec else frame
if payload:
self.wfile.write(payload)
self.wfile.flush()
time.sleep(PERIOD)
Клиентский парсер минимальный: он режет текст по \n\n и собирает строки data: . Он покрывает ровно то подмножество формата, которое использует стенд, и полноценным EventSource не является.
Результат
Режим | Первые байты тела | Первый текст после декодера | Первое событие | Событий |
|---|---|---|---|---|
| 1 мс | 1 мс | 1 мс | 3 |
| 1 мс | 1 мс | 751 мс | 1 |
| 1 мс | 751 мс | 752 мс | 3 |
| 1 мс | 1 мс | 1 мс | 3 |
Во всех четырёх режимах байты начинают приходить сразу. Поэтому проверка «соединение живое и что-то пришло» ничего не различает: она одинаково проходит и в рабочем, и в сломанных вариантах.
Различает то, где появляется задержка в 750 мс (три паузы по 250 мс).
missing_separator. Текст после декодера виден сразу, все три строки data: дошли вовремя. События нет до тех пор, пока сервер не допишет пустую строку. Потом приходит одно событие с данными 0\n1\n2. По стандарту WHATWG событие отправляется на пустой строке, а несколько строк data: до неё склеиваются через перевод строки. Если поток закончится без пустой строки, незаконченный блок будет отброшен, поэтому в стенде сервер дописывает её явно.
gzip_finish. Байты тоже приходят сразу, но это 10 байт заголовка gzip. Вторая и третья запись на сервере вообще ничего не отдали в сокет: компрессор принял по 9 байт и вернул пустую строку. Все данные ушли одним куском в 27 байт при завершающем flush(), и декодер выдал текст только тогда. Три события появились одновременно, на 752-й миллисекунде.
Обработчик HTTP здесь ни при чём: в стенде wfile пишет в сокет сразу, без своего буфера, и wfile.flush() после каждой записи ничего не меняет. Данные лежат внутри компрессора: compress() вернул пустую строку, и отправлять в сокет было нечего. В документации zlib для Python это разделено явно: Z_SYNC_FLUSH выдаёт всё накопленное и оставляет поток открытым для продолжения, а flush() без аргумента использует Z_FINISH и закрывает поток.
gzip_sync. С Z_SYNC_FLUSH после каждого кадра сервер отправил 25, 12 и 11 байт, а в конце ещё 10 байт завершения потока. Текст и события появляются сразу, как без сжатия.
Порядок диагностики
Для симптома «события приходят пачкой» по стенду получается такая последовательность:
Отметить время чтений тела ответа. Учтите, что первые байты могут оказаться только заголовком gzip без единой строки
data:.Отметить время текста после декодера. Если байты есть, а текста нет, проверьте, выдаёт ли компрессор данные до завершения ответа. Самая быстрая проверка: тот же поток без сжатия и тот же поток с синхронным сбросом.
Если строки
data:видны, а события нет, проверьте пустую строку после каждого кадра. Признак именно этой причины: вместо нескольких событий приходит одно с многострочными данными.Если текст и события появляются вовремя, причина за пределами этих двух веток. Стенд её не показывает, и угадывать её по этим данным не стоит.
Ограничения
Парсер стенда понимает только UTF-8,
LFи полеdata:.CRLF,id,event,retry, переподключение и поведение браузерного EventSource не проверялись.Нет TLS, HTTP/2, nginx, CDN, медленного клиента и большого потока. Буферизация на этих слоях существует, но здесь не воспроизводится. Список причин из статьи не полный.
750 мс заданы паузами сервера и ничего не говорят о скорости SSE или gzip. Миллисекунды, размеры и число чтений относятся к этому прогону и этим версиям.
Сравнивается конкретный кодек с конкретным режимом сброса. Из опыта не следует, что сжатие несовместимо с SSE или что его обязательно отключать: в режиме
gzip_syncоба требования выполнены одновременно.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.