Полоска прогресса стоит на нуле. Минуту, две. Потом разом показывает сто процентов и пишет «готово». В логах воркера при этом всё честно: обработан первый файл, второй, третий, каждый с отметкой времени. Работа шла всё время, просто события не доходили до браузера по одному. Они накапливались где-то по дороге и вышли пачкой, когда ответ закончился.
Этот симптом хорош тем, что он наблюдаемый. По нему можно отличить настоящий поток событий от одного длинного ответа, который притворяется потоком. Дальше разберём, где именно застревают данные, какие настройки nginx за это отвечают, что происходит, когда вкладка закрыта, и как сделать так, чтобы вернувшийся пользователь увидел результат, а не запустил всё заново.
Поток или длинный ответ: как их отличить
Server-Sent Events — это обычный HTTP-ответ с типом text/event-stream, который сервер не закрывает и в который время от времени дописывает текст. Каждое событие — несколько строк, заканчивающихся пустой строкой. Браузерный EventSource читает такой ответ кусками и на каждое завершённое событие вызывает обработчик.
Разница с длинным ответом не в формате, а в моменте доставки. Если сервер сформировал десять событий за десять минут, у клиента есть два сценария: получить каждое сразу или получить все десять в конце. Для кода приложения они выглядят одинаково: генератор честно отдавал данные по одному. Поэтому «у меня же написан yield» ничего не доказывает. Доказывает только то, что видно с другой стороны сети.
Самый быстрый способ посмотреть это без браузера — запросить поток консольным клиентом с отключённой собственной буферизацией и смотреть, с какой периодичностью появляются строки:
curl -N -i https://example.com/jobs/42/eventsКлюч -N отключает буферизацию самого curl, чтобы не путать её с буферизацией по пути. Если строки идут по одной в момент отправки, поток живой. Если терминал молчит, а затем выплёвывает всё сразу, между приложением и вами есть накопитель. Полезно сделать эту проверку дважды: напрямую на порт uvicorn и через nginx. Разница между двумя запусками сразу показывает, на чьей стороне проблема.
Приложение: событие рождается в генераторе
Во FastAPI поток собирается из StreamingResponse. По документации FastAPI он принимает асинхронный или обычный генератор и передаёт тело по мере получения порций (FastAPI: Custom Response). Для SSE нужно лишь задать тип содержимого и формат строк.
Минимальный эндпоинт может выглядеть так:
import asyncio
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
def sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@app.get("/jobs/{job_id}/events")
async def job_events(job_id: str):
async def stream():
while True:
job = await load_job(job_id)
yield sse("progress", {"done": job.done, "total": job.total})
if job.state in ("finished", "failed"):
yield sse(job.state, {"result_url": job.result_url})
return
await asyncio.sleep(1)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)Здесь функция load_job условная: она читает состояние задания из места, о котором мы поговорим ниже. Обратите внимание на форму событий. Они короткие и предметные: progress с двумя числами, finished со ссылкой на результат, failed с причиной. Никаких «обновлений всего объекта»: клиенту нужно ровно столько, чтобы перерисовать полоску.
Два замечания о самом генераторе. Во-первых, await asyncio.sleep(1) здесь не только пауза. Это точка, в которой асинхронный код отдаёт управление и в которой его можно отменить. Документация FastAPI отмечает, что асинхронная задача обрабатывает отмену в точках await, а бесконечный цикл без await может не завершиться по запросу отмены. Поэтому цикл ожидания события без единого await — плохая идея сразу по двум причинам: он блокирует цикл событий и не умеет останавливаться.
Во-вторых, если вместо async def использовать обычный генератор с блокирующим time.sleep, FastAPI допускает и такой вариант, но тогда ожидание пройдёт в отдельном потоке из пула. Для десятка подписчиков это незаметно, для сотен уже стоит смотреть на количество потоков. Асинхронный вариант с await дешевле по ресурсам и естественно отменяется.
Прокси: где данные превращаются в тыкву
Теперь проведём одно событие через всю цепочку и посмотрим, где оно может задержаться.
- Генератор. Строка
yieldпередаёт текст серверу приложения. - uvicorn. Записывает порцию в сокет к nginx. Обычно без накопления.
- nginx. Принимает ответ upstream и по умолчанию складывает его в буферы, прежде чем отдавать клиенту.
- Всё, что за nginx. CDN, балансировщик, корпоративный прокси клиента: любое звено может поступить так же.
- Браузер. Получает куски и разбирает события.
Классический виновник — третий шаг. Директива proxy_buffering управляет буферизацией ответов upstream, а при её отключении данные передаются клиенту по мере получения (nginx: HTTP proxy module). При включённой буферизации nginx накапливает ответ и отдаёт его, когда набирается буфер или upstream заканчивает отвечать. Для обычных страниц это полезно: быстрый upstream освобождается, а медленный клиент не держит воркер приложения. Для потока из коротких событий это ровно то, что превращает его в «ноль, ноль, ноль, сто».
Размер события здесь играет вторую роль. Строка event: progress с парой чисел занимает несколько десятков байт, и буфер из нескольких килобайт заполняется очень нескоро. Поэтому в тестах с короткими событиями симптом проявляется ярче всего: пока ответ не завершится, nginx просто нечем «выталкивать».
Два способа выключить буфер
Первый способ — на стороне конфигурации nginx, для конкретного пути:
location /jobs/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 75s;
}Второй способ — заголовок ответа X-Accel-Buffering: no, который мы уже поставили в приложении. По документации nginx, такой заголовок может управлять буферизацией, если это не запрещено конфигурацией (nginx: HTTP proxy module). Оговорка важна: если в конфигурации включена директива, игнорирующая этот заголовок, приложение не сможет переопределить поведение прокси.
Какой способ выбрать? Заголовок удобен тем, что решение живёт рядом с кодом эндпоинта: тот, кто пишет поток, сам заявляет, что ему нужна доставка без накопления, и не зависит от того, кто правил конфиг. Директива в location надёжнее тем, что работает независимо от приложения и видна администратору. На практике разумно держать оба: заголовок как заявку со стороны приложения, директиву как страховку для конкретного пути, чтобы обычные страницы сайта сохранили буферизацию.
Строки proxy_http_version 1.1 и пустой Connection здесь стоят ради долгоживущего соединения с upstream: по умолчанию nginx говорит с upstream по HTTP/1.0, а для потока нужно соединение, которое остаётся открытым. Это общепринятая схема для долгих ответов, но проверяйте её на своей версии nginx сами.
Время ожидания: общая длина ответа не при чём
Вторая частая беда — обрыв потока через минуту после начала. Тут легко ошибиться в понимании директивы proxy_read_timeout. Она относится к интервалу между двумя последовательными чтениями из upstream, а не к общей длительности ответа (nginx: HTTP proxy module). Значит, поток может длиться час, если события приходят чаще, чем истекает таймаут. И наоборот: если таймаут равен шестидесяти секундам, а задача молчит девяносто, nginx закроет соединение, хотя работа идёт нормально.
Отсюда следует простая арифметика. Если ваши события приходят нерегулярно, например после тяжёлого шага обработки видео, нужно либо поднять таймаут выше самой длинной паузы, либо заполнять паузы служебными сообщениями. Второе обычно лучше: огромный таймаут сохраняет и зависшие соединения, а регулярный сигнал даёт и nginx, и браузеру, и любому промежуточному узлу понять, что соединение живо.
В SSE для таких сигналов есть удобный приём: строка, начинающаяся с двоеточия, считается комментарием и клиентом игнорируется. Её можно отправлять как «пульс»:
HEARTBEAT = ": ping\n\n"
async def stream():
last_sent = 0.0
while True:
job = await load_job(job_id)
...
yield HEARTBEAT if nothing_new else sse("progress", {...})
await asyncio.sleep(15)Выбирайте интервал пульса заметно короче proxy_read_timeout: при таймауте в 75 секунд пульс раз в 15–20 даёт запас на несколько пропущенных циклов. Цифры здесь условные, подберите их под свою цепочку, ведь за nginx могут стоять другие узлы со своими лимитами.
Заодно пульс решает ещё одну задачу. Он первым делом пробивает накопители: если по дороге остался буфер, который вы не нашли, регулярные мелкие строки не доезжают до клиента — и это видно сразу, а не через час после релиза.
Сводка симптомов
| Симптом | Вероятная причина | Что проверить | |
|---|---|---|---|
| Все события приходят разом в конце | Буферизация ответа в nginx или другом промежуточном узле | proxy_buffering, заголовок X-Accel-Buffering, сравнение запроса к uvicorn и через nginx | |
| Поток обрывается примерно через одну и ту же паузу | Интервал молчания превысил proxy_read_timeout | Самая длинная пауза между событиями, периодичность пульса | |
| Поток идёт, но клиент видит один и тот же старый статус | Кэширование или чтение устаревшего состояния | Заголовок Cache-Control, источник данных в load_job | |
| После закрытия вкладки задача остановилась | Работа привязана к жизни запроса | Где исполняется задача: в обработчике запроса или отдельно |
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Закрытая вкладка и судьба работы
Теперь самое важное различие, о котором легко забыть. Соединение с клиентом и сама задача — две разные вещи. Когда пользователь закрывает вкладку, обрывается именно поток событий. Вопрос в том, что стояло за ним.
Если тяжёлая работа выполняется прямо внутри генератора, то есть событие отдаётся после очередного шага вычислений в том же корутинном цикле, тогда закрытие соединения приведёт к отмене всего обработчика, и вместе с потоком оборвётся работа. Это не баг SSE, а следствие того, что работа жила в запросе. Документация FastAPI напоминает, что отмена асинхронной задачи срабатывает в точках await (FastAPI: Custom Response): на очередном ожидании внутри генератора вылетит CancelledError, и обработка прервётся посередине файла.
Поэтому задачу и поток разделяют. Эндпоинт, который принимает работу, создаёт запись о задании со статусом queued и отдаёт клиенту идентификатор. Саму работу выполняет отдельный исполнитель: фоновый воркер, очередь, отдельный процесс. Эндпоинт событий только читает состояние и пересказывает его в виде потока. Тогда закрытая вкладка значит ровно одно: у задания стало на одного слушателя меньше.
Такое разделение имеет приятное следствие: слушателей может быть ноль, один или три. Пользователь открыл задание в двух вкладках, коллега смотрит на ту же страницу — всем им достаётся один и тот же статус, а работа не удваивается.
Что делать с отменой внутри потока
Отмена в генераторе всё равно случится, и это нормально: когда клиент ушёл, поток должен остановиться и освободить ресурсы. Но если внутри держится что-то, что нужно закрыть, например соединение с базой или подписка, оформляйте это через try/finally:
async def stream():
try:
while True:
...
await asyncio.sleep(15)
finally:
await release_subscription(job_id)Блок finally выполнится и при нормальном завершении, и при отмене. Только не оставляйте там код, который сам бесконечно ждёт: в отменяемом участке такой код может зависнуть и держать соединение вместо того, чтобы его отпустить.
Состояние живёт не в потоке
Здесь стоит сформулировать главное правило этой статьи: поток прогресса не должен быть единственным местом хранения состояния задачи. Поток — это окно, через которое смотрят на задание. Если вся правда о ходе работы существует только как последовательность событий, прошедших по конкретному соединению, то любой обрыв стирает историю: кто не успел увидеть событие, тот его не увидит.
Минимальная запись о задании может быть очень скромной:
{
"id": "42",
"state": "running",
"done": 7,
"total": 20,
"result_url": null,
"error": null,
"updated_at": "2026-11-01T10:15:30Z"
}Хранить её можно там, где вам удобно: в таблице базы данных, в Redis, в файле рядом с результатом для совсем небольших систем. Важно не место, а свойство: запись переживает обрыв соединения и перезапуск веб-процесса, и её можно прочитать обычным запросом, без всякого потока.
Тогда события становятся вторичным представлением. Воркер обновляет запись, эндпоинт событий читает её и отправляет изменения. Если поток оборвался, никакого ущерба нет: клиент спросит состояние заново. Поэтому полезно иметь и обычный обработчик, возвращающий одну такую запись:
@app.get("/jobs/{job_id}")
async def job_status(job_id: str):
return await load_job(job_id)Он нужен не как запасной вариант «на всякий случай», а как точка, с которой начинается любой возврат на страницу.
Повторное подключение без сюрпризов
Браузерный EventSource при обрыве сам пытается подключиться снова. Это удобно, но надо понимать, что именно повторится: тот же GET-запрос на тот же адрес. Если эндпоинт событий что-то запускает или изменяет, повторное подключение запустит это ещё раз. Поэтому правило простое: эндпоинт потока ничего не запускает, а только читает. Создание задания живёт в отдельном POST, и подключение к потоку никогда не бывает причиной начала работы.
Второй вопрос — что отправить в первую очередь после подключения. Самое разумное решение: сразу, до любых ожиданий, отправить текущее состояние целиком. Клиент, пришедший в середине работы, мгновенно получит progress с числами 7 и 20, а не будет ждать следующего шага. Клиент, пришедший после завершения, получит finished и закроется.
Спецификация SSE предусматривает для этого поле id в событии и заголовок Last-Event-ID, который браузер отправляет при переподключении. Его можно использовать, если вы храните журнал событий и хотите дослать только пропущенные. Но для прогресса задания это чаще лишнее: пропущенные промежуточные проценты никому не нужны, достаточно актуального состояния. Журнал событий оправдан там, где важна каждая запись, например лента сообщений. Для полоски «7 из 20» хватает снимка.
Ещё одна деталь: когда задание завершено, поток должен закончиться, а не висеть вхолостую. Если сервер просто закроет соединение, EventSource решит, что произошёл обрыв, и подключится снова, затем снова получит finished, и так по кругу. Чтобы этого не было, клиент после события finished или failed сам вызывает close():
const es = new EventSource(`/jobs/${id}/events`);
es.addEventListener("progress", e => render(JSON.parse(e.data)));
es.addEventListener("finished", e => {
showResult(JSON.parse(e.data).result_url);
es.close();
});
es.addEventListener("failed", e => {
showError(JSON.parse(e.data));
es.close();
});Ресурсы VPS под долгие соединения
Каждый подписчик потока — это открытое соединение, которое держит сокет у nginx, сокет у uvicorn и небольшую корутину в приложении. Асинхронные соединения дёшевы по процессору, но не бесплатны по памяти и числу файловых дескрипторов. Сколько их выдержит конкретная машина, зависит от вашего кода, размера событий и частоты опроса состояния, поэтому это стоит измерять, а не предполагать.
Практичнее смотреть на другое: сколько запросов к хранилищу состояния создают подписчики. Если каждый из сотни слушателей раз в секунду читает запись из базы, это сто запросов в секунду ради полоски прогресса. Если слушатели смотрят на одно и то же задание, дешевле читать состояние один раз и раздавать его всем, либо увеличить паузу между опросами до нескольких секунд. Для хода задачи точность в долю секунды обычно не нужна.
Если вы только подбираете машину под такую связку, начинать можно с небольшой конфигурации из линейки VPS MAATRIX с NVMe-накопителем и root-доступом, где вы сами управляете nginx и числом процессов. Тарифы вроде Ba или Uab с их ресурсами подходят как отправная точка для опыта, но не как гарантия числа клиентов: реальный потолок покажет нагрузочная проверка вашего приложения. Как распределять процессы приложения по ядрам, разобрано в материале VPS для FastAPI: uvicorn и gunicorn workers; для SSE это важно потому, что каждый процесс держит свою долю долгих соединений.
Как убедиться, что цепочка работает
Проверку удобно разделить на три шага, каждый из которых отвечает на свой вопрос.
- Запрос напрямую к uvicorn. События должны приходить по одному, с паузами, соответствующими вашим
sleep. Если здесь уже всё разом, дело в приложении или в способе отдачи. - Тот же запрос через nginx. Если теперь события слипаются, виноват прокси: проверьте
proxy_buffering, а также нет ли глобальных настроек, перекрывающих заголовокX-Accel-Buffering. - Длинная пауза. Запустите задание с шагом, который молчит дольше таймаута, и посмотрите, переживёт ли поток паузу благодаря пульсу.
Отдельно стоит пройти сценарий обрыва: откройте страницу, дождитесь середины работы, закройте вкладку, подождите и откройте снова. Задание должно продолжать идти всё это время, а новое подключение должно сразу показать актуальный статус.
Вкладка, которая вернулась
Теперь можно представить финальный эпизод. Пользователь запускает обработку двадцати файлов и закрывает вкладку на седьмом: вспомнил про встречу. Соединение с потоком закрывается, генератор получает отмену на ближайшем await, finally освобождает подписку. Воркер ничего этого не замечает: он продолжает обрабатывать восьмой файл, девятый, десятый и обновлять запись о задании.
Через полчаса человек возвращается на ту же страницу. Страница знает идентификатор задания из адреса и сначала запрашивает обычный статус. Там написано finished и лежит ссылка на результат. Никакого нового запуска, никакого «начать заново»: клиент показывает готовое и не открывает поток вообще. Если бы он вернулся на пятнадцатом файле, он открыл бы поток, мгновенно получил снимок «15 из 20» и дальше видел бы события по одному.
Именно так проверяется всё остальное в этой статье. Буфер nginx отключён, потому что без этого события не дойдут вовремя. Пульс есть, потому что пауза не должна выглядеть как смерть соединения. Состояние лежит отдельно, потому что поток — только окно, а не хранилище. И запуск работы отделён от чтения, потому что возвращение пользователя не должно ничего перезапускать. Полоска прогресса, которая честно показывает семь из двадцати и не торопится соврать про сто, оказывается просто следствием того, что каждая часть цепочки отвечает за своё.