Соединение, которое живёт часами, гарантированно рвётся: телефон уходит в сон, Wi-Fi переключается на LTE, прокси убивает простой, а сервер из прошлого урока честно прощается кадром 1001 на каждом релизе. Дизайн-вопрос не «как не рвать» — никак, — а «что делает вкладка в следующие десять секунд». Плохих ответа два. Первый: ничего — пользователь смотрит на замёрзший чат и перезагружает страницу сам, теряя всё состояние интерфейса. Второй: мгновенный бесконечный цикл переподключений — при недоступном сервере вкладка штампует попытки без паузы, греет процессор клиента и добивает сервер, когда тот начнёт подниматься. Правильный ответ посередине: паузы растут с каждой неудачей, попытки прекращаются там, где бессмысленны, а после успеха клиент восстанавливает то, что слушал раньше. Переподключение клиента — это не одна строка с new WebSocket в retry-цикле, а маленькая политика с тремя правилами.
Websocket reconnect: экспоненциальный backoff по шагам#
Ядро политики — экспоненциальный backoff: пауза между попытками удваивается с каждой неудачей, от базового значения до потолка. Схема для чата: база одна секунда, потолок тридцать — паузы идут одна, две, четыре, восемь, шестнадцать, тридцать, тридцать. Зачем именно так: сервер из шестнадцатого урока поднимается десять секунд, и за это время вкладка сделает шесть попыток вместо шестисот; при часовой аварии паузы упираются в потолок и вкладка пробует раз в полминуты вместо того, чтобы долбить. Websocket reconnect с backoff решает и встречную задачу: когда сервер оживает, тысячи вкладок не должны ломиться к нему синхронно — для этого пауза каждого клиента разбавляется джиттером, случайной добавкой до пары сотен миллисекунд, которая растаскивает попытки по времени. Счётчик попыток сбрасывается только при успешном рукопожатии, и это правило нарушают чаще всего.
Клиент: connect, backoff, джиттер
GOconst url = "wss://chat.example/ws";
let socket = null;
let attempts = 0;
function connect() {
socket = new WebSocket(url);
socket.onopen = function () {
attempts = 0; // рукопожатие удалось — счётчик в ноль
socket.send(JSON.stringify({ type: "join", room: "general" }));
};
socket.onclose = function (event) {
if (event.code === 1000) {
return; // закрылись сами — не возвращаемся
}
const delay = Math.min(1000 * Math.pow(2, attempts), 30000);
const jitter = Math.random() * 300;
attempts = attempts + 1;
setTimeout(connect, delay + jitter);
};
}
connect();Вся политика умещается в один onclose: код 1000 означает осознанное закрытие самим приложением, на него не реагируем; всё остальное — пауза min от базы в степени попыток до потолка, плюс джиттер, и setTimeout со следующей попыткой. Recursion через setTimeout не копит стек: каждая попытка живёт в новом тике таймера. Максимальное ожидание — тридцать секунд плюс случайные триста миллисекунд.
Три детали, на которых ломаются реализации. Первая: счётчик сбрасывается в onopen, а не в onclose — рукопожатие должно пройти целиком, иначе вкладка, которую сервер принял и сразу отклонил проверкой Origin, будет считать себя вечным новичком и штормить с базовой паузой. Вторая: перед отправкой любых кадров проверяйте socket.readyState — переподключение клиента асинхронно, и кадр из старого кода легко улетает в уже закрытый сокет пятого урока. Третья: не каждый close обязан рождать попытку. Код 1000 — сами закрыли по кнопке «выйти». Ответ 401 на рукопожатие означает просроченный токен: надо сходить за новым по обычному HTTP и только потом открывать сокет, а не долбить сервер с невалидным — это путь, который банят первым. Реакция на код — часть политики, а не универсальный retry. И держите переходное состояние видимым: пока вкладка в ожидании, интерфейс показывает «переподключаемся», а не молча притворяется, что всё хорошо, — пользователь должен знать, что последнее сообщение ещё в пути.
Повторные подписки: новый сокет не помнит ничего#
На сервере каждое соединение — новый Client в картах хаба: старый умер вместе с обрывом, и весь контекст сессии нулевой. Поэтому первое, что делает вкладка в onopen, — повторяет всё, что делала при первом входе: join в комнату из одиннадцатого урока, повторную авторизацию, если сервер её требует, пересылку presence-подписок. Подписки обязаны быть идемпотентными: повторный join в ту же комнату не должен давать дубликаты, и switchRoom из одиннадцатого урока именно так устроен — удалить, вычистить, добавить. Здесь же всплывает токен: соединение жило три часа, а JWT истёк через пятнадцать минут после рукопожатия, как в тринадцатом уроке; при переподключении вкладка обязана сначала получить свежий токен у обычного HTTP-эндпоинта и только потом открывать сокет. Жизненный цикл refresh-пар запротоколирован в курсе про JWT, здесь достаточно знать: reconnect — это новый гость со свежими документами, а не продолжение старого.
Resume: догоняем пропущенное по seq
GOcase "resume":
h.switchRoom(c, env.Room) // идемпотентно: повторный join без дубликатов
for _, e := range h.history[env.Room] {
if e.Seq <= env.LastSeq {
continue
}
select {
case c.send <- e:
default:
close(c.send)
}
}Клиент шлёт конверт resume с именем комнаты и номером последнего виденного события; хаб повторяет идемпотентный вход и проигрывает историю поверх — всё, что с Seq больше LastSeq. История — тот самый срез последних кадров из домашнего задания одиннадцатого урока, теперь с монотонным номером. Буферный send и select с default остаются на месте: reconnecting-клиент не должен блокировать остальных.
Пропущенные события — отдельная тема, и у неё три честных решения по цене потери. Первое: ничего не делать, если события lossy по природе — presence, typing-индикаторы, счётчики онлайн; свежий снапшот при входе закрывает вопрос. Второе: seq и проигрыш истории из буфера сервера — подходит для чата, работает, пока буфер держит весь зазор, и требует честного лимита: если клиент отсутствовал сутки, история его не спасёт, и нужна третья схема. Третья: полный реснапшот через обычный REST при каждом входе — дороже, но безгранично: список непрочитанных и последние пятьдесят сообщений приходят HTTP-запросом, а websocket дальше шлёт только новое. Выбор зависит от того, сколько стоит пропажа: для уведомлений достаточно второго, для переписки с гарантией доставки нужен store-and-forward из двенадцатого урока плюс хранилище. На практике схемы комбинируют: дельты из буфера закрывают первые секунды, а REST-реснапшот при уходе из буфера страхует остальное.
Встречный поток: что сервер делает с возвращающимися#
Серверная сторона reconnect почти не требует кода, но требует гигиены. Возвращающийся клиент проходит обычное рукопожатие: токен проверяется, Client регистрируется, join обрабатывается — ничего специального. Тонкости две. Первая: старый Client ещё числится в картах до дедлайна из восемнадцатого урока, и при быстром переподключении у одного userID на секунды живут два соединения; карта устройств из двенадцатого урока переживает это спокойно, а одинарная карта просто затирается — осознанное поведение, а не баг. Вторая: лимит соединений на пользователя. Без него одна вкладка с ошибкой в цикле переподключения откроет сотни сокетов, и сервер первым узнает о баге клиента. Проверка в ветке register — не больше пяти устройств на userID, лишние отклоняются системным конвертом, — и reconnecting-шторм упирается в потолок, а не в память. Rate limit на сам register тут избыточен: backoff клиента уже растянул попытки, серверу достаточно не усиливать их.
Сервер перезапустился, пять тысяч вкладок получили 1001 и все одновременно отсчитали одну секунду — волна попыток синхронна, как аплодисменты. Backoff без джиттера именно так и работает: паузы у всех одинаковые. Джиттер растаскивает попытки, а потолок не даёт вкладкам сойтись в общий такт после долгой аварии. Моё правило: ретраить с одним и тем же интервалом без случайной добавки — значит запланировать шторм заранее, вы просто выбираете его дату.
Код закрытия и реакция вкладки#
| Код | Что случилось | Реакция клиента |
|---|---|---|
| 1000 | закрылись сами: кнопка, выход | не переподключаться |
| 1001 | сервер ушёл: рестарт, деплой | backoff и переподключение |
| 1006 | обрыв без рукопожатия: сеть | backoff и переподключение |
| 1011 | ошибка на сервере | backoff, при повторах — пауза растёт |
| HTTP 401 на рукопожатии | токен просрочен | обновить токен, затем открыть сокет |
Проверка: рост паузы
База — одна секунда, потолок — тридцать. Какая пауза после четвёртой неудачной попытки подряд?
Проверка: сброс счётчика
Когда правильно обнулять счётчик попыток backoff?
Потолок backoff
Прочитайте код и выберите, что он выведет:
const base = 1000;
const cap = 4000;
const delays = [];
for (let attempts = 0; attempts < 5; attempts = attempts + 1) {
delays.push(Math.min(base * Math.pow(2, attempts), cap));
}Соберите клиент с backoff и сервер из прошлого урока на локальном Go. Откройте вкладку, прервите сервер Ctrl+C — вкладка получит 1001 и начнёт паузы; поднимите сервер обратно и убедитесь, что onopen вернул счётчик в ноль и повторил join. Затем в devtools выберите Offline на несколько секунд: вкладка переживёт пропажу и вернётся после возврата сети. Два сценария закрывают девяносто процентов реальных обрывов.
Соберите ветку onclose
Впишите три пропуска: поле события с кодом закрытия, основание степени и прирост счётчика попыток.
Соберите планировщик попыток
Расставьте строки функции переподключения: подсчёт паузы, учёт попытки и отложенный вызов connect.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: reconnect
Клиентская политика собрана: паузы растут, шторм рассыпан джиттером, подписки идемпотентны, зазор закрывается resume. Обратная сторона истории — серверная: соединения, которые умерли молча и ещё не отпущены, разбирает восемнадцатый урок про отвалы и таймауты. Прощальный кадр 1001, на который реагирует наш onclose, — наследие шестнадцатого урока про graceful shutdown, а поведение самих событий onopen и onclose с кодами — пятый урок про браузерный клиент. Свежий токен перед переподключением и жизненный цикл refresh-пар — в курсе JWT в Go, особенно урок про жизненный цикл токенов. Конверты с Seq и типами событий лежат в десятой теме про JSON-протокол. Проверьте оба сценария на локальном Go: рестарт сервера и Offline в devtools, плюс третий — испорченный токен, который должен остановить цикл, а не раскрутить его. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Backoff удваивает паузу от базы до потолка: сервер после рестарта получает попытки через 1, 2, 4, 8 секунд, а не шквал.
- Джиттер — случайная добавка к паузе: без него тысячи вкладок сошлись бы в синхронный шторм воссоединения.
- Счётчик попыток сбрасывается только в onopen — рукопожатие целиком, а не onclose.
- Новый сокет ничего не помнит: join, авторизация и presence-подписки повторяются и обязаны быть идемпотентными.
- Resume с lastSeq и историей хаба закрывает зазор пропущенных событий, пока он влезает в буфер.
- Не всё ретраится: 1000 — сами закрыли, 401 — сначала свежий токен по HTTP, потом сокет.
Домашнее задание
Дополните клиент из пятого урока политикой этого урока: экспоненциальный backoff с потолком в тридцать секунд, джиттер, сброс счётчика в onopen, отказ от переподключения на коде 1000. Затем добавьте в протокол Seq на сервере и команду resume: после reconnect вкладка должна догнать пропущенные кадры из истории комнаты. Проверьте локально три обрыва: Ctrl+C сервера, Offline в devtools и просроченный токен.
Личные заметкиПусто
Вопросы и ответы по уроку
Какую базу и потолок выбрать?
Обычный диапазон для чатов: база одна-две секунды, потолок тридцать-шестьдесят. Меньшая база быстрее возвращает после короткого рестарта, но чаще дёргает сервер при аварии; больший потолок экономит трафик, но клиент дольше замечает восстановление. Мобильным клиентам берут потолок побольше — их обрывы дольше.
Нужно ли ограничивать число попыток?
С потолком пауз это почти не нужно: вкладка пробует раз в полминуты, и это дёшево. Ограничение оправдано для фоновых вкладок и мобилок: после N часов ожиданий имеет смысл остановиться и показывать кнопку «переподключиться вручную» — держать ретраи ради мёртвой сессии расточительно.
Почему нельзя переподключаться сразу без пауз?
Потому что худший момент для шквала попыток — сразу после смерти сервера: он ещё поднимается, и каждая попытка стоит ему процессорного времени на отклонение. Экспоненциальный рост даёт серверу разгон без вклада клиентов, а джиттер не даёт выстроить из вкладок единую очередь атаки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами