Потеря соединения

Потеря соединения в приложениях на основе STOMP.js — одна из ключевых проблем при разработке систем реального времени. Даже при стабильной серверной инфраструктуре WebSocket-соединение может быть разорвано из-за сетевых сбоев, ограничений браузера, таймаутов балансировщиков нагрузки, перезапуска брокера сообщений или ошибок в клиентской логике.

Наиболее распространённые причины:

  • нестабильное интернет-соединение;
  • переход устройства в спящий режим;
  • закрытие TCP-сессии прокси-сервером;
  • отключение heartbeat-механизма;
  • перезапуск брокера;
  • ошибки SSL/TLS;
  • проблемы авторизации;
  • ограничение количества соединений;
  • блокировка WebSocket корпоративным firewall;
  • потеря мобильной сети.

STOMP.js предоставляет встроенные механизмы для обработки подобных ситуаций, включая автоматическое переподключение, heartbeat-пакеты и события жизненного цикла соединения.


Как STOMP.js определяет потерю соединения

Библиотека использует несколько механизмов:

Событие закрытия WebSocket

При разрыве WebSocket вызывается callback:

client.onWebSocketCl ose = (event) => {
    console.log('Соединение закрыто');
    console.log(event);
};

Объект event содержит:

{
    code: 1006,
    reason: "",
    wasClean: false
}

Основные коды закрытия:

Код Описание
1000 Нормальное закрытие
1001 Сервер завершил работу
1006 Аварийное отключение
1008 Ошибка политики безопасности
1011 Внутренняя ошибка сервера

Heartbeat-механизм

STOMP-протокол поддерживает heartbeat-пакеты.

Если сервер или клиент перестают получать heartbeat в течение заданного времени, соединение считается потерянным.

Настройка heartbeat:

client.heartbeatIncoming = 10000;
client.heartbeatOutgoing = 10000;

Значения указываются в миллисекундах.

В данном примере:

  • клиент отправляет heartbeat каждые 10 секунд;
  • клиент ожидает heartbeat от сервера каждые 10 секунд.

Если heartbeat не приходит — соединение разрывается.


Базовая обработка отключения

Минимальная конфигурация:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    reconnectDelay: 5000
});

client.onConn ect = () => {
    console.log('Соединение установлено');
};

client.onDisconn ect = () => {
    console.log('Соединение закрыто');
};

client.onStompEr ror = (frame) => {
    console.error('STOMP ошибка');
    console.error(frame.headers['message']);
};

client.onWebSocketCl ose = () => {
    console.log('WebSocket закрыт');
};

client.activate();

Автоматическое переподключение

STOMP.js поддерживает встроенный reconnect-механизм.

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    reconnectDelay: 5000
});

Параметр:

reconnectDelay: 5000

означает:

  • при потере соединения клиент подождёт 5 секунд;
  • затем выполнит повторное подключение;
  • цикл будет продолжаться бесконечно.

Полный цикл восстановления соединения

Типичный сценарий:

  1. WebSocket закрывается.
  2. Вызывается onWebSocketClose.
  3. STOMP.js запускает reconnect timer.
  4. Выполняется новое подключение.
  5. Вызывается onConnect.
  6. Подписки создаются повторно.
  7. Работа приложения продолжается.

Повторная регистрация подписок

После переподключения старые subscriptions становятся недействительными.

Это критически важный момент.

Неправильный вариант:

client.subscribe('/topic/messages', callback);

После reconnect подписка исчезнет.

Правильный подход:

client.onConn ect = () => {
    client.subscribe('/topic/messages', (message) => {
        console.log(message.body);
    });
};

Теперь каждая новая сессия автоматически восстанавливает подписки.


Централизованное восстановление подписок

В больших приложениях подписок может быть много.

Удобно хранить их в массиве:

const subscriptions = [
    {
        destination: '/topic/chat'
    },
    {
        destination: '/topic/notifications'
    },
    {
        destination: '/topic/system'
    }
];

Повторное подключение:

client.onConn ect = () => {

    subscriptions.forEach((item) => {

        client.subscribe(item.destination, (message) => {
            console.log(item.destination);
            console.log(message.body);
        });

    });

};

