WebSocket в лифте: соединение есть, но приложение не работает

Вы заходите в лифт. Интернет пропадает. WebSocket по-прежнему OPEN. Приложение уверено, что все хорошо. Вы спускаетесь вниз, выходите, старый WebSocket может быть по-прежнему OPEN. Добро пожаловать в мир распределенных систем.
Главная проблема: операционная система не обязана мгновенно сообщать приложению, что TCP-соединение перестало передавать данные.
Причина неприятностей
А вот и первая ошибка:
socket.onopen = () => setOnline(true)Событие onopen сообщает, что WebSocket открыт. Оно не сообщает, что собеседник все еще жив и что сервис за этим соединением готов работать.
В проектах, которые делали в спешке, часто встречаются одни и те же ошибки:
Ошибка №1. Переподключение каждые N секунд
Ошибка №2. Reconnect сразу из нескольких обработчиков
Ошибка №3. Считать socket.open доказательством живого соединения
Ошибка №4. Reconnect по "кнопке"
И самое неприятное — все это невыдуманные примеры. Такой код я действительно встречал в продакшене.
Что не так?
Открытый сокет еще не означает, что все работает. Чтобы понять, действительно ли соединение работает, нужно проверить три вещи:
Транспорт. Открыт ли сокет?
Протокол. Жив ли собеседник прямо сейчас?
Сервис. Готов ли сервис за соединением обслуживать нас?
При этом каждый уровень может сломаться независимо от предыдущего. Балансировщик держит TCP-соединение, но сервер завис. Сервер отвечает на проверки доступности, но сервис за ним упал.
Кроме статуса, есть еще две задачи. Нужно разобраться с запросами, оставшимися без ответа, и организовать переподключение так, чтобы не добить сервер после рестарта.
История одного клиента
Давайте напишем простой клиент и будем менять его только тогда, когда увидим конкретный сбой.
Если хочется сразу все потыкать — код, как обычно, лежит в демопроекте: npm run dev поднимает настоящий WebSocket-сервер, которому можно включать сбои кнопками, а этапы, описанные в статье переключаются параметром ?stage=0 … ?stage=5.
Этап 0. Наивный клиент: верим событиям сокета
Первая версия клиента считает себя онлайн после onopen, а офлайн — после onclose. После разрыва она переподключается через секунду.
protected handleOpen() {
this.setLevels({ transport: 'up' })
this.subscribeToFeed()
}
protected handleClose(code: number, reason: string) {
this.setLevels({ transport: 'down' })
if (this.started) this.scheduleReconnect()
}
Пока сервер здоров, код работает. Теперь включим в демосбой «Freeze». Сервер ставит TCP-сокет на паузу: соединение остается открытым, но данные больше не читаются и не отправляются. Для браузера это выглядит как собеседник, который ушел с вечеринки, ни с кем не попрощавшись (Irish goodbye).
Клиент говорит: READY
Обновления: не приходят
Запросы: копятся без ответаПочему браузер ничего не замечает? Если собеседник пропал без закрытия соединения, TCP-пакет с завершением просто не придет. Механизм TCP keepalive здесь не помогает: из браузера им нельзя управлять, а на Linux первая проба по умолчанию отправляется только через два часа простоя.
У WebSocket есть собственные ping- и pong-фреймы, но браузер обрабатывает их сам и не показывает JavaScript-коду. Клиент не может ни отправить такой ping, ни узнать о его получении.
У наивного клиента есть и вторая проблема. Запросы лежат в Map и ждут ответа, сколько бы ни пришлось:
protected registerRequest(id: number, request: PendingRequest) {
this.pending.set(id, request)
}Если соединение оборвалось до ответа, такой промис не завершится никогда. Кнопка «Отправить» останется в состоянии загрузки, пока пользователь не обновит страницу.
Подводя итог, можно сказать, что статус наивного клиента описывает только сокет. Начнем с запросов, которые могут зависнуть навсегда.
Этап 1. Реестр запросов: ни один промис не висит вечно
Честно говоря, про реестр запросов забывают довольно часто. Хотя правило здесь довольно простое: каждый запрос должен завершиться, даже если ответ не придет. Для этого нужны таймаут и отклонение всех запросов в момент разрыва.
protected registerRequest(id: number, request: PendingRequest) {
const timer = setTimeout(() => {
this.pending.delete(id)
request.reject(new RequestTimeoutError(`${request.method} got no response`))
}, REQUEST_TIMEOUT_MS)
// ...в resolve и reject таймер очищается
}
protected handleClose(code: number, reason: string) {
this.rejectInFlight(new ConnectionClosedError(`Connection closed with ${code}`))
super.handleClose(code, reason)
}Обратите внимание, что типы ошибок здесь важнее, чем кажется, потому что от них зависит реакция вызывающего кода:
Ошибка | Что произошло | Можно ли повторить |
|---|---|---|
| Запрос не был отправлен | Да, всегда |
| Ответа нет, судьба запроса неизвестна | Только идемпотентный |
| Соединение оборвалось в пути, судьба неизвестна | Только идемпотентный |
Запрос мог дойти до сервера и выполниться, а потерялся только ответ. Поэтому, если автоматически повторить запрос без ключа идемпотентности, можно случайно создать второй заказ.
Еще одна деталь: номера запросов растут постоянно и не сбрасываются при переподключении. Поздний ответ с идентификатором старого запроса просто не найдет пару в реестре и будет проигнорирован.
Подводя итог, можно сказать, что теперь запросы завершаются, но статус все еще неверен. В сбое «Freeze» запросы падают по таймауту, а клиент продолжает говорить READY. Следующим этапом будем проверять, жив ли собеседник.
Этап 2. Heartbeat: проверяем доступность собеседника
Как мы уже обсудили, протокольные ping-фреймы нам недоступны, поэтому проверка доступности переходит на уровень приложения. В нашем демосервер отправляет сообщение ping сразу после подключения и затем каждую секунду. Клиент считает собеседника живым только после первого ping и запускает сторожевой таймер.
protected handleOpen() {
this.setLevels({ protocol: 'pending' })
this.armWatchdog(FIRST_PING_TIMEOUT_MS)
super.handleOpen()
}
protected handleMessage(message: ServerMessage) {
if (message.type === 'ping') {
this.armWatchdog(message.intervalMs * 2)
if (this.levels.protocol !== 'up') this.setLevels({ protocol: 'up' })
return
}
super.handleMessage(message)
}Таймер ждет два интервала, а не один. Одно опоздавшее сообщение из-за сети или занятого главного потока не должно разрывать рабочее соединение. Цена такого решения — обнаружение занимает до двух интервалов.
Интервал приходит от сервера. Это позволяет изменить частоту проверок без релиза клиента, например снизить нагрузку при большом числе соединений.
Есть и другой вариант: клиент сам отправляет ping и ждет pong.
Модель | Плюсы | Минусы |
|---|---|---|
Сервер шлет, клиент ждет | Интервалом управляет сервер, клиент только слушает | Клиент не проверяет путь от себя к серверу |
Клиент шлет, сервер отвечает | Проверяется путь в обе стороны | Интервал зашит в клиенте, менять его сложнее |
В нашем демопроекте используется первая модель. Сбой «Freeze» теперь определяется за одну-две секунды.
Что делать с мертвым сокетом
Таймер сработал, соединение нужно заменить. На первый взгляд, достаточно вызвать close() и дождаться onclose, чтобы затем переподключиться
Но close() только начинает процедуру закрытия: браузер отправляет close-фрейм и ждет ответа. Мертвый собеседник не ответит. В нашем замере в Chrome onclose на таком сокете пришел через 60 секунд с кодом 1006.
Минуту ждать точно нельзя, поэтому клиент вызывает close() и продолжает работу сразу:
protected forceReconnect(code: number, reason: string) {
this.socket?.close(code, reason)
this.handleClose(code, reason)
}Этот код выглядит правильно и даже работает в простом сценарии. Но обработчики старого сокета остались на месте, и его onclose однажды все-таки сработает.
Подводя итог, можно сказать, что heartbeat решил проблему обнаружения, но отказ от ожидания onclose создал новую: у клиента теперь могут быть два сокета одновременно.
Этап 3. Устаревшие события: говорит только текущий сокет
Давайте посмотрим, что происходит на временной шкале:
Старый сокет: ──open── close() ············ onclose (поздно)
↓ ↓
Новый сокет: ──open────READY────────сброс статуса!
↓
Лишний сокет: ──open────Поздний onclose старого сокета вызывает обычный handleClose. Клиент не знает, что событие устарело, поэтому:
сбрасывает статус живого соединения;
отклоняет запросы, которые выполнялись уже в новом соединении;
запускает переподключение, хотя текущий сокет работает.
Текущий сокет при этом никто не закрывает, и он остается открытым. В демопроекте после 12 секунд заморозки одна вкладка держит на сервере 4 соединения, и их число не уменьшается. Следующий разрыв запускает переподключение от каждого из них: за 15 секунд сбоя «Flapping» сервер видит 48 попыток вместо 12.
Исправление сводится к одному правилу: менять состояние клиента может только текущий сокет.
protected bindSocket(socket: SocketLike) {
super.bindSocket(socket)
const { onopen, onmessage, onclose } = socket
socket.onopen = (event) => {
if (socket === this.socket) onopen?.(event)
}
socket.onmessage = (event) => {
if (socket === this.socket) onmessage?.(event)
}
socket.onclose = (event) => {
if (socket === this.socket) onclose?.(event)
}
}
protected forceReconnect(code: number, reason: string) {
const stale = this.socket
// С этого момента все, что скажет старый сокет, игнорируется.
this.socket = null
stale?.close(code, reason)
this.handleClose(code, reason)
}
Счетчик поколений здесь не нужен: идентичность объекта уже однозначно определяет соединение. Это та же проблема, что и гонка в useEffect, когда ответ старого запроса приходит позже нового. И решение то же: прежде чем применить результат, проверить, что он все еще актуален.
После исправления вкладка возвращается к одному соединению. Но пока сервер заморожен, он все еще видит несколько сокетов: это старые соединения, которым некому завершить закрытие. Клиенту они больше не мешают, а вот серверу нужна собственная проверка доступности, чтобы их почистить.
Подводя итог, можно сказать, что статус транспорта и протокола теперь надежен. Но живой собеседник еще не означает работающий сервис.
Этап 4. Готовность сервиса: третий уровень
Давайте включим сбой «Service down». Сервер продолжает отправлять ping, но сервис за ним недоступен: он сообщает о потере подписки и перестает отвечать.
Клиент с предыдущего третьего этапа честно видит, что транспорт и протокол работают, и говорит READY. Хуже другое: когда сервис вернется, обновлений все равно не будет. Подписка потеряна, и никто не подписывается заново. Статус верный по всем отслеживаемым уровням, а приложение остается без данных, пока не разорвется соединение.
Поэтому у сервиса появляется собственный статус. Подписка считается действующей только после подтверждения, а без него клиент повторяет запрос по тому же сокету:
protected subscribeToFeed() {
this.setLevels({ service: 'pending' })
super.subscribeToFeed()
clearTimeout(this.subscribeTimer)
this.subscribeTimer = setTimeout(() => this.subscribeToFeed(), SUBSCRIBE_TIMEOUT_MS)
}
protected handleMessage(message: ServerMessage) {
if (message.type === 'subscribed') {
clearTimeout(this.subscribeTimer)
this.setLevels({ service: 'up' })
} else if (message.type === 'unsubscribed') {
this.subscribeToFeed()
} else {
super.handleMessage(message)
}
}Обратите внимание, что соединение здесь не разрывается. С сокетом все в порядке, поэтому разрывать его и заново проходить подключение и первую проверку незачем, особенно если сервис недоступен у всех клиентов сразу.
Готовность теперь складывается из трех уровней:
protected isReady() {
return (
this.levels.transport === 'up' &&
this.levels.protocol === 'up' &&
this.levels.service === 'up'
)
}Статусы уровней зависят друг от друга только в одну сторону. Разрыв транспорта сбрасывает все, что выше, а потеря подписки не трогает транспорт и протокол:
Транспорт → открыт ли сокет
Протокол → приходят ли ping
Сервис → подтверждена ли подписка
UI → смотрит только на итог: readyХочу продемонстрировать еще один сценарий, который сознательно не влияет на готовность. При сбое «Slow replies» запросы падают по таймауту, но подписка и ping работают, и статус остается READY. Правильный ответ — это ошибка конкретного запроса, а не потеря соединения. Если приложению важно показывать деградацию, для нее лучше завести отдельный сигнал.
Подводя итог, можно сказать, что наш демоклиент теперь оценивает свое состояние правильно. Но у сервиса обычно не один клиент, а поведение тысяч клиентов при переподключении само по себе становится нагрузкой.
Этап 5. Политика переподключения: не добивать сервер
Все предыдущие этапы переподключаются ровно через секунду. Для одного клиента это нормально. Но после рестарта сервера соединение теряют все клиенты сразу, и все они вернутся в одну и ту же секунду.
Лавину из тысяч клиентов не воспроизвести в одной вкладке, поэтому в демопроекте есть симуляция в виртуальном времени. Она использует тот же код политик, что и настоящий клиент. Условия: 10 000 клиентов теряют соединение одновременно, сервер недоступен 10 секунд, а затем принимает 500 подключений в секунду.
Четыре политики
Фиксированная задержка. Все клиенты повторяют попытку каждую секунду.
delay = 1000Экспоненциальный рост. Задержка удваивается до потолка.
delay = Math.min(cap, base * 2 ** attempt)Случайный старт и линейный рост. Первая задержка случайна, дальше она растет на постоянный шаг.
delay = attempt === 0 ? random() * maxStart : previousDelay + stepЭкспоненциальный рост с полным джиттером. Задержка выбирается случайно в пределах экспоненциального потолка. Этот вариант подробно разобран в статье Exponential Backoff And Jitter.
delay = random() * Math.min(cap, base * 2 ** attempt)Что показала симуляция?
Политика | Пик после возврата сервера, в секунду | Всего попыток | Все подключены через |
|---|---|---|---|
Фиксированная, 1 с | 10 000 | 204 000 | 30 с |
Экспонента, 1–30 с | 10 000 | 58 350 | не за 90 с |
Случайный старт, +1 с | 2 040 | 63 235 | 35 с |
Экспонента, 1–30 с, полный джиттер | 1 540 | 53 501 | 51 с |
Здесь есть несколько неочевидных результатов.
Джиттер не снижает первый всплеск. При базовой задержке в секунду все клиенты все равно делают первую попытку в течение этой секунды. Он работает позже: когда сервер возвращается, пик в полном джиттере составляет 1 540 попыток в секунду вместо 10 000.
Экспонента без джиттера хуже фиксированной задержки. Она сокращает число попыток, но клиенты остаются синхронными. Попытки идут волнами на 1, 3, 7, 15, 31 и 61 секунде, и каждая волна приносит на сервер всех оставшихся клиентов сразу. Сервер принимает лишь небольшую часть попыток подключения, поэтому к 90-й секунде подключается примерно 1 650 клиентов из 10 000. Именно такую политику часто считают правильной по умолчанию.
Фиксированная задержка восстанавливается быстрее всех. 30 секунд здесь теоретический минимум: 10 секунд простоя и 20 секунд на прием 10 000 клиентов со скоростью 500 в секунду. Но это слабость модели, а не достоинство политики. В симуляции неудачная попытка подключения ничего не стоит серверу. В реальности 10 000 попыток в секунду при пропускной способности в 500 расходуют ресурсы сервера на TLS и отказы и могут помешать ему подняться.
Случайный старт с линейным ростом выглядит неожиданно хорошо. В этом сценарии он даже подключает всех быстрее полного джиттера. Но за первую минуту простоя клиент делает больше попыток, чем экспонента, а его задержка не имеет потолка. Если сервер недоступен час, клиент будет ждать следующей попытки около полутора минут уже после того, как сервер вернулся. Потолок задержки стоит добавлять в любом случае.
С полным джиттером восстановление занимает больше времени, зато дает самый низкий пик и меньше всего попыток. Плата за это — более долгое ожидание для отдельных клиентов.
Когда сбрасывать задержку
Backoff нужно когда-нибудь сбрасывать, иначе после одной аварии клиент будет долго восстанавливаться после каждого следующего разрыва. Самое очевидное место — onopen. Но представьте сервер, который принимает соединение и сразу его разрывает:
open → сброс → разрыв → короткая задержка → open → сброс → разрыв → ...Каждый краткий успех обнуляет накопленную задержку, и backoff перестает работать. Поэтому задержку лучше сбрасывать только после того, как соединение проработало заданное время:
protected setLevels(levels: Partial<Levels>) {
super.setLevels(levels)
if (!this.isReady()) {
clearTimeout(this.cooldownTimer)
this.cooldownTimer = undefined
} else if (this.cooldownTimer === undefined) {
this.cooldownTimer = setTimeout(() => this.backoff.reset(), COOLDOWN_MS)
}
}Отсчет начинается с готовности, а не с onopen. Соединение, которое открылось, но так и не получило подтверждения подписки, не считается успешным.
В симуляции клиент на сервере, разрывающем соединение через 300 мс:
Политика | Сброс при открытии, подключений в минуту | Сброс после 5 с готовности |
|---|---|---|
Экспонента, 1–30 с | 46 | 5 |
Случайный старт, +1 с | 77 | 10 |
Экспонента, 1–30 с, полный джиттер | 77 | 8 |
Сеть устройства
Если устройство офлайн, попытки бессмысленны и только увеличивают задержку. Клиент слушает события offline и online: в первом случае отменяет таймеры переподключения, во втором сбрасывает backoff и подключается заново с небольшой случайной задержкой.
Эти события надежно сообщают только об отсутствии сети. Значение online не гарантирует доступность сервера, поэтому online запускает обычную попытку подключения, а не меняет статус. После выхода ноутбука из спящего режима отдельная обработка не нужна: сторожевой таймер заметит, что ping давно не приходили.
Подводя итог, можно сказать, что политика переподключения защищает сервис, но имеет цену для пользователя. В нашем прогоне возврат после рестарта на этапе 5 занял около 2,4 секунды вместо менее чем секунды на простых этапах. Это осознанный компромисс.
Результат измерений
Все этапы я прогнал для одних и тех же сбоев на настоящем сервере: headless Chrome, production-сборка, по одному прогону на этап. Значения времени зависят от окружения и момента сбоя, поэтому порядок важнее величин.
Этап | Заморозка замечена | Пропажа сервиса замечена | После возврата сервиса | Соединений на сервере после заморозки | Попыток за 15 с флаппинга |
|---|---|---|---|---|---|
0. Наивный клиент | никогда | никогда | без данных | 1 | 12 |
1. Реестр запросов | никогда | никогда | без данных | 1 | 12 |
2. Heartbeat | ~1,2 с | никогда | без данных | 4 | 48 |
3. Устаревшие события | ~1,0 с | никогда | без данных | 1 | 12 |
4. Готовность сервиса | ~1,1 с | сразу | данные в течение 2 с | 1 | 12 |
5. Политика переподключения | ~1,1 с | сразу | данные в течение 2 с | 1 | 6 |
Строка этапа 2 показывает самое интересное: добавление heartbeat сделало клиента внимательнее и одновременно хуже для сервера. Исправление одной проблемы открыло другую, и заметить ее можно было только по счетчику соединений на сервере.
У этих измерений есть ограничения. Заморозка моделирует молчание собеседника паузой сокета на сервере, а не настоящую потерю сети. Значение в 60 секунд получено только в Chrome, в других браузерах оно может отличаться. Поведение сторожевого таймера в фоновой вкладке, где браузер может замедлять таймеры, мы не измеряли.
Практические советы
Не каждому приложению нужна вся эта логика. Все зависит от требований к надежности, но начинать я бы советовал с одного и того же:
Завершайте каждый запрос. Таймаут и отклонение при разрыве нужны всегда. Это несколько строк, а без них интерфейс зависает.
Добавьте heartbeat. Без него клиент не узнает о мертвом соединении. Интервал лучше получать от сервера.
Не ждите
oncloseу мертвого сокета и проверяйте, что событие пришло от текущего. Эти два шага нельзя разделять.Отделите готовность сервиса от доступности соединения. Подтверждайте подписки и восстанавливайте их без разрыва сокета.
Добавьте джиттер и потолок задержки. Сбрасывайте backoff только после заданного времени готовности.
Считайте соединения на сервере. Лишние соединения от одной вкладки не видны в интерфейсе клиента, но видны на графике сервера.
Проверьте сбоями, а не только разрывом сети. Обрыв соединения — самый легкий случай, потому что браузер о нем сообщает. Тестируйте зависание, отказ сервиса и флаппинг.
В демопроекте этапы сделаны цепочкой подклассов, чтобы файл каждого этапа был ровно одним исправлением. В рабочем коде удобнее разделить обязанности на отдельные слои: сокет с переподключением, проверка доступности с реестром запросов, подписки и итоговый статус для интерфейса.
onopen отвечает на самый простой из трех вопросов. Надежный клиент не доверяет одному событию: он проверяет, жив ли собеседник, работает ли сервис, и помнит, что вернуться после сбоя нужно так, чтобы не вызвать следующий.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.