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

—

от автора

Сервер отправляет по SSE три коротких сообщения с паузой в четверть секунды, а обработчик на клиенте срабатывает только в конце: либо три раза подряд, либо один раз со всеми данными сразу. Логи сервера показывают, что каждая запись ушла вовремя. Где задержались данные?

Причин у такого симптома много: прокси, CDN, буфер приложения, медленный клиент. Я разобрал две из них на маленьком стенде, где все остальные слои убраны: незавершённый кадр SSE и gzip без промежуточного сброса. Обе причины описаны в документации. Задача стенда другая: показать, по каким наблюдениям их можно отличить друг от друга, не гадая.

Три точки наблюдения

Между записью на сервере и обработчиком события на клиенте данные проходят три этапа, и на каждом можно поставить отметку времени:

  1. Байты тела HTTP. Чтение из сокета вернуло хоть что-то.

  2. Декодированный текст. После распаковки Content-Encoding появились строки data:.

  3. Готовое событие. Парсер 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), при необходимости распаковывает и разбирает события. Каждый режим повторён три раза; ниже медианы, полный вывод с размерами каждого чтения лежит в журнале проверки.

Режимы отличаются одним параметром:

Режим

Что меняется

plain

после каждой строки data: пустая строка, без сжатия

missing_separator

между строками data: только один перевод строки; пустая строка дописывается один раз, после третьей

gzip_finish

кадры правильные, ответ сжат gzip, компрессор сбрасывается только в конце (Z_FINISH)

gzip_sync

то же сжатие, но после каждого кадра flush(zlib.Z_SYNC_FLUSH)

Ядро сервера:

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 не является.

Результат

Режим

Первые байты тела

Первый текст после декодера

Первое событие

Событий

plain

1 мс

1 мс

1 мс

3

missing_separator

1 мс

1 мс

751 мс

1

gzip_finish

1 мс

751 мс

752 мс

3

gzip_sync

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 байт завершения потока. Текст и события появляются сразу, как без сжатия.

Порядок диагностики

Для симптома «события приходят пачкой» по стенду получается такая последовательность:

  1. Отметить время чтений тела ответа. Учтите, что первые байты могут оказаться только заголовком gzip без единой строки data:.

  2. Отметить время текста после декодера. Если байты есть, а текста нет, проверьте, выдаёт ли компрессор данные до завершения ответа. Самая быстрая проверка: тот же поток без сжатия и тот же поток с синхронным сбросом.

  3. Если строки data: видны, а события нет, проверьте пустую строку после каждого кадра. Признак именно этой причины: вместо нескольких событий приходит одно с многострочными данными.

  4. Если текст и события появляются вовремя, причина за пределами этих двух веток. Стенд её не показывает, и угадывать её по этим данным не стоит.

Ограничения

  • Парсер стенда понимает только UTF-8, LF и поле data:. CRLF, id, event, retry, переподключение и поведение браузерного EventSource не проверялись.

  • Нет TLS, HTTP/2, nginx, CDN, медленного клиента и большого потока. Буферизация на этих слоях существует, но здесь не воспроизводится. Список причин из статьи не полный.

  • 750 мс заданы паузами сервера и ничего не говорят о скорости SSE или gzip. Миллисекунды, размеры и число чтений относятся к этому прогону и этим версиям.

  • Сравнивается конкретный кодек с конкретным режимом сброса. Из опыта не следует, что сжатие несовместимо с SSE или что его обязательно отключать: в режиме gzip_sync оба требования выполнены одновременно.

ссылка на оригинал статьи https://habr.com/ru/articles/1100912/