SDP Offer/Answer: Подробный Разбор

Автор: Николай СапуновОбновлено: август 202629 мин чтения
Содержание статьи +

TL;DR

Модель offer/answer – это шаблон переговоров, который WebRTC использует, чтобы два участника заранее договорились о кодеках, сетевых путях и ключах шифрования, ещё до передачи первого аудио- или видеокадра. Одна сторона, инициатор (offerer), создаёт текстовый документ под названием SDP – Session Description Protocol – и описывает в нём, что готова отправлять и принимать; вторая сторона, отвечающий (answerer), формирует ответный SDP, выбирая, на какие условия соглашается. Правила, по которым браузер строит и применяет эти документы, описаны в RFC 9429 – JSEP, JavaScript Session Establishment Protocol, опубликованном в апреле 2024 года; формат самого SDP стандартизирован в RFC 8866 (январь 2021); исходная модель offer/answer восходит к RFC 3264 (июнь 2002). Эта статья шаг за шагом разбирает процесс обмена, пять состояний сигнализации, через которые проходит соединение, архитектуру BUNDLE + trickle, лежащую в основе современного WebRTC, и паттерн Perfect Negotiation, который предотвращает сбой двунаправленного звонка, когда обе стороны одновременно пытаются перезаключить соглашение.

Почему это важно

Если вы разрабатываете что-либо на основе WebRTC – приложение для видеосвязи, телемедицинскую платформу, голосовой аукцион или онлайн-урок – обмен offer/answer становится тем самым критическим этапом, на котором звонок либо успешно устанавливается, либо падает. Когда врач слышит: «у пациента не идёт видео», в четырёх случаях из пяти причина кроется в SDP-обмене: не был предложен нужный кодек, нарушилась группа BUNDLE, новый offer отправлен до получения ответа на предыдущий, ICE-кандидат пришёл слишком рано или трек добавлен после answer без повторного согласования. Хорошая новость в том, что правила описаны с редкой для протоколов точностью – в одном документе, RFC 9429, – и их немного: вся спецификация JSEP занимает около 130 страниц, а та её часть, что важна продуктовой команде, умещается в этой статье. Прочитайте её один раз – и весь остальной WebRTC станет проще оценивать, отлаживать и обсуждать с инженерами.

Что такое SDP на самом деле

Протокол описания сессии, сокращённо SDP, – это небольшой текстовый формат для описания мультимедийной сессии. Он был впервые опубликован для телефонии в 1998 году, обновлён для современного интернета в RFC 4566 и заменён на RFC 8866 в январе 2021 года (IETF, RFC 8866, SDP: Session Description Protocol, January 2021). Документ SDP представляет собой плоский список однобуквенных ключей и их значений, по одной паре на строку:

v=0
o=- 4611732742 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 63
c=IN IP4 0.0.0.0
a=rtcp-mux
a=mid:0
a=sendrecv
a=rtpmap:111 opus/48000/2
a=ice-ufrag:F7gI
a=ice-pwd:x9cml/YzichV2+XlhiMu8g
a=fingerprint:sha-256 1A:2B:3C:...:F9
a=setup:actpass

Вот это и есть, в 14 строках, WebRTC-офер с одной аудиодорожкой и самыми важными полями. Читатель, который никогда не открывал SDP, обычно спрашивает: почему он выглядит так криптично? Ответ прост: SDP старше JSON на десятилетие, ещё старше XML, и был разработан в те времена, когда 28.8-килобитный модем считался нормой – поэтому краткость победила над читаемостью, а формат с тех пор не менялся, поскольку не менялась установленная база. WebRTC унаследовал его, потому что весь существующий телефонный стек уже умел его обрабатывать.

