Чем мы займемся

Как правило, работа с веб-сокетами сводится к паре строк: connect() и send(). Удобные абстракции библиотек превратили этот протокол в магическую трубу, по которой летают данные в обе стороны. Но магия заканчивается ровно в тот момент, когда соединение молча отваливается с кодом 1006, балансировщик рвет коннект, а в логах появляются странные ошибки фрагментации. В этой статье мы спустимся с небес высокоуровневых фреймворков на уровень байтов и битовых масок.

Мы пройдем полный путь WebSocket-соединения, опираясь на RFC 6455: от генерации ключа на стороне клиента до обмена закрывающими фреймами. Попутно разберем весь необходимый понятийный аппарат: что такое фреймы, какими они бывают, зачем их маскируют и фрагментируют и т.д. Цель не в том, чтобы научиться пользоваться конкретной библиотекой, а в том, чтобы понять, как протокол работает изнутри независимо от языка и реализации. Для иллюстраций по тексту статьи даны сниппеты на Python.

Зачем нужен протокол веб-сокетов

WebSocket представляет собой протокол прикладного уровня (как и HTTP) и возник из-за необходимости обеспечить полноценный двусторонний обмен сообщениями между клиентом и сервером.

Долгое время разработчики имитировали двустороннюю связь поверх HTTP через поллинг, лонг-поллин и другие ухищрения, объединенные под общим именем Comet. Каждый подход был компромиссом: лишние соединения, HTTP-оверхед на каждое сообщение, капризное поведение за корпоративными прокси.

Идея настоящего двустороннего канала оформилась в середине нулевых, в 2008 родилось само название WebSocket. В декабре 2011 года был опубликован стандарт RFC 6455 "The WebSocket Protocol", с тех пор он практически не менялся. Подробную историю читайте в статье "История веб-сокетов: от идеи к стандарту".

RFC 6455 — ��вод правил для реализации веб-сокетов

RFC 6455 — это документ, который описывает протокол WebSocket на уровне байтов: как устроено рукопожатие, как формируются фреймы, как закрывается соединение. Это не привязанная к языку спецификация, а набор правил, которым обязана следовать любая реализация: на Python, Go, Rust, Java, C++ или чем-то еще.

Открытие соединения — рукопожатие

Начиная с этого места мы пройдем все этапы соединения так, как это предписывает RFC 6455.

Клиент генерирует ключ

Перед отправкой запроса клиент генерирует случайное 16-байтовое значение и кодирует его в base64 — это значение будет использоваться в заголовке Sec-WebSocket-Key. RFC требует, чтобы ключ был криптографически случайным и уникальным для каждого соединения.

В Python это одна строка:

import secrets, base64

key = base64.b64encode(secrets.token_bytes(16)).decode()

Ключ нужен для верификации сервера: клиент убеждается, что сервер действительно понимает протокол WebSocket, а не просто вернул произвольный HTTP-ответ. Проверка происходит через заголовок Sec-WebSocket-Accept — сервер вычисляет значение для него из этого ключа по строго определённой схеме, и клиент может это проверить.

Клиент формирует запрос

Валидный HTTP/1.1 GET с пятью обязательными заголовками:

  • Host — имя сервера, к которому подключаемся. Стандартный HTTP-заголовок, позволяет серверу обслуживать несколько доменов на одном IP.

  • Upgrade: websocket — просьба переключить протокол. Сервер понимает, что это не обычный HTTP-запрос, а запрос на смену протокола.

  • Connection: Upgrade — указывает, что заголовок Upgrade нужно обработать, а не проигнорировать. Эти два заголовка всегда идут в паре.

  • Sec-WebSocket-Key — случайный ключ для верификации сервера, который мы сгенерировали на предыдущем шаге.

  • Sec-WebSocket-Version: 13 — версия протокола WebSocket. Версия 13 единственная актуальная с 2011 года и других в обиходе нет.

Если клиент — браузер, то он добавляет еще один заголовок, Origin,  сообщая серверу, с какого сайта пришел запрос. Сервер сам решает, что с этим делать: принять соединение, отклонить с кодом 403 или проигнорировать. Небраузерные клиенты могут отправлять этот заголовок по желанию.