Экспоненциальный reconnect

Фиксированная задержка не всегда эффективна.

Иногда сервер недоступен длительное время, и постоянные reconnect-попытки создают лишнюю нагрузку.

Можно реализовать экспоненциальную стратегию:

let reconnectDelay = 1000;

function createClient() {

    const client = new Client({
        brokerURL: 'ws://localhost:15674/ws',
        reconnectDelay: reconnectDelay
    });

    client.onConn ect = () => {

        reconnectDelay = 1000;

        console.log('Подключение восстановлено');
    };

    client.onWebSocketCl ose = () => {

        reconnectDelay = Math.min(
            reconnectDelay * 2,
            30000
        );

        console.log(
            'Новая задержка:',
            reconnectDelay
        );
    };

    return client;
}

Защита от reconnect storm

Reconnect storm — ситуация, когда тысячи клиентов одновременно пытаются восстановить соединение после сбоя сервера.

Для защиты используется случайная задержка:

const reconnectDelay =
    3000 + Math.random() * 5000;

Теперь клиенты будут подключаться в разное время.


Обработка сетевых переключений

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

  • Wi-Fi → LTE;
  • LTE → 5G;
  • VPN → обычная сеть.

Браузер предоставляет события:

window.addEventListener('offline', () => {
    console.log('Интернет отключен');
});

window.addEventListener('online', () => {
    console.log('Интернет восстановлен');
});

Можно инициировать reconnect:

window.addEventListener('online', () => {

    if (!client.active) {
        client.activate();
    }

});

Проверка состояния соединения

STOMP.js предоставляет свойства:

console.log(client.active);
console.log(client.connected);

Разница:

Свойство Значение
active Клиент активирован
connected Соединение установлено

Пример:

if (client.connected) {
    client.publish({
        destination: '/app/chat',
        body: 'Сообщение'
    });
}

Потеря соединения во время отправки сообщений

Возможна ситуация:

  1. клиент вызывает publish;
  2. соединение теряется;
  3. сообщение не доходит до сервера.

Простейшая защита:

function safePublish(destination, body) {

    if (!client.connected) {

        console.log('Нет соединения');

        return false;
    }

    client.publish({
        destination,
        body
    });

    return true;
}

Очередь исходящих сообщений

Более надёжный подход — локальная очередь.

const pendingMessages = [];

Отправка:

function send(destination, body) {

    if (!client.connected) {

        pendingMessages.push({
            destination,
            body
        });

        return;
    }

    client.publish({
        destination,
        body
    });
}

После reconnect:

client.onConn ect = () => {

    while (pendingMessages.length > 0) {

        const message =
            pendingMessages.shift();

        client.publish({
            destination: message.destination,
            body: message.body
        });
    }

};

Дублирование сообщений после reconnect

При повторной отправке возможны дубликаты.

Особенно это актуально для:

  • чатов;
  • финансовых систем;
  • игровых серверов;
  • заказов;
  • уведомлений.

Решение — уникальный messageId.

const message = {
    id: crypto.randomUUID(),
    text: 'Привет'
};

Сервер хранит уже обработанные идентификаторы.


Потеря сообщений во время отключения

Pub/Sub архитектура не гарантирует доставку сообщений отключённому клиенту.

Если пользователь потерял соединение:

  • он может пропустить события;
  • уведомления исчезнут;
  • данные рассинхронизируются.

Решения:

Использование очередей

/queue/messages

История сообщений

После reconnect клиент запрашивает:

GET /api/messages?after=timestamp

Event sourcing

Сервер хранит журнал событий.


Heartbeat и ложные отключения

Слишком маленький heartbeat приводит к ложным reconnect.

Проблемный пример:

client.heartbeatIncoming = 1000;
client.heartbeatOutgoing = 1000;

Даже кратковременная задержка сети может вызвать disconnect.

Рекомендуемые значения:

Тип сети Heartbeat
LAN 5000
Интернет 10000
Мобильная сеть 20000–30000

Обнаружение зависшего соединения