Каждая строка – это одно поле. v=0 – версия протокола. Строка o= идентифицирует инициатора. s= – имя сессии (в WebRTC оно всегда равно -, то есть отсутствует). t=0 0 – тайминг сессии (нули означают «пока обе стороны заинтересованы»). Каждая строка m= открывает медиасекцию – одну для аудио, одну для видео и, при наличии, одну для канала данных. Внутри каждой медиасекции каждая строка a= – это атрибут, уточняющий поведение этой секции: используемые кодеки, механизмы обратной связи RTP, ключи шифрования, ICE-кандидаты и направление передачи медиа.

Ключевая мысль: SDP – описательный, а не активный. Документ сам по себе не передаёт данные. Он лишь объясняет обеим сторонам, как будут выглядеть байты при передаче. Сам транспорт – UDP-пакеты с зашифрованным RTP – существует полностью независимо от SDP и работает на тех портах, которые SDP указал. В терминах программного обеспечения SDP – это конфигурационный файл, которым обмениваются два браузера.

Рис. 1. Анатомия WebRTC SDP offer. Каждая строка m= открывает секцию; всё, что ниже до следующей m=, уточняет эту секцию.

Модель Offer/Answer – Идея 2002 Года, Которая До сих пор Работает

Сама модель старше WebRTC на десять лет. RFC 3264, опубликованный в июне 2002 года, ввёл простое правило: один участник сессии формирует SDP, описывающий, что он готов отправлять и принимать – это offer – и отправляет его другой стороне; та отвечает SDP, описывающим, на что она согласна – это answer – и обмен завершён (IETF, RFC 3264, An Offer/Answer Model with the Session Description Protocol, §1, June 2002). Обе стороны применяют полученные описания к своим локальным состояниям, и с этого момента может начаться передача медиа.

Два правила из RFC 3264 управляют современным WebRTC. Первое: ответ должен содержать столько же медиасекций, сколько и предложение, в том же порядке (RFC 3264, §6.1). Ответчик не может удалить секцию, но может отказаться от участия в ней, установив порт равным нулю. Второе: одновременно может существовать только один незавершённый offer – агент не имеет права создавать новый offer, пока не получил answer на предыдущий и не ответил на полученный offer (RFC 3264, §4). Именно из этого второго правила возникает состояние glare, о котором мы расскажем ниже: когда оба пира одновременно создают offer, правило нарушается случайно.

Исходный документ разрабатывался под SIP – телефонный сигнальный стандарт. WebRTC унаследовал эти правила и добавил к ним своё специфичное для браузера представление состояния.

JSEP – машина состояний на стороне браузера

Браузерная механика описана в одном документе: RFC 9429, JavaScript Session Establishment Protocol (сокращённо JSEP), опубликованном в апреле 2024 года (IETF, RFC 9429, JavaScript Session Establishment Protocol, April 2024). Он заменяет более ранний RFC 8829 от января 2021 года. JSEP точно определяет, что должны делать createOffer(), createAnswer(), setLocalDescription() и setRemoteDescription() внутри браузера, какие состояния на каждом этапе являются допустимыми и что считается ошибкой.

Правильная модель в голове такова: JSEP – это машина из пяти состояний, через которую два пира проходят одновременно, и каждый переход вызывает один вызов JavaScript. Ниже перечислены состояния с пояснением их смысла согласно канонической диаграмме из RFC 9429, §3.2:

СостояниеЧто оно означает
stableОбмен offer/answer не идёт. Соединение либо только что создано, либо завершило предыдущий раунд. Любая сторона может выпустить offer из этого состояния.
have-local-offerЛокальная сторона вызвала setLocalDescription(offer), но ещё не получила remote answer.
have-remote-offerЛокальная сторона вызвала setRemoteDescription(offer), но ещё не отправила свой answer.
have-local-pranswerЛокальная сторона отправила «provisional answer» (частичная фиксация, используется в некоторых SIP-сценариях; в чистом WebRTC редко).
have-remote-pranswerЛокальная сторона получила provisional answer от remote.