Все новые HTTP-заголовки, которые вводит RFC 6455, используют префикс Sec-WebSocket-. В браузерном сценарии это помогает отличить настоящее рукопожатие от запросов, которые вредоносный скрипт мог бы попытаться отправить через XMLHttpRequest или форму. Но это не абсолютная гарантия "подлинности клиента", небраузерный клиент может сформировать такие заголовки сам.

Пример

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

В Python это сборка строки и кодирование в байты:

request = (
    f"GET {resource} HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Upgrade: websocket\r\n"
    f"Connection: Upgrade\r\n"
    f"Sec-WebSocket-Key: {key}\r\n"
    f"Sec-WebSocket-Version: 13\r\n"
    f"\r\n"
)

Завершающий \r\n\r\n — обязательный разделитель между заголовками и телом запроса согласно требованиям HTTP-протокола. Тело отсутствует, но разделитель должен быть.

Помимо обязательных заголовков, рукопожатие предусматривает необязательные:

  • Sec-WebSocket-Protocol позволяет клиенту предложить субпротокол — именованный прикладной протокол поверх WebSocket

  • Sec-WebSocket-Extensions открывает возможность согласовать расширения. Именно через этот механизм работает сжатие из RFC 7692.

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

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

Реальные примеры субпротоколов:

  • graphql-ws и graphql-transport-ws — субпротоколы для GraphQL.

  • mqtt — субпротокол для интернета вещей. Устройства умного дома, промышленные датчики.

Клиент отправляет запрос

WebSocket вводит собственные схемы URI: ws:// для обычного соединения и wss:// для защищенного через TLS, по аналогии с http:// и https://. Порты по умолчанию те же: 80 и 443 соответственно. Якоря (#anchor) в WebSocket URI не имеют смысла и RFC запрещает их использование.

С момента начала установки соединения клиент входит в состояние CONNECTING. Это первое из нескольких состояний, через которые проходит соединение в течение своей жизни. Если что-то идет не так на любом шаге (неверные параметры URI, недоступный хост, провалившееся TLS-рукопожатие) протокол предписывает прервать соединение.

Запрос отправляется через TCP-соединение, которое клиент должен предварительно открыть.

В asyncio это делается через asyncio.open_connection(), которая возвращает пару reader/writer — абстракцию над сокетом.

reader, writer = await asyncio.open_connection(host, port)
writer.write(request.encode())
await writer.drain()

Сервер принимает запрос

Сервер читает входящие байты и парсит HTTP-запрос. Минимально нужно извлечь 3 вещи:

  • путь из строки запроса

  • заголовок Upgrade для проверки, что это именно WebSocket-запрос

  • значение Sec-WebSocket-Key для формирования ответа

В asyncio на Python это может выглядеть так:

data = await reader.read(1024)
request = data.decode("utf-8")

headers: dict[str, str] = {}
# Разбиваем только по \r\n, игнорируя пустые строки в конце
for line in request.split("\r\n")[1:]:
    if ":" in line:
        key, value = line.split(":", 1)
        headers[key.strip().lower()] = value.strip()

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

  • метод GET

  • версия HTTP/1.1

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

Если что-то не так, вернуть 400 Bad Request и закрыть соединение.

Сервер вычисляет accept-ключ

Сервер берет полученный Sec-WebSocket-Key, дописывает к нему фиксированный GUID и вычисляет SHA-1 от результата:

import hashlib, base64

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def compute_accept_key(key: str) -> str:
    combined = key + GUID
    sha1 = hashlib.sha1(combined.encode()).digest()
    return base64.b64encode(sha1).decode()

accept_key = compute_accept_key(key)

Откуда значение фиксированного GUID? Оно захардкожено прямо в тексте RFC (раздел 1.3) и с тех пор кочует по всем реализациям веб-сокетов в мире. Поэтому в сниппете GUID не вымышленный, именно его нужно использовать в собственной реализации протокола.

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

Сервер отправляет ответ клиенту

Сервер формирует HTTP-ответ. В нем обязательны:

  • 101 Switching Protocols в статусной строке ответа

  • заголовки Upgrade и Connection

  • заголовок Sec-WebSocket-Accept. Значение для него сервер вычислил на предыдущем шаге.

Реализация на Python может выглядеть так:

response = (
    "HTTP/1.1 101 Switching Protocols\r\n"
    "Upgrade: websocket\r\n"
    "Connection: Upgrade\r\n"
    f"Sec-WebSocket-Accept: {accept_key}\r\n"
    "\r\n"
)
writer.write(response.encode())
await writer.drain()

После отправки этого ответа сервер переходит в состояние OPEN. Клиент перейдет в OPEN чуть позже, когда получит и провалидирует этот ответ.

В прикладном коде это состояние отслеживается через какую-нибудь переменную, enum и т.д.

Общение по протоколу HTTP закончилось. Дальше по этому же TCP-соединению пойдут только фреймы по правилам протокола веб-сокетов.

Передача данных

Что такое фреймы

Если по HTTP общаются запросами и ответами, то через веб-сокеты общаются фреймами.

Фрейм — это бинарная структура с заголовком и полезной нагрузкой. Заголовок говорит: какой тип данных несет фрейм, последний ли он в сообщении, замаскирована ли нагрузка и сколько байт она занимает.

Минимальный фрейм — два байта заголовка и любое количество байт полезной нагрузки, включая ноль. Никакого текстового форматирования, никаких HTTP-заголовков — только плотно упакованные биты.

Именно поэтому WebSocket значительно эффективнее HTTP-опроса: вместо сотен байт заголовков на каждое сообщение два байта накладных расходов на короткий фрейм.

Типы фреймов

Протокол предусматривает 6 типов фреймов. Их можно разделить на 2 группы.

Фреймы данных: текстовый (0x1), бинарный (0x2) и фрейм-продолжение (0x0). Несут полезную нагрузку приложения. Фрейм-продолжения нужны для передачи полезной нагрузки, "расщепленной" (фрагментированной) на несколько фреймов.

Управляющие фреймы: фрейм закрытия соединения (0x8), пинг-фрейм (0x9), понг-фрейм (0xA). Служат для управления состоянием соединения, не несут данных приложения. У них два жестких ограничения: полезная нагрузка не более 125 байт и фрагментация запрещена.

Структура фрейма

И в программе-клиенте, и в программе-сервере нужно реализовать:

  • парсер входящих фреймов

  • сериализатор исходящих

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

Чтобы реализовать парсер и сериализатор, надо понять, как фрейм устроен на уровне байтов.

Разберем каждое поле структуры фрейма.

Поле FIN (1 бит) — признак последнего фрейма в сообщении:

  • 1 — сообщение завершено

  • 0 — за ним последуют фреймы-продолжения с опкодом 0x0.

Поля RSV1, RSV2, RSV3 (по 1 биту) зарезервированы для расширений. В базовой реализации всегда 0. Получив ненулевое значение без согласованного расширения, нужно разорвать соединение. Одно из применений расширений — permessage-deflate (RFC 7692, сжатие данных). При его согласовании бит RSV1 используется как флаг: он сигнализирует, что полезная нагрузка сжата.

Поле Opcode (4 бита) указывает на тип фрейма. Шесть используемых значений мы уже перечислили выше.

Поле MASK (1 бит) — признак маскирования полезной нагрузки:

  • от клиента к серверу всегда 1

  • от сервера к клиенту всегда 0.

Если допущено нарушение, соединение должно быть закрыто.

Поле Payload length (7 бит) содержит длину полезной нагрузки, закодированную в 3 возможных варианта:

  • значение 0–125 — это и есть длина

  • значение 126 — следующие 2 байта содержат реальную длину

  • значение 127 — следующие 8 байт содержат реальную длину

Поле Masking-key (0 или 4 байта) — присутствует только во фреймах от клиента к серверу. Это случайное 32-битное значение, которое используется для маскирования полезной нагрузки. О том, что такое маскирование, будет ниже.

Поле Payload data — полезная нагрузка фрейма. Если бит MASK установлен в 1, данные замаскированы и сервер обязан их демаскировать перед обработкой.

Зачем фреймы маскируют

Маскирование — это обязательное требование для всех фреймов от клиента к серверу. Сервер к клиенту никогда не маскирует фреймы. Нарушение правила с любой стороны должно повлечь немедленное закрытие соединения.

