Обрыв realtime/voice websocket
Голосовые и realtime-интерфейсы держат постоянный websocket (OpenAI Realtime API, стриминговый ASR/TTS). Один разрыв — норма, клиент переподключится. А вот шторм реконнектов означает реальную проблему: у пользователя рвётся разговор на полуслове. HTTP-мониторинг это не видит вовсе — соединение живёт не по запросам, а секундами и минутами, и рвётся своими close-кодами.
Ловим аномальные коды закрытия и всплеск частоты переподключений.
Считаем реконнекты и аномальные close-коды
Заголовок раздела «Считаем реконнекты и аномальные close-коды»Оборачиваем клиент websocket: 1000 — штатное закрытие, всё остальное
считаем в скользящем окне; всплеск = push.
import os, time, collections, asyncio, requestsimport websockets # pip install websockets
NOTIFLY_URL = os.environ["NOTIFLY_URL"]NOTIFLY_TOKEN = os.environ["NOTIFLY_TOKEN"]
WINDOW_SEC = 120 # окно подсчёта реконнектовRECONNECT_MAX = 5 # столько переподключений за окно = штормCOOLDOWN = 180NORMAL_CLOSE = {1000, 1001} # штатные коды: going away / normal
_reconnects = collections.deque()_last_alert = 0.0
def notify(title, msg, prio): requests.post(f"{NOTIFLY_URL}/message", params={"token": NOTIFLY_TOKEN}, json={"title": title, "message": msg, "priority": prio}, timeout=5)
def _record_reconnect(code): global _last_alert now = time.time() _reconnects.append(now) while _reconnects and _reconnects[0] < now - WINDOW_SEC: _reconnects.popleft() abnormal = code not in NORMAL_CLOSE if (len(_reconnects) >= RECONNECT_MAX or abnormal) and now - _last_alert > COOLDOWN: _last_alert = now notify( f"🔌 Realtime WS рвётся: {len(_reconnects)} реконнектов", f"Последний close-код {code} за {WINDOW_SEC}с. " f"У пользователей рвётся голос/стрим.", priority=8, )
async def realtime_session(url, headers): """Держим соединение; на разрыве считаем реконнект и переподключаемся с бэкоффом.""" backoff = 1 while True: try: async with websockets.connect(url, additional_headers=headers) as ws: backoff = 1 # успешно подключились — сброс async for message in ws: handle(message) except websockets.ConnectionClosed as e: _record_reconnect(e.code) except Exception: _record_reconnect(-1) # -1 = обрыв без close-кода await asyncio.sleep(min(backoff, 30)) backoff *= 2 # экспоненциальный бэкофф
def handle(message): ... # разбор аудио/текстовых фреймовШтатные коды (1000/1001) не тревожат — рвётся не только сеть, но и сам
пользователь, кладя трубку. Алёрт бьёт либо на аномальный код закрытия
(1006 abnormal closure, 1011 server error), либо на частоту
реконнектов. Экспоненциальный бэкофф в самом клиенте не даёт превратить
шторм реконнектов в DDoS против провайдера.
Аномальные коды, за которыми стоит следить
Заголовок раздела «Аномальные коды, за которыми стоит следить»1006— abnormal closure, соединение оборвалось без close-фрейма (сеть, прокси);1011— internal server error на стороне провайдера;1013— try again later, провайдер перегружен;4xx-подобные апп-коды провайдера — часто протухший токен сессии.
Разные коды — разные причины: 1011/1013 про сторону провайдера, 1006
про вашу сеть, апп-коды про авторизацию.
Подстрахуйте heartbeat’ом сессии
Заголовок раздела «Подстрахуйте heartbeat’ом сессии»Пока соединение живёт, шлите редкий heartbeat «voice-сессия активна». Если воркер, который держит websocket, целиком умрёт (упал процесс, OOM), реконнектов не будет вовсе — считать станет нечего. Пропущенный heartbeat поймает именно это молчание, которое счётчик реконнектов не увидит.
Что положить в текст алёрта
Заголовок раздела «Что положить в текст алёрта»- последний close-код и его смысл;
- число реконнектов за окно;
- сколько сессий затронуто (если держите их несколько);
- аптайм соединения до обрыва — короткий аптайм намекает на авторизацию/сеть.