Соединение всегда начинается в stable. Первый offer переводит offerer в have-local-offer, а answerer – в have-remote-offer. Когда обе стороны обработали парный answer, они возвращаются в stable. Оттуда любая из сторон может инициировать следующий раунд – и этот раунд представляет собой ту же пятиактную последовательность, применённую к изменениям (например, включена вторая камера, добавлен шеринг экрана, сеть переключилась). Диаграмма замкнута: каждый легальный вызов либо изменяет состояние, либо оставляет его без изменений, а любой нелегальный вызов (например, createAnswer до получения offer) обязан вызвать выброс InvalidStateError.

Рис. 2. Пять состояний сигналинга, определённых в RFC 9429, §3.2. Каждый допустимый переход подписан JavaScript-вызовом, который его инициирует. Из нестабильного состояния можно выйти только путём отправки answer или выполнения rollback.

Опция rollback внизу диаграммы – это ключевой элемент, на котором строится работа Perfect Negotiation; об этом подробнее ниже.

Жизнь одного оффера шаг за шагом

Самый простой способ понять весь процесс обмена – проследить за одним offer от момента, когда приложение решает добавить трек. Ниже приведён реальный ход событий внутри браузера. Вызовы JavaScript приложения выделены моноширинным шрифтом; всё остальное – действия самого браузера:

  1. Появляется повод перезаключать договор. Приложение вызывает pc.addTrack(localCameraTrack, localStream), изменяет направление трансивера или добавляет data channel. Браузер помечает peer-connection как «требуется renegotiation» и генерирует событие negotiationneeded (W3C WebRTC, §4.4, Recommendation 13 March 2025).
  2. Приложение вызывает await pc.createOffer(). Браузер проходит по внутреннему списку трансиверов, кодек-предпочтений, ICE-учётных данных, DTLS-фингерпринтов, BUNDLE-групп, RTP-заголовочных расширений и msid, и формирует SDP-объект, описывающий всё это. На этом этапе ничего ещё не запущено: сбор ICE-кандидатов не начался, DTLS не активирован, медиа не передаётся. Браузер предлагает план; никаких обязательств пока нет (RFC 9429, §4.1.8).
  3. Приложение вызывает await pc.setLocalDescription(offer). Состояние сигнализации переходит из stable в have-local-offer. Браузер начинает собирать ICE-кандидатов и может сразу начать генерировать события icecandidate, которые приложение должно передать удалённому пиру (RFC 9429, §4.1.11; RFC 8838, §3, Trickle ICE, January 2021). DTLS-эндпоинты настроены; ключи SRTP пока не сгенерированы.
  4. Приложение отправляет offer по своему сигнальному каналу – WebSocket, long-poll HTTP, комната Matrix или любой другой способ, выбранный командой разработки. JSEP намеренно не стандартизирует, как именно передаётся offer (RFC 9429, §3.1: «addressing, retransmission, forking, and glare handling … entirely up to the application»).
  5. Удалённый пир вызывает await pc.setRemoteDescription(offer). Состояние переходит из stable в have-remote-offer. Браузер создаёт объекты RTCRtpTransceiver, зеркально отражающие медиасекции offerer, готовит декодеры для предложенных кодеков и настраивает свои ICE и DTLS-эндпоинты соответствующим образом (RFC 9429, §4.1.12).
  6. Удалённый пир вызывает await pc.createAnswer(). Браузер формирует SDP, в котором количество и порядок медиасекций точно соответствуют offer (RFC 3264, §6.1), выбирает по одному кодеку на секцию из предложенных offerer, задаёт направление каждой секции (sendrecv, sendonly, recvonly, inactive) и добавляет в документ свой DTLS-фингерпринт (RFC 9429, §4.1.9).
  7. Удалённый пир вызывает await pc.setLocalDescription(answer). Состояние переходит в stable. Обязательства зафиксированы. Ключи SRTP можно сгенерировать, как только завершится DTLS; медиа может начаться, как только ICE найдёт рабочую пару кандидатов (RFC 9429, §5.11).
  8. Удалённый пир отправляет answer обратно по сигнальному каналу.
  9. Первоначальный пир вызывает await pc.setRemoteDescription(answer). Состояние переходит в stable и на этой стороне. Ресурсы, удерживаемые во время переговоров – дополнительные ICE-компоненты, пулы кандидатов – освобождаются (RFC 9429, §5.11).
  10. Trickle ICE всё это время шёл параллельно. Обе стороны собирали и обменивались кандидатами – с шага 3 у offerer и с шага 5 у answerer. Как только одна валидная пара кандидатов проходит проверку соединения, состояние ICE переходит в connected, и медиа начинает передаваться – часто ещё до получения answer (RFC 8838, §3).