Цель маскирования не в шифровании. Данные легко демаскировать, зная ключ, а ключ передается прямо в теле фрейма. Цель в другом: защита промежуточных прокси от атаки, при которой вредоносный JavaScript формирует WebSocket-трафик, выглядящий как валидный HTTP-запрос. Случайный ключ делает содержимое фрейма непредсказуемым для атакующего.

RFC требует такой алгоритм: клиент генерирует случайный 4-байтовый ключ и побайтово применяет его к полезной нагрузке. Операция обратима, тот же алгоритм и для маскирования, и для демаскирования:

def apply_mask(data: bytes, mask: bytes) -> bytes:
    return bytes(b ^ mask[i % 4] for i, b in enumerate(data))

Для учебных целей мы используем генератор, но в боевых библиотеках (той же websockets) маскирование оптимизируют через C-расширения или использование int.from_bytes для обработки сразу блоками.

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

import os

mask = os.urandom(4)
masked_data = apply_mask(payload, mask)

Фрагментация

Одно логическое сообщение может быть разбито на несколько фреймов. Это называется фрагментацией.

На практике использование фрагментации сводится к 2 главным преимуществам:

  • потоковая передача (экономия памяти)

  • возможность вставлять управляющие фреймы (ping, pong, close) между фрагментами. Если данные отправляются фрагментами, сервер может прерваться между двумя чанками, быстро ответить понгом на клиентский пинг, подтвердив, что он жив, и продолжить отправку следующего фрагмента. Сами управляющие фреймы фрагментировать запрещено.

Правила сборки фрагментированного сообщения:

  • первый фрейм говорит, какие данные передаются (текстовые или бинарные, 0x1 или 0x2), и в поле FIN содержит 0

  • далее идут фреймы-продолжения с opcode 0x0 и FIN=0

  • последний фрейм-продолжение имеет opcode 0x0 и FIN не 0, а 1. Так мы понимаем, что фрагментированный фрейм передан до конца.

Полезная нагрузка собирается конкатенацией фрагментов в порядке получения.

Пример: передача текстового файла фрагментами

Допустим, клиент отправляет текстовый файл размером 1 МБ, разбитый на четыре фрагмента по 256 КБ. В сети это выглядит так:

Фрейм 1: FIN=0  opcode=0x1  payload = байты 0      .. 262143   <- начало, TEXT
Фрейм 2: FIN=0  opcode=0x0  payload = байты 262144 .. 524287   <- продолжение
Фрейм 3: FIN=0  opcode=0x0  payload = байты 524288 .. 786431   <- продолжение
Фрейм 4: FIN=1  opcode=0x0  payload = байты 786432 .. 1048575  <- конец сообщения

Опкод 0x1 (TEXT) ставится только в первом фрейме, он определяет тип всего сообщения. Остальные фрагменты идут с опкодом 0x0 (CONTINUATION): они не несут информации о типе, просто продолжают начатое. Получатель накапливает полезную нагрузку всех фрагментов и только после получения фрейма с FIN=1 собирает их в единое сообщение и проверяет на соответствие UTF-8.

Между фрагментами данных можно вставлять управляющие фреймы — пинг, понг или закрывающий фрейм. Это не нарушает протокол: управляющие фреймы обрабатываются немедленно и не смешиваются с потоком фрагментов.

Фрейм 1: FIN=0  opcode=0x1  payload = часть 1     <- начало сообщения
Фрейм *: FIN=1  opcode=0x9  payload = ping data   <- пинг посреди передачи, ок
Фрейм 2: FIN=0  opcode=0x0  payload = часть 2     <- продолжение
Фрейм 3: FIN=1  opcode=0x0  payload = часть 3     <- конец сообщения

Клиент отправляет фрейм

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

  1. Определить опкод фрейма: 0x1 для текста, 0x2 для бинарных данных.

  2. Установить FIN=1, если сообщение не фрагментировано.

  3. Сгенерировать случайный 4-байтовый маскирующий ключ.

  4. Замаскировать полезную нагрузку.

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

  6. Отправить:

  • служебные байты фрейма

  • ключ

  • замаскированную полезную нагрузку.

Пример сериализатора фрейма

import os, struct

