javascript

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

  • понедельник, 16 ноября 2026 г. в 00:00:07
https://habr.com/ru/articles/1107984/

Вы заходите в лифт. Интернет пропадает. WebSocket по-прежнему OPEN. Приложение уверено, что все хорошо. Вы спускаетесь вниз, выходите, старый WebSocket может быть по-прежнему OPEN. Добро пожаловать в мир распределенных систем.

Главная проблема: операционная система не обязана мгновенно сообщать приложению, что TCP-соединение перестало передавать данные.

Причина неприятностей

А вот и первая ошибка:

socket.onopen = () => setOnline(true)

Событие onopen сообщает, что WebSocket открыт. Оно не сообщает, что собеседник все еще жив и что сервис за этим соединением готов работать.

В проектах, которые делали в спешке, часто встречаются одни и те же ошибки:

  • Ошибка №1. Переподключение каждые N секунд

  • Ошибка №2. Reconnect сразу из нескольких обработчиков

  • Ошибка №3. Считать socket.open доказательством живого соединения

  • Ошибка №4. Reconnect по "кнопке"

И самое неприятное — все это невыдуманные примеры. Такой код я действительно встречал в продакшене.

Что не так?

Открытый сокет еще не означает, что все работает. Чтобы понять, действительно ли соединение работает, нужно проверить три вещи:

  1. Транспорт. Открыт ли сокет?

  2. Протокол. Жив ли собеседник прямо сейчас?

  3. Сервис. Готов ли сервис за соединением обслуживать нас?

При этом каждый уровень может сломаться независимо от предыдущего. Балансировщик держит 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)
}

Обратите внимание, что типы ошибок здесь важнее, чем кажется, потому что от них зависит реакция вызывающего кода:

Ошибка

Что произошло

Можно ли повторить

NotReadyError

Запрос не был отправлен

Да, всегда

RequestTimeoutError

Ответа нет, судьба запроса неизвестна

Только идемпотентный

ConnectionClosedError

Соединение оборвалось в пути, судьба неизвестна

Только идемпотентный

Запрос мог дойти до сервера и выполниться, а потерялся только ответ. Поэтому, если автоматически повторить запрос без ключа идемпотентности, можно случайно создать второй заказ.

Еще одна деталь: номера запросов растут постоянно и не сбрасываются при переподключении. Поздний ответ с идентификатором старого запроса просто не найдет пару в реестре и будет проигнорирован.

Подводя итог, можно сказать, что теперь запросы завершаются, но статус все еще неверен. В сбое «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, в других браузерах оно может отличаться. Поведение сторожевого таймера в фоновой вкладке, где браузер может замедлять таймеры, мы не измеряли.

Практические советы

Не каждому приложению нужна вся эта логика. Все зависит от требований к надежности, но начинать я бы советовал с одного и того же:

  1. Завершайте каждый запрос. Таймаут и отклонение при разрыве нужны всегда. Это несколько строк, а без них интерфейс зависает.

  2. Добавьте heartbeat. Без него клиент не узнает о мертвом соединении. Интервал лучше получать от сервера.

  3. Не ждите onclose у мертвого сокета и проверяйте, что событие пришло от текущего. Эти два шага нельзя разделять.

  4. Отделите готовность сервиса от доступности соединения. Подтверждайте подписки и восстанавливайте их без разрыва сокета.

  5. Добавьте джиттер и потолок задержки. Сбрасывайте backoff только после заданного времени готовности.

  6. Считайте соединения на сервере. Лишние соединения от одной вкладки не видны в интерфейсе клиента, но видны на графике сервера.

  7. Проверьте сбоями, а не только разрывом сети. Обрыв соединения — самый легкий случай, потому что браузер о нем сообщает. Тестируйте зависание, отказ сервиса и флаппинг.

В демопроекте этапы сделаны цепочкой подклассов, чтобы файл каждого этапа был ровно одним исправлением. В рабочем коде удобнее разделить обязанности на отдельные слои: сокет с переподключением, проверка доступности с реестром запросов, подписки и итоговый статус для интерфейса.

onopen отвечает на самый простой из трех вопросов. Надежный клиент не доверяет одному событию: он проверяет, жив ли собеседник, работает ли сервис, и помнит, что вернуться после сбоя нужно так, чтобы не вызвать следующий.

Код к статье: godfreyd/websocket-readiness.