Весь обмен обычно занимает 50–300 мс от конца до конца, и основную часть времени занимает задержка сигнального канала. ICE-обнаружение и DTLS работают параллельно и добавляют ещё 100–500 мс до начала передачи медиа. В тесте в локальной сети всё укладывается в 200 мс; при пробивании корпоративного фаервола с использованием TURN-аллокации время может увеличиться до двух секунд.

Рис. 3. Полный таймлайн обмена offer/answer. SDP-блобы передаются по сигнальному каналу; ICE и DTLS работают параллельно и завершаются независимо.

Какие Поля Реально Содержит WebRTC-Offer

SDP-поля из приведённого выше примера заслуживают более пристального внимания – ведь большинство сбоев в продакшене связано с одной-единственной неверно сформулированной строкой. Ниже приведён практический набор, который должен знать каждый инженер по WebRTC; мы сгруппировали их по назначению, а не по RFC.

Строки уровня сессии – небольшой блок в верхней части документа:

  • v=0 – версия протокола. Всегда равна нулю (RFC 8866, §5.1).
  • o=<username> <sess-id> <sess-version> IN IP4 <addr> – инициатор и идентификатор сессии (RFC 8866, §5.2).
  • s=- – имя сессии. Тире означает, что имя не требуется – это соглашение WebRTC.
  • t=0 0 – тайминг. Если оба значения равны нулю, сессия считается неограниченной по времени.
  • a=group:BUNDLE 0 1 2 – BUNDLE-группа, содержащая идентификаторы медиасекций, которые будут использовать один сетевой транспорт (RFC 9143, Negotiating Media Multiplexing Using SDP, §7, февраль 2022).

Медиасекции повторяются по одному разу для каждого аудио-, видео- или data-потока:

  • m=audio 9 UDP/TLS/RTP/SAVPF 111 63 – открывает аудиосекцию поверх UDP с DTLS, SRTP и обратной связью, перечисляя номера payload-type кодеков, которые offerer готов принимать (RFC 8866, §5.14; RFC 5764, DTLS Extension to Establish Keys for SRTP, май 2010).
  • c=IN IP4 0.0.0.0 – плейсхолдер для адреса соединения. При использовании BUNDLE и ICE реальный адрес передаётся через строки candidate (RFC 8866, §5.7).
  • a=rtcp-mux – мультиплексирование RTP и RTCP на одном порту. Обязательно в современном WebRTC (RFC 5761, Multiplexing RTP Data and Control Packets on a Single Port, апрель 2010).
  • a=mid:0 – media-идентификатор, используемый в качестве ключа для BUNDLE (RFC 5888; обязателен согласно RFC 9143).
  • a=sendrecv (или sendonly, recvonly, inactive) – направление медиапотока (RFC 3264, §5.1).
  • a=rtpmap:111 opus/48000/2 – имя кодека, частота дискретизации и количество каналов для payload type. Повторяется для каждого предлагаемого кодека (RFC 8866, §6.6).
  • a=fmtp:111 minptime=10;useinbandfec=1 – параметры формата, специфичные для кодека (RFC 8866, §6.15).
  • a=rtcp-fb:111 transport-cc – типы сообщений обратной связи RTCP, поддерживаемые кодеком (RFC 4585).
  • a=ice-ufrag:F7gI и a=ice-pwd:x9cml/... – временные ICE-учётные данные, используемые для аутентификации пакетов проверки соединения (RFC 8839, SDP Offer/Answer Procedures for ICE, §5.4, январь 2021).
  • a=candidate:0 1 udp 2113929471 192.0.2.1 51772 typ host – один ICE-кандидат на каждый адрес, по которому пир доступен; таких строк может быть несколько (RFC 8839, §5.1).
  • a=fingerprint:sha-256 1A:2B:...:F9 – отпечаток SHA-256 DTLS-сертификата, который пир собирается использовать. Сигнальный канал становится точкой доверия для медиаканала (RFC 8122, март 2017).
  • a=setup:actpass – указывает, кто инициирует DTLS-рукопожатие. В offer используется actpass, в answer – active или passive (RFC 4145, §4).
  • a=msid:<stream-id> <track-id> – связывает секцию с MediaStream и MediaStreamTrack в JavaScript API (RFC 8830, январь 2021).
  • a=simulcast:send 1;2;3 – включает simulcast: отправитель будет транслировать три закодированных слоя (RFC 8853, январь 2021).
  • a=ssrc:1234567890 cname:abc... – синхронизационные источники и их канонические имена (RFC 5576).