def build_frame(payload: bytes, opcode: int = 0x1) -> bytes:
    fin_and_opcode = 0x80 | opcode # FIN=1, RSV=0
    mask = os.urandom(4)
    masked_payload = apply_mask(payload, mask)
    length = len(payload)

    if length <= 125:
        header = struct.pack(">BB", fin_and_opcode, 0x80 | length)
    elif length <= 65535:
        header = struct.pack(">BBH", fin_and_opcode, 0x80 | 126, length)
    else:
        header = struct.pack(">BBQ", fin_and_opcode, 0x80 | 127, length)

    return header + mask + masked_payload


writer.write(build_frame(b"hello"))
await writer.drain()

Бит 0x80 в поле длины — это MASK=1, он сигнализирует серверу, что нагрузка замаскирована и во фрейме присутствует 4-байтовый ключ.

Сервер получает и разбирает фрейм

Сервер читает входящие байты и разбирает фрейм в обратном порядке. Порядок действий:

  1. Прочитать первые 2 байта, извлечь FIN, opcode, MASK и базовую длину.

  2. Если базовая длина 126, прочитать еще 2 байта для получения реальной длины.

  3. Если базовая длина 127, прочитать еще 8 байт.

  4. Прочитать 4 байта маскирующего ключа.

  5. Прочитать полезную нагрузку и демаскировать ее.

Пример реализации парсера фрейма на Python

async def parse_frame(reader: asyncio.StreamReader) -> tuple[int, bool, bool, bytes]:
    header = await reader.readexactly(2)
    fin    = bool(header[0] & 0x80)
    opcode = header[0] & 0x0F
    masked = bool(header[1] & 0x80)
    length = header[1] & 0x7F

    if length == 126:
        length = struct.unpack(">H", await reader.readexactly(2))[0]
    elif length == 127:
        length = struct.unpack(">Q", await reader.readexactly(8))[0]

    mask = await reader.readexactly(4) if masked else b""
    payload = await reader.readexactly(length)

    if masked:
        payload = apply_mask(payload, mask)

    return opcode, fin, masked, payload

Этот парсер разбирает только структуру фрейма. Валидация бита MASK (проверка, что клиентские фреймы всегда замаскированы, а серверные никогда) выполняется отдельным слоем поверх парсера.

Сервер обрабатывает фрейм в зависимости от типа

После разбора фрейма сервер смотрит на опкод фрейма (текстовый, бинарный, какой-то неизвестный) и действует соответственно.

Пример реализации обработчика фрейма:

async def handle_frame(opcode: int, payload: bytes, writer: asyncio.StreamWriter):
    if opcode == 0x1:  # текст
        text = payload.decode("utf-8")
        # обработка текстового сообщения
    elif opcode == 0x2:  # бинарные данные
        # обработка бинарных данных
    elif opcode == 0x9:  # ping
        await send_pong(writer, payload)
    elif opcode == 0x8:  # close
        await send_close(writer, payload)
    else:
        # неизвестный opcode — разрываем соединение
        await send_close(writer, 1002)

3 момента, которые обязательны для корректной реализации:

  • текстовый фрейм декодируется явно с обработкой ошибки — невалидный UTF-8 означает закрытие с кодом 1007

  • пинг-фрейм требует немедленного ответа понг-фреймом с идентичной полезной нагрузкой

  • если опкод фрейма неизвестен, соединение должно закрываться с кодом 1002.

Сервер отправляет фрейм

Аналогично клиенту, но без маскирования. Логика сериализатора та же, что и на клиенте, только бит MASK не выставляется и маскирующий ключ не генерируется и не включается во фрейм.

Клиент получает и разбирает фрейм

Парсер практически идентичен серверному. Единственное отличие: клиент ожидает MASK=0 во входящих фреймах. Если пришел замаскированный фрейм от сервера, то это нарушение протокола, соединение закрывается с кодом 1002.

Клиент обрабатывает фрейм по типу

Логика идентична серверной, те же опкоды, те же реакции.

Зачем нужен пинг-понг

TCP-соединение может "зависнуть" незаметно для обеих сторон. Например, промежуточный прокси или NAT молча дропнул соединение без FIN-пакета. Приложение при этом считает соединение живым, хотя данные уже никуда не идут.

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

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

Закрытие соединения

