Перейти к содержимому

Обрыв realtime/voice websocket

Голосовые и realtime-интерфейсы держат постоянный websocket (OpenAI Realtime API, стриминговый ASR/TTS). Один разрыв — норма, клиент переподключится. А вот шторм реконнектов означает реальную проблему: у пользователя рвётся разговор на полуслове. HTTP-мониторинг это не видит вовсе — соединение живёт не по запросам, а секундами и минутами, и рвётся своими close-кодами.

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

Оборачиваем клиент websocket: 1000 — штатное закрытие, всё остальное считаем в скользящем окне; всплеск = push.

import os, time, collections, asyncio, requests
import websockets # pip install websockets
NOTIFLY_URL = os.environ["NOTIFLY_URL"]
NOTIFLY_TOKEN = os.environ["NOTIFLY_TOKEN"]
WINDOW_SEC = 120 # окно подсчёта реконнектов
RECONNECT_MAX = 5 # столько переподключений за окно = шторм
COOLDOWN = 180
NORMAL_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 «voice-сессия активна». Если воркер, который держит websocket, целиком умрёт (упал процесс, OOM), реконнектов не будет вовсе — считать станет нечего. Пропущенный heartbeat поймает именно это молчание, которое счётчик реконнектов не увидит.

  • последний close-код и его смысл;
  • число реконнектов за окно;
  • сколько сессий затронуто (если держите их несколько);
  • аптайм соединения до обрыва — короткий аптайм намекает на авторизацию/сеть.