Это рабочий набор. На практике вы также увидите дополнительные a=extmap для RTP header extensions (transport-wide congestion control, absolute send time, mid extension), дополнительные записи a=rtcp-fb (nack, nack pli, ccm fir, goog-remb) и m=application для канала SCTP-over-DTLS, если используется канал данных (RFC 8841). Полноценное предложение для production с аудио, видео и data channel занимает 70–120 строк; offer SFU, раздающий поток на 20-человечную встречу, может достигать 600 строк и 25 КБ.

Численный пример. Посчитаем количество строк в типичном SDP-офере для звонка 1:1 с аудио, видео и одним data channel, опираясь на размеры секций из примеров RFC 9429, §7.3:

БлокСтрок
Уровень сессии (v=, o=, s=, t=, BUNDLE group, ice-options:trickle)8
Audio m-секция (Opus + headers + ICE-креды + 2 кандидата + fingerprint + setup)26
Video m-секция (VP8 + VP9 + H264 + AV1 + headers + ICE-креды + 2 кандидата + fingerprint + setup)32
Data-channel m-секция (SCTP)8
Всего74

Примерно 30–40 символов на строку – получается 2,2–3,0 килобайта. Перейдите от 1:1 к SFU с 8 входящими потоками, по три simulcast-слоя в каждом, – и тот же offer раздувается до 400+ строк и 15+ килобайт. Это одна из причин, почему сигнальные каналы WebRTC передают удивительно большие сообщения.

Glare – Сбой, которому дали имя

Glare – это термин, введённый в RFC 3264 для описания ситуации, когда оба участника одновременно создают offer, не получив offer друг от друга, и правило уникальности offer из §4 нарушается сразу на обеих сторонах. Слово пришло из коммутируемой телефонии: когда два оператора одновременно захватывали один и тот же транк, линия «glared» («слепила») – и аналогия здесь точна: два endpoints одновременно стремятся использовать сигнальный канал, и ни один не знает, как действовать.

В самописном WebRTC-приложении glare – самая частая причина периодически прерывающихся двусторонних звонков. Он возникает, как только обе стороны одновременно решают перезаключить соединение в пределах RTT-окна: оба клиента видят кнопку «поделиться экраном», оба пользователя нажимают её с интервалом в 200 мс, оба браузера активируют negotiationneeded, оба приложения вызывают createOffer и setLocalDescription, оба отправляют offer в сигнальный канал – и оба приходят на другой стороне в состоянии have-local-offer, которое не позволяет корректно принять offer. По умолчанию один или оба конца выбрасывают InvalidStateError, и звонок обрывается.

