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

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

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 На Самом Деле

Session Description Protocol, сокращённо 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-offer с одной аудиодорожкой и самыми важными полями. Читатель, который никогда не открывал 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. Первое: answer обязан содержать столько же медиасекций, сколько offer, в том же порядке (RFC 3264, §6.1). Answerer не может выкинуть секцию, но может отказаться участвовать в ней, выставив порт в ноль. Второе: одновременно может быть только один незавершённый 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 Шаг За Шагом

Самый чистый способ понять обмен целиком – провести один offer от момента, когда приложение решило добавить трек. Список ниже – это что реально происходит внутри браузера. JS-вызовы приложения подсвечены моноширинным; остальное – работа браузера:

  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 fingerprint, BUNDLE-группам, RTP header extensions и 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 endpoints настроены; ключи 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 endpoints соответствующим образом (RFC 9429, §4.1.12).
  6. Удалённый пир вызывает await pc.createAnswer(). Браузер строит SDP, у которого число и порядок медиасекций ровно совпадают с offer (RFC 3264, §6.1), выбирает по одному кодеку на секцию из тех, что предложил offerer, выставляет направление каждой секции (sendrecv, sendonly, recvonly, inactive) и подписывает в документ свой DTLS certificate fingerprint (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. Как только одна валидная пара кандидатов проходит connectivity check, состояние ICE переходит в connected и медиа идёт – часто ещё до того, как пришёл answer (RFC 8838, §3).

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

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

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

SDP-поля из примера выше заслуживают более внимательного взгляда – потому что большинство production-сбоев привязано к ровно одной неверно сформулированной строке. Список ниже – это рабочий набор, который должен узнавать каждый 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, February 2022).

Медиасекции повторяются по разу на каждый аудио/видео/data поток:

  • m=audio 9 UDP/TLS/RTP/SAVPF 111 63 – открывает аудиосекцию поверх UDP-with-DTLS-SRTP-and-feedback, перечисляя payload-type-номера тех кодеков, которые offerer принимает (RFC 8866, §5.14; RFC 5764, DTLS Extension to Establish Keys for SRTP, May 2010).
  • c=IN IP4 0.0.0.0 – placeholder для адреса соединения. С 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, April 2010).
  • a=mid:0 – media-id, используется как ключ BUNDLE (RFC 5888; обязателен по RFC 9143).
  • a=sendrecv (или sendonly, recvonly, inactive) – направление медиапотока (RFC 3264, §5.1).
  • a=rtpmap:111 opus/48000/2 – имя кодека, sample rate и число каналов для 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-креды, которыми аутентифицируются connectivity-check пакеты (RFC 8839, SDP Offer/Answer Procedures for ICE, §5.4, January 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 fingerprint того DTLS-сертификата, который пир собирается предъявить. Сигнальный канал становится якорем доверия для медиаканала (RFC 8122, March 2017).
  • a=setup:actpass – кто инициирует DTLS-рукопожатие. actpass в offer, active или passive в answer (RFC 4145, §4).
  • a=msid:<stream-id> <track-id> – привязывает секцию к MediaStream и MediaStreamTrack в JavaScript API (RFC 8830, January 2021).
  • a=simulcast:send 1;2;3 – открывает simulcast: отправитель будет публиковать три закодированных слоя (RFC 8853, January 2021).
  • a=ssrc:1234567890 cname:abc... – synchronization sources и их canonical name (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-offer с аудио, видео и data channel – это 70–120 строк; offer SFU, который раздаёт fan-out на 20-человечную встречу, может дойти до 600 строк и 25 КБ.

Численный пример. Посчитаем строки в типичном SDP-offer для звонка 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 Recommendation (§10.7, Perfect negotiation example, 13 March 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 запускает неявный rollback локального offer, потому что алгоритм W3C (§4.4.1) ловит коллизию и трактует её как setLocalDescription(rollback) плюс setRemoteDescription(offer). Роли симметричны: звонок никогда не зависает, не падает с исключением, и единственная цена – что вежливая сторона выбрасывает свой offer и принимает чужой. После завершения раунда у вежливого пира снова зажигается negotiationneeded, и его изменения уходят на следующем круге – ровно тот же результат, что без коллизии, просто плюс один round-trip.

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

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

BUNDLE И Trickle ICE – Почему WebRTC В 2026 Живёт На Одном Порту

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

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

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

Вместе 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 и дать ему гонять любые последующие renegotiations (W3C WebRTC, §4.4, March 2025).

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

Ошибка 3: вручную правят SDP между createOffer и setLocalDescription. Старые туториалы по WebRTC любят показывать «SDP munging» – strring-replace порядка кодеков, удаление неудобных видеоформатов, hard-coded потолок битрейта. RFC 9429, §5.4 запрещает это прямо: «The SDP returned from createOffer or createAnswer MUST NOT be changed before passing it to setLocalDescription». Модификации между setLocalDescription и отправкой в сеть разрешены только для снижения возможностей, никогда для добавления. Делайте «munging» правильно – через современные API, которые для этого и существуют: RTCRtpTransceiver.setCodecPreferences() для порядка кодеков, RTCRtpSender.setParameters() для битрейта, addTransceiver({direction}) для направления.

Ошибка 4: неправильное использование ICE restart. Передача iceRestart: true в createOffer пересоздаёт ICE-креды и заставляет обе стороны заново гонять connectivity checks; на короткое время медиа прерывается. Это нужно для случая, когда 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 отвергают не-bundled offer. mediasoup, Janus, LiveKit, Jitsi Videobridge и Pion – все в 2026 году по умолчанию строго BUNDLE. Offer, в котором отдельные ICE-креды для аудио- и видеосекций – типичная картина в старом коде, переписанном из туториалов до 2020 года, – будет отвергнут на стороне SFU на этапе setRemoteDescription. Фикс – RTCConfiguration.bundlePolicy: "max-bundle" (которое в RFC 9429, §1.3 уже фигурирует под новым именем "must-bundle" для следующего раунда правок), чтобы браузер никогда не строил не-bundled offer.

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

«Подводный камень – никогда не вырезайте 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-live-shopping, системах видеонаблюдения, AR/VR-коллаборации. Через двести с лишним сданных проектов слой состояний offer/answer – это место, где мы провели больше всего часов в отладчике и больше всего времени в проектировании, потому что именно здесь намерения протокола и намерения приложения должны встретиться, не протекая ни вверх, ни вниз. Команды, которые приходят к нам на аудит WebRTC-стека, почти всегда несут хотя бы одну из шести ошибок выше где-то в коде; мы обычно находим и чиним их за несколько дней. Если вы строите real-time-продукт и звонки рвутся так, что не воспроизводится, обмен SDP – правильное место для первой проверки.

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

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

Что Читать Дальше

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

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