Зачем нужен закрывающий фрейм, если есть TCP FIN/ACK?

Авторы RFC отвечают: TCP-закрытие не всегда надежно сквозь прокси и промежуточные узлы, они могут оборвать соединение молча, без FIN-сегмента. Кроме того, на некоторых платформах закрытие сокета с непрочитанными данными в буфере приводит к отправке RST вместо FIN — и получатель теряет данные, которые уже были доставлены.

Закрывающий фрейм решает обе проблемы: он идет по тому же каналу, что и данные, и гарантирует, что обе стороны явно договорились о завершении обмена до того, как TCP-соединение закроется.

Несколько слов о статусах закрытия, предусмотренных RFC 6455

RFC определяет набор кодов, которые стороны могут передавать в закрывающем фрейме. Часть из них рабочие, часть зарезервированы и по сети никогда не передаются.

Рабочие коды:

  • 1000 — нормальное закрытие, цель соединения достигнута

  • 1001 — сторона уходит: сервер перезапускается, браузер закрывает вкладку

  • 1002 — ошибка протокола: нарушение правил фрейминга

  • 1003 — получен неприемлемый тип данных, например бинарный фрейм там, где ожидался текст

  • 1007 — данные не соответствуют типу фрейма: невалидный UTF-8 в текстовом сообщении

  • 1008 — сообщение нарушает политику приложения, универсальный код когда более конкретный не подходит

  • 1009 — сообщение слишком большое для обработки

  • 1010 — клиент ожидал согласования расширений, но сервер их не вернул

  • 1011 — сервер завершает соединение из-за непредвиденной внутренней ошибки

Зарезервированные коды — не передаются по сети, только фиксируются локально:

  • 1004 — зарезервирован, значение не определено

  • 1005 — нет кода: закрывающий фрейм пришел без полезной нагрузки

  • 1006 — аномальное закрытие: TCP оборвался без обмена закрывающими фреймами

  • 1015 — сбой TLS-рукопожатия

Коды 1005, 1006 и 1015 принципиально отличаются от остальных: их нельзя отправить, потому что они описывают ситуации, в которых отправка невозможна по определению.

Инициатор отправляет фрейм закрытия соединения

Фрейм закрытия — это управляющий фрейм с опкодом 0x8. Он может нести необязательную полезную нагрузку:

  • в первых двух байтах код причины закрытия

  • в остальных UTF-8 сообщение для отладки. Парсер второй стороны обязан проверить валидность этого UTF-8. Если пришел битый текст (невалидный UTF-8), это считается ошибкой протокола и повод закрыть соединение с кодом 1007.

Пример

async def send_close(writer: asyncio.StreamWriter, code: int = 1000, reason: str = ""):
    payload = struct.pack(">H", code) + reason.encode("utf-8")
    frame = build_frame(payload, opcode=0x8)
    writer.write(frame)
    await writer.drain()

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

Получатель отвечает фреймом закрытия

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

async def handle_close(writer: asyncio.StreamWriter, payload: bytes):
    if not payload:
        # пустой payload валиден, отвечаем тоже пустым
        writer.write(build_frame(b"", opcode=0x8))
        await writer.drain()
        return
    code = struct.unpack(">H", payload[:2])[0]
    await send_close(writer, code)

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

TCP-соединение закрывается

После обмена закрывающими фреймами стороны закрывают TCP. Здесь есть асимметрия: сервер закрывает немедленно, клиент ждет TCP FIN-сегмент от сервера. Асимметрия намеренно предусмотрена протоколом: сервер должен закрывать TCP первым, чтобы именно он удерживал состояние TIME_WAIT, а не клиент. Для клиента TIME_WAIT означает, что он не может переоткрыть то же соединение в течение 2MSL (двух максимальных времен жизни TCP-сегмента). Для сервера удержание TIME_WAIT не создает аналогичных проблем, потому что новое входящее SYN с большим порядковым номером немедленно открывает новое соединение.

# сервер
writer.close()
await writer.wait_closed()

# клиент ждет EOF от сервера
await reader.read(1)
writer.close()
await writer.wait_closed()

Соединение переходит в состояние CLOSED