У фикса есть имя: Perfect Negotiation. Паттерн описан в рекомендации W3C WebRTC (§10.7, Perfect negotiation example, 13 марта 2025) и подробно разобран на MDN. Идея заключается в том, чтобы при установлении соединения назначить каждому пиру фиксированную роль: один – polite («вежливый»), другой – impolite («невежливый»), – и предоставить вежливому пиру возможность выполнить rollback, который RFC 9429 специально ввёл в state-машину для этой цели.

Реализация короткая, её стоит привести целиком:

let makingOffer = false;
let ignoreOffer = false;
const polite = /* назначается при установке, например по тому, кто первый зашёл */;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();           // внутри вызовет createOffer
    signaling.send({ description: pc.localDescription });
  } finally {
    makingOffer = false;
  }
};

signaling.onmessage = async ({ description, candidate }) => {
  if (description) {
    const collision = description.type === "offer"
                      && (makingOffer || pc.signalingState !== "stable");

    ignoreOffer = !polite && collision;
    if (ignoreOffer) return;

    await pc.setRemoteDescription(description);    // если polite — неявный rollback
    if (description.type === "offer") {
      await pc.setLocalDescription();
      signaling.send({ description: pc.localDescription });
    }
  }
};

Всю работу выполняют два правила. Невежливый пир игнорирует входящие offer, пока свой не закрыт – его offer выигрывает по умолчанию. Вежливый пир откатывает свой offer и применяет входящий – он уступает раунд. На вежливой стороне setRemoteDescription запускает неявный откат локального offer, потому что алгоритм W3C (§4.4.1) обнаруживает коллизию и трактует её как setLocalDescription(rollback) плюс setRemoteDescription(offer). Роли симметричны: звонок никогда не зависает, не падает с исключением, а единственная плата – в том, что вежливая сторона отбрасывает свой offer и принимает чужой. После завершения раунда у вежливого пира снова активируется negotiationneeded, и его изменения отправляются на следующем круге – ровно тот же результат, что и без коллизии, просто с одним дополнительным round-trip.

Паттерн – это каноническое решение для целого класса задач, на которые раньше приходилось писать сотни строк собственной логики. Если вы пишете новый WebRTC-код в 2026 году, Perfect Negotiation – стандарт по умолчанию; всё остальное – пережиток эпохи до появления rollback.

Рис. 4. Perfect Negotiation разрешает glare, назначая одному пиру роль polite, второму – impolite. Вежливый откатывает свой offer и уступает; offer невежливого побеждает.

BUNDLE и Trickle ICE – почему WebRTC в 2026 живёт на одном порту

Два расширения модели offer/answer сформировали облик современного WebRTC и заслуживают отдельного разбора, поскольку оба связаны с SDP.

BUNDLE (RFC 9143, февраль 2022) позволяет всем медиасекциям в SDP использовать один сетевой транспорт – одну ICE-компоненту, одну DTLS-сессию и один набор UDP-портов. Строка уровня сессии a=group:BUNDLE 0 1 2 перечисляет идентификаторы участвующих секций. Без BUNDLE звонок с аудио, видео и данными открыл бы три отдельных UDP-потока, выполнил бы три ICE-проверки, три DTLS-рукопожатия и пробил бы три дыры в NAT. С BUNDLE всё передаётся по одному транспорту. Время установления соединения снижается с 2–3 секунд до субсекундного уровня даже на сложной сети; пробивание фаервола становится пропорциональным числу пиров, а не числу медиадорожек. Современные браузеры по умолчанию используют bundlePolicy: "balanced", который собирает кандидатов только для первой медиасекции каждого типа; передача bundlePolicy: "max-bundle" заставляет работать в строгом режиме single-transport – именно его и ожидают production-версии SFU.

