WebSocket closed with code 1006
Соединение оборвалось без Close-кадра. Четыре причины и как их различить.
Что это значит
Код 1006 никогда не ходит по сети. RFC 6455, пункт 7.4.1 запрещает ставить
его в Close-кадр: код подставляет ваш же WebSocket-клиент, когда соединение
оборвалось и Close-кадра так и не было. То есть 1006 сообщает один факт:
никто не попрощался. Причину он не называет.
Как исправить
Вот всё, что есть в событии: event.code равен 1006, event.reason содержит
пустую строку, event.wasClean равен false. Так задумано. Спецификация запрещает браузеру
рассказывать скрипту, что именно случилось: не разрешилось DNS-имя, закрыт порт,
упал TLS, отказано в рукопожатии. Иначе любая страница сканировала бы вашу сеть.
Не путайте с 1005. Там Close-кадр пришёл, просто без двух байт со статусом,
и wasClean равен true, если рукопожатие закрытия завершилось. Это другая
проблема, и искать её надо в другом месте.
На практике причин четыре. Для вашего JavaScript все четыре выглядят одинаково.
Консоль Chrome говорит чуть больше: при провале рукопожатия она пишет
Unexpected response code: 403.
1. Рукопожатие не прошло. Сервер ответил на Upgrade не кодом 101, а 404,
403, 502 или редиректом. За редиректом браузер здесь не идёт никогда
(по спецификации режим редиректов у этого запроса выставлен в error), так что
301 тоже считается отказом. Проверяйте это первым: перед вами обычный
HTTP-ответ, его видно целиком.
curl -i -N --http1.1 --max-time 5 \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/ws
--http1.1 обязателен. Без него curl договаривается на HTTP/2 поверх TLS, заголовки
Upgrade отбрасываются, и вы получаете обычный 200, то есть ложный ответ. Значение
Sec-WebSocket-Key здесь взято из примера в RFC: это 16 байт в base64. Для ручной
проверки годится, но RFC 6455, пункт 4.1
требует, чтобы настоящий клиент каждый раз генерировал новое случайное значение.
Ответ HTTP/1.1 101 Switching Protocols
говорит, что рукопожатие работает и причина в другом. Любой другой код означает, что вы её нашли.
2. Таймаут простоя на прокси. Почти везде по умолчанию 60 секунд. У nginx
proxy_read_timeout 60s. У AWS ALB idle_timeout держит те же 60 секунд.
Таймаут выдаёт себя временем: 1006 приходит всегда на
одной и той же секунде.
Поднимите таймаут на прокси:
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
И добавьте в приложение пинг: у следующего прокси в цепочке будет свой таймаут, о котором вы не знаете.
const ping = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
}, 30_000);
socket.addEventListener('close', () => clearInterval(ping));
Сервер обязан на пинг отвечать. proxy_read_timeout в nginx считает только байты,
идущие от сервера, поэтому одни клиентские пинги там ничего не меняют. У ALB
иначе: его сбрасывает трафик в любую сторону. А собственный Ping-кадр протокола
(RFC 6455, пункт 5.5.2)
из JavaScript отправить нельзя, так что пинг остаётся обычным сообщением, о формате
которого договорились обе стороны.
3. Процесс на сервере упал. Перезапуск, OOM killer, выкатка. Время произвольное, и соединения отваливаются все разом. Таймаут так не выглядит.
4. Сеть сменилась. Wi-Fi на мобильную, VPN, уснувший ноутбук. На телефонах это основная причина. Чинить тут нечего, остаётся переподключение с нарастающей задержкой:
let attempt = 0;
function connect() {
const socket = new WebSocket(url);
socket.addEventListener('open', () => { attempt = 0; });
socket.addEventListener('close', (e) => {
if (e.code === 1000) return; // закрыли нормально, не повторяем
const delay = Math.min(30_000, 500 * 2 ** attempt++);
setTimeout(connect, delay * (0.5 + Math.random()));
});
}
Джиттер обязателен. Без него тысяча клиентов, отвалившихся от одного перезапуска, вернётся в одну и ту же миллисекунду и положит сервер второй раз.
Как увидеть это у себя
Первые две причины живут в HTTP-обмене, а он целиком виден со стороны клиента.
Найдите в списке запросов Upgrade к /ws и посмотрите код ответа: 101 значит,
что рукопожатие прошло, любой другой код называет причину. Если код 101, сравните
время жизни соединений: один и тот же предел у каждого обрыва указывает на таймаут
на прокси.
Как Solpuga записывает стрим: Перехват и просмотр трафика.
Инструмент к этой странице
Close-коды WebSocketВведите Close-код WebSocket и получите его значение, стандарт, в котором он описан, и типичные причины.Рядом
- Стрим обрывается через 60 секунд: proxy_read_timeoutОбрыв на одной и той же секунде означает таймаут, а не сеть. Значения по умолчанию в nginx, ALB, Cloudflare, Heroku и Envoy и почему пинг надёжнее.
- Седьмая вкладка не грузится: лимит шести соединенийБраузер открывает не больше шести HTTP/1.1-соединений на один origin. Один EventSource занимает одно навсегда, и седьмая вкладка просто ждёт.