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 записывает стрим: Перехват и просмотр трафика.

Посмотреть в Solpuga

Инструмент к этой странице

Close-коды WebSocketВведите Close-код WebSocket и получите его значение, стандарт, в котором он описан, и типичные причины.

Рядом