Trickle ICE (RFC 8838, январь 2021) освобождает обмен ICE-кандидатами от привязки к циклу offer/answer. Без Trickle отправитель offer был бы вынужден ждать, пока ICE соберёт всех кандидатов – включая round-trip к STUN и аллокацию TURN, каждый из которых занимает 100–500 мс. С Trickle offer отправляется сразу, с теми кандидатами, которые локальный агент успел собрать, а остальные поступают через сигнальный канал отдельными сообщениями в течение следующей секунды или двух. Теперь машины состояний сигнализации и ICE работают независимо: звонок может находиться в состоянии have-local-offer с точки зрения сигнализации, пока приложение продолжает отправлять кандидатов, и перейти в состояние iceConnectionState=connected ещё до получения answer. Одна строка a=ice-options:trickle в SDP сообщает удалённой стороне, что offer можно принять, не дожидаясь завершения сбора кандидатов.

Вместе BUNDLE, Trickle ICE и rtcp-mux – вот что делает WebRTC-соединение в 2026 году быстрым там, где в 2018 году оно казалось медленным. Цена для приложения – минимальна: всего три небольших изменения в SDP, а выигрыш в сетевой производительности – значительный.

Шесть ошибок, которые ломают production WebRTC

За пятнадцать лет разработки WebRTC-решений одни и те же шесть ошибок в SDP снова и снова попадают в продакшн. Мы видели их во время аудита чужого кода – и каждую из них хотя бы раз писали сами. Назвать их по имени – самый дешёвый способ избежать.

Ошибка 1: забыли перезаключить договор после добавления трека. Приложение вызывает pc.addTrack(newCameraTrack) уже после установления соединения и предполагает, что новый трек начнёт работать автоматически. Но этого не произойдёт. addTrack активирует negotiationneeded; если приложение не отслеживает это событие и не запускает новый раунд обмена offer/answer, у нового трека не будет своей m=-строки и, соответственно, не будет медиа. Фикс – повесить один обработчик negotiationneeded при создании peer-connection и поручить ему управлять всеми последующими переговорами (W3C WebRTC, §4.4, March 2025).

Ошибка 2: вызывают createAnswer до setRemoteDescription. Answerer не может сформировать ответ, не увидев offer – отвечать просто не на что. RFC 9429, §4.1.9–1 чётко фиксирует требование: «setRemoteDescription MUST have been called prior to calling createAnswer». Браузер выбросит InvalidStateError, но эту ошибку легко упустить, когда сигналинг асинхронный, а createAnswer() работает в неправильной цепочке промисов.

Ошибка 3: вручную правят SDP между createOffer и setLocalDescription. Старые туториалы по WebRTC часто демонстрируют «SDP munging» – замену порядка кодеков, удаление неудобных видеоформатов, жёстко заданный лимит битрейта. RFC 9429, §5.4 прямо запрещает это: «SDP, возвращённый из createOffer или createAnswer, НЕ ДОЛЖЕН изменяться перед передачей в setLocalDescription». Изменения между setLocalDescription и отправкой в сеть допустимы только для снижения возможностей – но никогда для добавления. Делайте «munging» правильно – с помощью современных API, созданных именно для этих задач: RTCRtpTransceiver.setCodecPreferences() для порядка кодеков, RTCRtpSender.setParameters() для битрейта, addTransceiver({direction}) для направления.

Ошибка 4: неправильное использование ICE restart. Передача iceRestart: true в createOffer пересоздаёт ICE-креденшалы и заставляет обе стороны заново выполнять проверки связности; на короткое время медиа-протокол прерывается. Это необходимо, если ICE-соединение перешло в состояние failed или disconnected (например, при смене сети или мобильном handoff). Вызывать его при каждом offer – как советуют некоторые туториалы – бессмысленно и вызывает лишние 1–2 секунды паузы на каждом раунде (RFC 9429, §4.1.18).