Иногда TCP-соединение остаётся открытым, но данные больше не передаются.

Heartbeat помогает выявлять подобные ситуации.

Дополнительная проверка:

let lastMessageTime = Date.now();

client.onConn ect = () => {

    client.subscribe('/topic/data', () => {

        lastMessageTime = Date.now();

    });

};

Периодическая проверка:

setInterval(() => {

    const now = Date.now();

    if (now - lastMessageTime > 30000) {

        console.log('Соединение зависло');

        client.deactivate();
        client.activate();
    }

}, 5000);

Ручное переподключение

Иногда требуется принудительный reconnect:

async function reconnect() {

    await client.deactivate();

    client.activate();
}

Применяется:

  • после обновления токена;
  • после смены пользователя;
  • при изменении brokerURL;
  • после сетевого сбоя.

Обновление JWT-токена после reconnect

Частая проблема — истечение access token.

Решение:

const client = new Client({

    brokerURL: 'ws://localhost:15674/ws',

    connectHeaders: {
        Authorization: `Bearer ${getToken()}`
    },

    reconnectDelay: 5000
});

Но reconnect использует старый token.

Правильный подход:

beforeConnect: async () => {

    client.connectHeaders = {
        Authorization: `Bearer ${await refreshToken()}`
    };

}

Обработка ошибок STOMP

STOMP-ошибки не всегда приводят к закрытию WebSocket.

client.onStompEr ror = (frame) => {

    console.error(
        'Broker error:',
        frame.headers['message']
    );

    console.error(
        'Details:',
        frame.body
    );
};

Причины:

  • отказ в авторизации;
  • отсутствие destination;
  • превышение лимитов;
  • ошибки маршрутизации;
  • недоступность exchange.

Диагностика reconnect

Полезно включать debug:

client.debug = (message) => {
    console.log(message);
};

Лог покажет:

  • CONNECT;
  • CONNECTED;
  • SUBSCRIBE;
  • DISCONNECT;
  • heartbeat;
  • reconnect;
  • ошибки брокера.

Логирование состояния соединения

Удобно централизовать события:

function logState(state) {

    console.log(
        `[STOMP STATE]: ${state}`
    );
}

Использование:

client.onConn ect = () => {
    logState('CONNECTED');
};

client.onDisconn ect = () => {
    logState('DISCONNECTED');
};

client.onWebSocketCl ose = () => {
    logState('SOCKET CLOSED');
};

Поведение браузера во вкладке background

Современные браузеры ограничивают таймеры фоновых вкладок.

Последствия:

  • heartbeat может задерживаться;
  • reconnect работает медленнее;
  • соединение закрывается прокси-сервером.

Особенно заметно:

  • в мобильном Chrome;
  • в Safari iOS;
  • в энергосберегающем режиме.

Потеря соединения при масштабировании сервера

В распределённой архитектуре reconnect может подключить клиента к другому серверу.

Проблемы:

  • потеря session state;
  • исчезновение временных подписок;
  • отсутствие синхронизации.

Решения:

  • sticky sessions;
  • shared broker;
  • Redis pub/sub;
  • external session storage.

Graceful shutdown

При закрытии вкладки желательно корректно завершать соединение.

window.addEventListener('beforeunload', () => {
    client.deactivate();
});

Это уменьшает количество “висячих” соединений на сервере.


Типичная production-конфигурация

const client = new Client({

    brokerURL: 'wss://api.example.com/ws',

    reconnectDelay: 5000,

    heartbeatIncoming: 10000,

    heartbeatOutgoing: 10000,

    beforeConnect: async () => {

        client.connectHeaders = {
            Authorization: `Bearer ${await refreshToken()}`
        };

    },

    debug: (message) => {
        console.log(message);
    }
});

client.onConn ect = () => {

    console.log('STOMP connected');

    client.subscribe('/topic/events', (message) => {
        console.log(message.body);
    });

};

client.onStompEr ror = (frame) => {

    console.error(
        frame.headers['message']
    );

};

client.onWebSocketCl ose = () => {

    console.log(
        'WebSocket disconnected'
    );

};

client.activate();