После закрытия TCP-соединения соединение по веб-сокету считается закрытым чисто, если обмен закрывающими фреймами был завершен. Если TCP-соединение оборвалось без закрывающих фреймов, соединение тоже считается CLOSED, но "грязно". Локально должен быть зафиксирован код закрытия 1006.

Переподключение при аномальном закрытии

Если TCP-соединение оборвалось без обмена закрывающими фреймами, клиент локально фиксирует код 1006 и не должен переподключаться немедленно. Тысячи клиентов, одновременно потерявших соединение и тут же ломящихся обратно, могут положить сервер не хуже DDoS-атаки.

RFC предписывает 2 вещи:

  • случайная начальная задержка перед первой попыткой переподключения, RFC предлагает от 0 до 5 секунд

  • нарастающая задержка при последующих неудачах: каждая следующая попытка должна ждать дольше предыдущей. RFC не предписывает конкретный алгоритм. В качестве примера упоминается "truncated binary exponential backoff", то есть удвоение задержки с верхним ограничением.

Пример реализации

import asyncio, random

async def reconnect_with_backoff():
    delay = random.uniform(0, 5)
    max_delay = 60

    while True:
        await asyncio.sleep(delay)
        try:
            await connect()
            break
        except Exception:
            delay = min(delay * 2, max_delay)

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

Собираем все шаги вместе: полная картина работы веб-сокетов

Открытие соединения

  1. Клиент генерирует случайный ключ

  2. Клиент открывает TCP-соединение к ws:// или wss:// адресу и отправляет GET-запрос по HTTP не ниже версии 1.1 с обязательными заголовками, включая заголовок Sec-WebSocket-Key с ключом, сгенерированным на шаге 1.

  3. Сервер принимает запрос, валидирует заголовки

  4. Сервер вычисляет Sec-WebSocket-Accept из ключа клиента и фиксированного GUID

  5. Сервер отвечает 101 Switching Protocols

  6. Клиент валидирует ответ сервера: статус 101, заголовки Upgrade/Connection и Sec-WebSocket-Accept. При успехе обе стороны переходят в состояние OPEN.

Передача данных

  1. Клиент формирует фрейм: FIN + RSV + опкод + MASK + длина + маскирующий ключ + замаскированная нагрузка

  2. Сервер принимает фрейм, демаскирует, обрабатывает по опкоду

  3. Сервер формирует фрейм: опкод + FIN + нагрузка (без маскирования)

  4. Клиент принимает фрейм, обрабатывает по опкоду

  5. Любая сторона периодически отправляет пинг — другая обязана ответить понгом

Закрытие соединения

  1. Инициатор отправляет фрейм закрытия (полезная нагрузка необязательна, но если присутствует, то содержит код причины), переходит в состояние CLOSING

  2. Получатель отвечает фреймом закрытия. Если инициатор прислал код, отвечает тем же кодом; если полезной нагрузки не было, отвечает тоже пустым фреймом

  3. Сервер закрывает TCP немедленно, клиент ждет TCP FIN-сегмент от сервера

  4. Обе стороны переходят в состояние CLOSED

Аномальное закрытие

  1. Если TCP оборвался без обмена закрывающими фреймами, локально фиксируется код 1006, он не передается по сети

  2. Клиент переподключается со случайной начальной задержкой и нарастающей задержкой при повторных неудачах

Учебная реализация на Python

Все шаги, описанные в статье (кроме самого последнего), реализованы в виде рабочего кода в учебном репозитории. Сервер и клиент написаны на чистом Python без сторонних зависимостей, только стандартная библиотека.

Общая логика учебного примера такая:

  • сервер и клиент запускаются в отдельных процессах через multiprocessing, каждый со своим событийным циклом. В данном случае код и клиента, и сервера сведены в один модуль для удобства (можно сразу запустить и увидеть обмен, а не открывать 2 отдельных терминала);

  • после рукопожатия обе стороны переходят в режим полноценного двустороннего обмена: клиент отправляет текстовый фрейм каждую секунду, а сервер независимо от клиента каждые 1.5 секунды;

  • параллельно сервер периодически отправляет пинг-фреймы, а клиент отвечает понг-фреймами, не прерывая основной поток сообщений;

  • после отправки 5 сообщений клиент инициирует корректное закрытие.