Ошибка 5: считать, что glare редкий. Звонок 1:1 «два человека» почти никогда не вызывает glare; mesh на 4 человека, SFU-распределение при совместном использовании экрана, конференция, где модератор может отключать участников – там glare становится регулярным явлением. Строить Perfect Negotiation нужно с первого дня. Паттерн добавляет, может быть, двадцать строк кода; альтернатива – периодически разваливающиеся звонки, которые невозможно воспроизвести.

Ошибка 6: забыли, что BUNDLE-only SFU отвергают не-объединённые предложения. mediasoup, Janus, LiveKit, Jitsi Videobridge и Pion – все в 2026 году по умолчанию строго используют BUNDLE. Предложение, в котором для аудио- и видеосекций указаны отдельные ICE-креденшиалы – типичная ситуация в старом коде, переписанном из туториалов до 2020 года, – будет отклонено на стороне SFU на этапе setRemoteDescription. Исправление – RTCConfiguration.bundlePolicy: "max-bundle" (которое в RFC 9429, §1.3 уже фигурирует под новым именем "must-bundle" для следующего раунда правок), чтобы браузер никогда не создавал не-объединённые предложения.

Одна ошибка, которую нужно назвать отдельно

«Подводный камень – никогда не вырезайте ICE-кандидатов из SDP, чтобы скрыть публичный IP. Это регулярно встречается в код-ревью: кто-то пытается защитить пользовательский IP, удаляя a=candidate:-строки с публичными IPv4-адресами перед вызовом setLocalDescription. Это бесполезно. ICE-агент в браузере всё равно соберёт этих кандидатов локально, всё равно зажжёт события icecandidate, всё равно отправит их через приложенческий сигнальный канал – просто в самом SDP их не будет. Чтобы действительно скрыть публичный адрес, выставьте RTCConfiguration.iceTransportPolicy: "relay", и тогда весь поток принудительно поедет через TURN-сервер, а host и server-reflexive кандидаты вообще не будут собираться (RFC 9429, §3.5.1; W3C WebRTC, §4.2.1.4). Счёт за TURN вырастет; приватность сохранится.»

Где Здесь Фора Софт

Мы разрабатываем WebRTC-решения с тех пор, как стандарт находился на стадии IETF-драфта в 2012 году – в видеоконференциях, телемедицинских консультациях, e-learning-классах, OTT-прямых трансляциях с элементами шопинга, системах видеонаблюдения, AR/VR-коллаборации. За двести с лишним реализованных проектов слой обработки offer/answer стал для нас местом, где мы провели больше всего времени в отладчике и проектировании, потому что именно здесь должны встретиться намерения протокола и приложения, не нарушая ни верхнего, ни нижнего уровня. Команды, приходящие к нам на аудит WebRTC-стека, почти всегда содержат хотя бы одну из шести типичных ошибок в коде; мы обычно находим и исправляем их за несколько дней. Если вы разрабатываете продукт реального времени, и звонки обрываются без возможности воспроизвести проблему – обмен SDP – это первое место, куда стоит заглянуть.

Ключевые выводы

  • Модель offer/answer описана в RFC 3264 (2002); JSEP (RFC 9429, апрель 2024) определяет её реализацию в браузерах.
  • WebRTC-соединение проходит пять состояний сигнализации; только stable является устойчивым.
  • Offer – это конфигурация, а не действие: передача медиа начинается только после того, как обе стороны перешли в состояние stable.
  • Trickle ICE и BUNDLE позволяют современному WebRTC устанавливать соединение за доли секунды; используйте max-bundle сразу.
  • Проблема Glare существует; Perfect Negotiation (W3C WebRTC §10.7) – каноническое решение, реализуемое в двадцати строках JavaScript.
  • Не изменяйте SDP между состояниями createOffer и setLocalDescription; используйте API transceiver/sender.

Что читать дальше

Строите такую систему?

Подберём параметры кодирования под ваш контент и посчитаем стоимость доставки до старта разработки.