ERR_CONNECTION_CLOSED и ERR_EMPTY_RESPONSE: сервер молча закрыл соединение
Соединение установилось, TLS прошёл, запрос ушёл — и в ответ ничего: ни кода 5xx, ни страницы ошибки, только «Сайт не отправил данных». Разбираем, чем эти коды отличаются друг от друга и от ERR_CONNECTION_RESET, какие директивы nginx, PHP-FPM и Apache к ним приводят и как найти виновника по curl, tcpdump и логам.
ERR_CONNECTION_CLOSED (код Chromium −100) означает, что сервер корректно закрыл TCP-соединение пакетом FIN, не дослав HTTP-ответ; ERR_EMPTY_RESPONSE (−324) — что соединение было закрыто, не отправив ни одного байта. В обоих случаях TCP-порт открыт и рукопожатие прошло, а отказ произошёл на уровне приложения: веб-сервер, прокси или защитный фильтр решили не отвечать. Этим они отличаются от ERR_CONNECTION_RESET, где соединение обрывается пакетом RST.
Три способа сервера прекратить разговор
У TCP ровно два механизма завершить соединение: FIN («у меня больше нет данных», RFC 9293, раздел 3.6) и RST («соединения нет или оно аварийно оборвано», раздел 3.5.2). Chromium различает их и добавляет третий случай — FIN на свежем соединении, когда не пришло ничего.
| Код Chromium | Что произошло на TCP | curl | В tcpdump | Типичный виновник |
|---|---|---|---|---|
| ERR_EMPTY_RESPONSE (−324) | Новое соединение, запрос ушёл, в ответ 0 байт и FIN | «Empty reply from server», exit 52 | [F.] от сервера сразу после запроса | nginx return 444, антибот, упавший воркер |
| ERR_CONNECTION_CLOSED (−100) | FIN на переиспользованном keep-alive-соединении или после части ответа | «Connection died, retrying a fresh connect» либо exit 18/56 | [F.] без данных или посреди тела ответа | Гонка keep-alive, лимит соединений, обрыв на прокси |
| ERR_CONNECTION_RESET (−101) | Пакет RST вместо FIN | «Connection reset by peer», exit 56 | Flags [R] или [R.] | DPI, файрвол, reset_timedout_connection |
Комментарии в net_error_list.h Chromium однозначны: CONNECTION_CLOSED — «corresponding to a TCP FIN», CONNECTION_RESET — «corresponding to a TCP RST», EMPTY_RESPONSE — «the server closed the connection without sending any data». RST чаще приходит из сети, а не от приложения, и разобран в статье про ERR_CONNECTION_RESET; здесь — только про вежливое закрытие.
Почему одна и та же проблема даёт то CLOSED, то EMPTY_RESPONSE
Разницу определяет не сервер, а то, было ли соединение новым. В http_stream_parser.cc Chromium: сокет закрылся с нулём принятых байт на новом соединении — ERR_EMPTY_RESPONSE; на переиспользованном — ERR_CONNECTION_CLOSED, потому что «сервер, скорее всего, закрыл сокет до того, как получил запрос».
Дальше включается повтор. HandleIOError в http_network_transaction.cc повторяет запрос при CONNECTION_CLOSED, CONNECTION_RESET, CONNECTION_ABORTED, SOCKET_NOT_CONNECTED и EMPTY_RESPONSE, но только если соединение уже использовалось (keep-alive) и заголовки ответа не начали приходить; лимит — 50 повторов, дальше ERR_TOO_MANY_RETRIES. На свежем соединении повтора нет. Правило: EMPTY_RESPONSE — почти всегда стабильная ошибка конфигурации или фильтра, CONNECTION_CLOSED — чаще гонка, перегрузка или обрыв посреди ответа.
Что на сервере закрывает соединение без ответа
Почти всегда это одно из десяти мест ниже, и у каждого есть директива, проверяемая за минуту. Умолчания — по документации nginx, Apache и PHP-FPM на сентябрь 2026.
| Причина | Директива / умолчание | Как проверить |
|---|---|---|
| nginx намеренно молчит | return 444 — «закрывает соединение без отправки заголовка ответа» | grep -rn 444 /etc/nginx/; статус 444 в access.log |
| Гонка keep-alive | keepalive_timeout 75s, keepalive_requests 1000 (до 1.19.10 — 100); Apache KeepAliveTimeout 5 | Плавающая ошибка со второго запроса; таймаут бэкенда меньше таймаута прокси |
| Исчерпаны соединения nginx | worker_connections 512 (считает и соединения к бэкенду), worker_rlimit_nofile | error.log: «worker_connections are not enough», «Too many open files»; ss -s |
| Исчерпаны воркеры бэкенда | pm.max_children (обязательный, умолчания нет); Apache MaxRequestWorkers 256 | php-fpm.log: «server reached pm.max_children setting»; страница pm.status_path |
| Воркер убит по таймауту | request_terminate_timeout (0 = выкл.) | php-fpm.log: «execution timed out (…), terminating» |
| Падение процесса | segfault, OOM-killer | error.log: «worker process … exited on signal 11»; dmesg: «Out of memory: Killed process» |
| Слишком большие заголовки | large_client_header_buffers 4 8k; Apache LimitRequestFieldSize 8190 | nginx отдаёт 400 (внутренний код 494 «Request Header Or Cookie Too Large»); молча рвут некоторые прокси; Cloudflare при 128 КБ даёт 520 |
| HTTP/2 GOAWAY | http2_max_concurrent_streams 128, keepalive_timeout для h2 | chrome://net-export; curl --http1.1 работает, curl --http2 — нет |
| WAF, антибот, ModSecurity | ModSecurity: действие drop вместо deny; кастомные правила антибота | Воспроизводится только с определённым User-Agent или IP |
| Нет подходящего vhost / SNI | ssl_reject_handshake on; default_server с return 444 | curl -v --resolve с правильным именем работает, по IP — нет |
Отдельно про PHP-FPM за nginx. Когда FPM-воркер умирает или убит по request_terminate_timeout, nginx пишет «upstream prematurely closed connection while reading response header from upstream» и отдаёт 502 Bad Gateway — браузер видит код, а не пустоту. Пустой ответ приходит, когда рвётся сам nginx (упал воркер, кончились worker_connections) или когда приложение отвечает без прокси: Node или Go-сервер на 0.0.0.0:8080 при падении просто закрывает сокеты.
Что означает Cloudflare 520 и как он связан с пустым ответом
Cloudflare 520 «Web server returns an unknown error» — тот же ERR_EMPTY_RESPONSE, только увиденный не браузером, а прокси Cloudflare. По документации Cloudflare 520 возникает, когда origin «возвращает пустой, неизвестный или неожиданный ответ»: сбой сервера, блокировка IP-адресов Cloudflare файрволом origin, заголовки больше 128 КБ (обычно из-за cookie), некорректная настройка HTTP/2 к origin.
| Код Cloudflare | Что видел Cloudflare от origin | Аналог без CDN |
|---|---|---|
| 520 | Соединение закрыто без валидного HTTP-ответа | ERR_EMPTY_RESPONSE / ERR_CONNECTION_CLOSED |
| 521 | Origin отказал в TCP-соединении | ERR_CONNECTION_REFUSED |
| 522 | TCP-рукопожатие не завершилось | ERR_CONNECTION_TIMED_OUT |
| 524 | HTTP-ответ не пришёл за лимит времени | ERR_TIMED_OUT / 504 |
Диагностика та же, но в обход Cloudflare: curl --resolve на IP origin покажет, действительно ли сервер отдаёт пустоту. Если напрямую всё работает, проверяют, не режет ли файрвол или антибот диапазоны Cloudflare и не включён ли HTTP/2 to Origin при бэкенде без его поддержки.
Когда виноват не сервер: клиент и сеть
Если ошибка только у одного пользователя, а с других устройств сайт открывается — источник между браузером и сервером. Антивирус с проверкой HTTPS, корпоративный прокси, VPN-клиент и расширения-блокировщики вклиниваются в соединение и могут закрыть его без HTTP-ответа; DPI провайдера шлёт RST, то есть даёт RESET, а не CLOSED. Тест: тот же URL в инкогнито, без VPN и веб-фильтра антивируса, затем с мобильного интернета. Исчезло — дело в локальной цепочке.
Диагностика: curl, tcpdump и логи
Первый инструмент — curl -v: видно, ушёл ли запрос и что пришло после. Пустой ответ на новом соединении (вывод — пример):
$ curl -sv https://example.ru/ -o /dev/null; echo "exit=$?"
* Trying 203.0.113.10:443...
* Connected to example.ru (203.0.113.10) port 443
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* using HTTP/1.x
> GET / HTTP/1.1
> Host: example.ru
> User-Agent: curl/8.10.1
>
* Empty reply from server
* Closing connection
curl: (52) Empty reply from server
exit=52
# Сравнить протоколы: с --http1.1 ответ есть, с --http2 нет — сломан h2 на сервере или прокси
$ curl -s -o /dev/null -w '%{http_code} %{http_version}\n' --http1.1 https://example.ru/
$ curl -s -o /dev/null -w '%{http_code} %{http_version}\n' --http2 https://example.ru/
# Обойти CDN и проверить origin напрямую
$ curl -sv --resolve example.ru:443:203.0.113.10 https://example.ru/ -o /dev/null
Коды выхода curl: 52 (CURLE_GOT_NOTHING) — «nothing was returned from the server», 56 (CURLE_RECV_ERROR) — ошибка приёма, чаще всего RST, 18 (CURLE_PARTIAL_FILE) — сервер обещал Content-Length и закрыл соединение раньше, 16 (CURLE_HTTP2) — ошибка в кадрах HTTP/2. На переиспользованном соединении curl, как и Chrome, повторяет запрос: в verbose появится «Connection died, retrying a fresh connect», лимит — 5 попыток.
Второй — tcpdump на сервере: он однозначно отвечает, FIN это или RST и кто его отправил.
# Только пакеты с FIN или RST на 443-м порту sudo tcpdump -ni eth0 'port 443 and (tcp[tcpflags] & (tcp-fin|tcp-rst) != 0)' # Пример: сервер закрыл соединение FIN-ом через 40 мс после запроса, не отправив данных 12:41:07.118 IP 198.51.100.7.51234 > 203.0.113.10.443: Flags [P.], seq 1:518, length 517 12:41:07.158 IP 203.0.113.10.443 > 198.51.100.7.51234: Flags [F.], seq 1, ack 518, length 0 12:41:07.159 IP 198.51.100.7.51234 > 203.0.113.10.443: Flags [F.], seq 518, ack 2, length 0
Flags [F.] от адреса сервера с length 0 сразу после запроса — и есть пустой ответ. Flags [R.] вместо него — читайте статью про RESET. FIN после нескольких пакетов с данными — обрыв посреди тела: в браузере тоже CONNECTION_CLOSED, в curl — exit 18.
Третий инструмент — логи: ошибки этого класса оставляют характерные строки.
sudo tail -n 200 /var/log/nginx/error.log | grep -E 'prematurely|exited on signal|not enough|no live|Too many open' sudo grep -E 'max_children|timed out|exited on signal' /var/log/php8.3-fpm.log | tail sudo dmesg -T | grep -i -E 'out of memory|killed process' ss -s # сводка: сколько соединений всего и в каких состояниях ss -tn state established '( sport = :443 )' | wc -l curl -s localhost/fpm-status # active processes, listen queue, max children reached
| Строка в логе | Причина | Исправление |
|---|---|---|
| upstream prematurely closed connection while reading response header | Бэкенд закрыл сокет до ответа: таймаут, падение, перезапуск | Смотреть лог бэкенда; поднять request_terminate_timeout или ускорить код |
| worker process 1234 exited on signal 11 | Segfault воркера nginx: модуль, Lua, битая сборка | Отключать сторонние модули по одному, обновить nginx |
| 512 worker_connections are not enough | Лимит соединений на воркер исчерпан | worker_connections 4096 плюс worker_rlimit_nofile 8192 |
| no live upstreams while connecting to upstream | Все бэкенды помечены недоступными после серии ошибок | Чинить бэкенд; проверить max_fails и fail_timeout |
| [pool www] server reached pm.max_children setting (5) | Все FPM-воркеры заняты, запросы копятся в очереди | Поднять pm.max_children под объём памяти |
| child 4321 … execution timed out (30.2 sec), terminating | Сработал request_terminate_timeout | Ускорить скрипт или вынести задачу в фон |
| Out of memory: Killed process 4321 (php-fpm8.3) | OOM-killer снял процесс с клиентом на руках | Уменьшить pm.max_children × memory_limit, добавить память |
| access.log: "GET /wp-login.php" 444 0 | Правило с return 444 сработало на легитимный запрос | Сузить условие location/if |
Как исправить: по стекам
У каждого стека три-четыре ручки. Главный принцип для цепочки прокси → бэкенд: таймаут простоя keep-alive у бэкенда должен быть больше, чем у прокси перед ним, иначе бэкенд закроет соединение ровно в момент, когда прокси решит его переиспользовать.
| Стек | Что проверить | Типовое исправление |
|---|---|---|
| nginx | return 444, worker_connections, keepalive_timeout, large_client_header_buffers, http2 | Ограничить 444 явными условиями; worker_connections 4096; у бэкенда keepalive_timeout выше, чем у фронта |
| Apache | MaxRequestWorkers 256, KeepAliveTimeout 5, MaxKeepAliveRequests 100, ModSecurity drop | Поднять MaxRequestWorkers вместе с ServerLimit; вместо drop — deny,status:403 |
| PHP-FPM | pm.max_children, request_terminate_timeout, pm.max_requests, память | max_children = свободная память ÷ средний RSS воркера; включить pm.status_path и следить за listen queue |
| Node / PM2 | server.keepAliveTimeout (5 с в выпущенных версиях), необработанные исключения, рестарты PM2 | keepAliveTimeout больше idle-таймаута балансировщика; логировать uncaughtException; graceful shutdown вместо kill |
| Cloudflare | 520 в логах, файрвол origin, размер cookie, HTTP/2 to Origin | Разрешить IP-диапазоны Cloudflare на origin; урезать cookie; отключить HTTP/2 to Origin |
Минимальный набор правок nginx под большинство случаев из таблицы:
worker_rlimit_nofile 16384;
events { worker_connections 8192; }
http {
keepalive_timeout 75s; # фронт
large_client_header_buffers 4 16k; # если 400/494 на больших cookie
upstream app { server 127.0.0.1:3000; keepalive 32; }
server { location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_next_upstream error timeout http_502;
proxy_pass http://app;
} }
}
# Бэкенд (Node): server.keepAliveTimeout = 90_000 — больше 75 с у nginx
Правила вида «User-Agent не похож на браузер — 444» регулярно отсекают платёжные шлюзы, вебхуки, краулеры и мониторинг. В логах это 444 и 0 байт, у второй стороны — ERR_EMPTY_RESPONSE без объяснений. Если фильтр нужен, отдавайте 403 с телом. Как фильтры ведут себя с проверками, разобрано в статье о WAF и мониторинге.
Как узнать об обрывах раньше пользователей
В access.log этих ошибок часто нет: запрос без ответа может не оставить ни строки, а «upstream prematurely closed» видно только в error.log. Снаружи обрыв обнаруживается одним способом — регулярным HTTP-запросом с разных точек, который ждёт валидный ответ, а не просто открытый порт.
HTTP-проверка выполняет полноценный GET, и пустой ответ или обрыв соединения фиксируется как недоступность так же, как 5xx: критерий — «получен ожидаемый ответ», а не «порт открыт». Параллельная проверка TCP-порта в тот же момент показывает, что 443-й порт по-прежнему принимает соединения. Сочетание «TCP зелёный, HTTP красный» — подпись ERR_EMPTY_RESPONSE: сервер жив, приложение или фильтр не отвечает. Вкладка «По локациям» покажет, рвётся у всех или только из одного региона.
Пруфен проверяет сайт по HTTP с интервалом от 2 минут из 13 точек мониторинга — 11 в России, а также Минск и Испания — и присылает уведомление в Email, Telegram или Макс, когда вместо страницы приходит пустой ответ или закрытое соединение. Если ресурс так и не поднялся, подключается голосовой звонок. В бесплатном плане — 5 проверок.
Частые вопросы
Чем ERR_EMPTY_RESPONSE отличается от 502 Bad Gateway?
502 — полноценный HTTP-ответ: прокси (nginx, балансировщик, CDN) не получил ответа от бэкенда и сообщил об этом кодом и страницей. ERR_EMPTY_RESPONSE означает, что HTTP-ответа не было вовсе — соединение закрылось на уровне TCP. 502 говорит «сломан бэкенд за прокси», пустой ответ — «молчит тот, к кому вы подключились напрямую». Если перед сайтом стоит nginx и вы видите пустоту, а не 502, проблема в самом nginx или раньше.
Почему curl показывает «Empty reply from server», а браузер иногда открывает страницу?
Три причины. Антибот или правило nginx реагирует на User-Agent curl — проверьте с ключом -A и заголовками из браузера. Протокол: браузер ходит по HTTP/2, curl может выбрать HTTP/1.1, и сломан только один из них. Повтор: на keep-alive-соединении Chrome молча повторяет запрос до 50 раз, а curl на новом соединении сразу получает exit 52.
Может ли антивирус давать ERR_CONNECTION_CLOSED?
Может. Модули проверки HTTPS-трафика работают как локальный прокси: браузер соединяется с ними, а не с сайтом. Если модуль блокирует страницу или падает, браузер видит закрытое соединение без ответа при исправном сервере. Признак: ошибка только на одном компьютере и исчезает при отключении веб-защиты.
Что такое nginx 444 и зачем он нужен?
444 — нестандартный внутренний код nginx: по документации, он «закрывает соединение без отправки заголовка ответа». В сеть код не уходит — клиент получает только FIN, в access.log остаётся 444 и 0 байт. Используют его против мусорных запросов: обращений по IP без Host, сканеров, default_server для чужих доменов. С reset_timedout_connection on такие соединения закрываются RST-ом, и CLOSED у клиента превращается в RESET.