MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第10回】切断・再接続とエラーハンドリング

    前回の第9回:MediaRecorder で録画する前に考えることで、ブラウザ内で完結する録画を作りました。今回は、実際の運用で必ず起きる「通話が切れた」への対応です。

    「社内の検証では問題なかったのに、本番で『Wi-Fi が切り替わったら戻らない』『サーバーを更新したら全員の通話が落ちた』と言われた。何を作り込めばいいの?」

    結論から言うと、切断は「サーバーとの接続」と「相手との接続(経路)」の2つに分けて、それぞれに自動復帰の仕組みを入れます。サーバーとの接続(WebSocket)は、待ち時間を倍々に延ばしながら再接続します。相手との経路は、WebRTC の ICE restart(経路の探し直し)で復旧させます。そして、今どの状態なのかを利用者が分かる言葉で表示します。

    目次

    今回作るもの:自動で戻る通話と、分かりやすい状態表示

    • シグナリング(WebSocket)が切れたら、1秒・2秒・4秒…と間隔を延ばしながら自動で再接続し、同じルームに入り直す
    • 8回失敗したらあきらめて「再接続する」ボタンを出す。オフラインの間は待ち、オンラインに戻ったらすぐ再接続する
    • 相手との経路が failed(失敗)になったら、restartIce() で経路を探し直す
    • 相手が退出した・切れたことを表示する
    • 状態表示を ConnectionStatus コンポーネントにまとめ、利用者向けの言葉に統一する

    追加・変更するファイル

    ファイル役割
    code/app/composables/useSignaling.ts指数バックオフによる再接続、online イベント、手動の再試行
    code/app/composables/usePeerConnection.tsICE restart、再入室時のつなぎ直し
    code/app/components/ConnectionStatus.vue接続状態の表示(新規)
    code/app/pages/room/[id].vue上記の組み込み、再接続時の TURN 設定の取り直し

    仕組み:「何が切れたか」で対処が違う

    ビデオ通話で「切れた」と言われる状態は、原因によって3つに分けられます。

    切れたもの起きる例今回の対処
    端末のネットワークWi-Fi が切れた、機内モードオフラインと表示し、戻ったらすぐ再接続
    サーバーとの接続(WebSocket)サーバーの再起動・デプロイ、プロキシのタイムアウト指数バックオフで自動再接続し、同じルームに入り直す
    相手との経路(ICE)回線の切り替え、TURN サーバーの再起動、NAT の変化ICE restart で経路を探し直す

    ここで大事なのは、映像・音声は P2P で流れているので、サーバーとの接続が切れても通話はしばらく続くという点です。実際に検証でも、サーバーを止めている間、相手の映像は届き続けていました。逆に、サーバーとつながっていても相手との経路が切れれば、映像は止まります。どちらが切れているかを分けて扱う必要があります。

    実装1:WebSocket を指数バックオフで再接続する

    code/app/composables/useSignaling.ts(追加・変更部分)

    /**
     * idle=未接続 / connecting=接続中 / open=接続済み / reconnecting=切れたので再接続待ち(第10回)
     * failed=再接続をあきらめた(第10回) / closed=自分で切断した
     */
    export type SignalingStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'failed' | 'closed'
    
    /** 第10回: 再接続の間隔(1秒, 2秒, 4秒…最大30秒)と、あきらめるまでの回数 */
    const RETRY_BASE_MS = 1000
    const RETRY_MAX_MS = 30_000
    const MAX_RETRIES = 8
        socket.addEventListener('open', () => {
          status.value = 'open'
          retryCount.value = 0
          // 再接続の場合も、同じルームに入り直す(サーバーから見ると新しい参加者になる)
          if (targetRoomId) send({ type: 'join', roomId: targetRoomId })
        })
        // 接続できなかった場合(サーバー停止・426 等)も、つながった後に切れた場合も close が来る
        socket.addEventListener('close', () => {
          if (ws !== socket) return // 新しい接続に切り替わった後の古い close は無視
          ws = null
          resetRoom()
          if (targetRoomId) scheduleReconnect()
          else status.value = 'closed'
        })
      }
    
      /** 第10回: 待ち時間を倍々に増やしながら再接続する(指数バックオフ) */
      function scheduleReconnect() {
        if (retryCount.value >= MAX_RETRIES) {
          status.value = 'failed'
          return
        }
        status.value = 'reconnecting'
        // オフラインの間は試しても無駄なので、online イベントを待つ
        if (!navigator.onLine) return
        const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** retryCount.value)
        // 全員が同時に再接続してサーバーに集中しないよう、待ち時間を 50〜100% の範囲でばらつかせる
        const jittered = delay * (0.5 + Math.random() * 0.5)
        retryCount.value++
        retryTimer = setTimeout(open, jittered)
      }

    ポイントは次のとおりです。

    • 「自分で切った」と「切れた」を区別する:targetRoomId(入っているべきルーム)が残っていれば、意図しない切断なので再接続します。退出ボタンや定員オーバーで disconnect() した場合は null にして、再接続しません。
    • 指数バックオフ+ばらつき(ジッター):サーバーを再起動すると、全員の接続が同時に切れます。全員が同じ間隔で再接続すると、復帰直後のサーバーに接続が集中します。間隔を倍々に延ばし、さらにランダムにずらすことで、負荷を分散します。
    • あきらめる条件を決める:無限に再接続し続けると、利用者は何が起きているか分かりません。8回(合計でおよそ1〜2分)失敗したら failed にして、「再接続する」ボタンを出します。
    • online イベントで即再接続:オフラインから戻ったときは、待ち時間を待たずにすぐ再接続します。

    これで、第6回で「WebSocket が失敗したとき(426 など)画面にエラーが出ない」と書いた問題も解消します。つながらない場合も close イベントが来るので、再接続を試み、最後は「サーバーに接続できませんでした」と表示されます。

    再接続したら、相手とはつなぎ直す

    再接続後のサーバーから見ると、入り直したブラウザは新しい ID の別の参加者です(サーバーのメモリも再起動で消えています)。そこで今回は、入り直したら相手との接続も作り直すことにしました。

    code/app/composables/usePeerConnection.ts(変更部分)

          case 'joined':
            // 先に誰かいたら、その人とつなぐ。後から入った自分は polite(譲る側)
            // 第10回: 再接続で入り直した場合、自分の ID が変わり以前の接続は使えないので、つなぎ直す
            if (msg.peers[0]) connect(msg.peers[0], true)
            else close()
            break
          case 'peer-joined':
            // 相手が後から入ってきた。先にいた自分は impolite
            // 第10回: 相手が再接続して新しい ID で入り直した場合も、古い接続を捨ててつなぎ直す
            if (msg.peerId !== remotePeerId.value) connect(msg.peerId, false)
            break

    この方式だと、サーバーが復帰して両者が入り直した時点で、映像が一瞬途切れてつながり直します。サーバーが止まっている間は古い接続のまま映像が流れ続けるので、利用者から見ると「数秒止まって戻る」程度です。ただし、録画(第9回)は相手の映像が切り替わるところで止まって保存され、チャット(第8回)の表示も新しい接続でリセットされます。

    完全に途切れさせないには、再接続したブラウザを「前と同じ参加者」としてサーバーが認識できる仕組み(再接続用の秘密のトークンと、切断後しばらく参加者を残しておく猶予時間など)が必要です。設計とテストの手間が増えるため、今回は採用していません。

    実装2:ICE restart で経路を探し直す

    相手との経路が切れた場合は、restartIce() を使います。MDN によると、restartIce を呼ぶと negotiationneeded イベントが発生し、新しい認証情報で候補(経路の候補)を集め直す offer が作られます。その間も、既存の送受信は続きます。

    📰 出典:MDN「RTCPeerConnection: restartIce() method」

    code/app/composables/usePeerConnection.ts(connect() 内の追加部分)

        // 第10回: 経路が切れて復旧できない(failed)ときは、ICE restart で経路を探し直す。
        // restartIce() を呼ぶと negotiationneeded が発生し、新しい offer(ICE の認証情報を作り直したもの)が送られる。
        // 両側が同時に行うと offer が衝突するため、まず後から入った側(polite)が行い、
        // 先にいた側は5秒たっても failed のままなら行う。
        // 検証した Chromium では iceConnectionState が disconnected のまま connectionState だけが failed になったため、両方を見る
        const isFailed = () => conn.iceConnectionState === 'failed' || conn.connectionState === 'failed'
        const restartIfFailed = () => {
          clearTimeout(restartTimer)
          if (!isFailed()) return
          if (polite) conn.restartIce()
          else restartTimer = setTimeout(() => {
            if (pc === conn && isFailed()) conn.restartIce()
          }, 5000)
        }
        conn.addEventListener('iceconnectionstatechange', () => {
          iceState.value = conn.iceConnectionState
          restartIfFailed()
        })

    MDN の例は iceConnectionState === 'failed' をきっかけにしています。ところが筆者の検証環境(Chromium)では、TURN サーバーを止めたとき、iceConnectionState は disconnected のまま、connectionState だけが failed になりました。そのため、connectionstatechange からも同じ判定を呼んでいます。

    両側が同時に restartIce すると offer が衝突します。第3回の Perfect negotiation で衝突は処理できますが、第4回で衝突時に接続が止まる現象を見ているため、ここでも「後から入った側が先に行う」ルールにそろえました。

    実装3:状態を利用者の言葉で表示する

    これまでルームページに直接書いていた状態表示を、ConnectionStatus コンポーネントにまとめました。優先順位は「端末のネットワーク → サーバーとの接続 → 相手との接続」です。

    状態表示する文言
    オフラインインターネットに接続されていません。接続が戻ると自動で再接続します。
    サーバー再接続中サーバーとの接続が切れました。再接続しています…(n回目)
    サーバー再接続をあきらめたサーバーに接続できませんでした。ネットワークを確認して「再接続する」を押してください。(ボタン付き)
    相手が退出・切断相手が退出しました(または接続が切れました)。相手の再入室を待っています
    経路が不安定(disconnected)通信が不安定です。回復を待っています…
    経路が失敗(failed)相手との接続が切れました。経路を探し直しています。回復しない場合は、いったん退出して入り直してください。

    code/app/components/ConnectionStatus.vue(script の抜粋)

    // 優先順位: 端末のネットワーク → サーバーとの接続 → 相手との接続
    const view = computed<View>(() => {
      if (!props.online) {
        return { level: 'error', text: 'インターネットに接続されていません。接続が戻ると自動で再接続します。' }
      }
      switch (props.signalingStatus) {
        case 'connecting':
          return { level: 'info', text: 'サーバーに接続しています…' }
        case 'reconnecting':
          return { level: 'warn', text: `サーバーとの接続が切れました。再接続しています…(${props.retryCount}回目)` }
        case 'failed':
          return { level: 'error', text: 'サーバーに接続できませんでした。ネットワークを確認して「再接続する」を押してください。' }
      }
      // …(相手との接続の状態ごとの文言。上の表のとおり)
    })

    ルームページでは、再接続中も通話画面のままにするため、「入室済みか」の判定を status === 'open' から「idle・closed 以外」に変えました。また、再接続したら相手とつなぎ直すので、有効期限付きの TURN 認証情報(第6回)を取り直しておきます。

    code/app/pages/room/[id].vue(追加部分)

    // 第10回: 再接続中も通話画面のままにする(idle / closed の時だけ入室前の画面を出す)
    const joined = computed(() => !['idle', 'closed'].includes(signaling.status.value))
    
    // 第10回: 再接続した後は相手とつなぎ直すので、TURN の認証情報(有効期限つき)を取り直しておく。
    // 取得に失敗しても(サーバー停止中など)、前の設定のまま再接続を続ける
    watch(signaling.status, (status, prev) => {
      if (status === 'open' && (prev === 'reconnecting' || prev === 'failed')) void fetchIceConfig().catch(() => {})
    })

    動作確認の方法

    1. 2つのウィンドウで通話中に、npm run dev を止めて数秒後に起動し直す → 「再接続しています」の後、自動でつながり直すこと
    2. 開発者ツールの Network でオフラインにする → 「インターネットに接続されていません」と表示され、戻すと表示が戻ること
    3. 相手のタブを閉じる → 「相手が退出しました」と表示されること
    4. サーバーを止めたまま待つ → 最後に「再接続する」ボタンが出て、サーバー起動後に押すとつながること
    5. TURN 経由の設定(第6回の NUXT_ICE_TRANSPORT_POLICY=relay)で TURN サーバーを再起動する → ICE restart で通話が戻ること

    筆者の環境では、Playwright で Chromium を操作して次の点を確認しました。TURN は第6回と同じく Docker の coturn を使っています。

    確認したこと結果
    通話中にサーバーを約3秒停止→起動停止中も相手の映像は届き続けた。起動後、両者が自動で再入室し、停止からおよそ6秒で再び「接続しました」。チャットも送受信できた
    WebSocket が常に失敗する状態1〜30秒の間隔で8回再試行し、約100秒後に「サーバーに接続できませんでした」と「再接続する」。ボタンで接続できた
    オフライン化(Playwright の setOffline)オフラインの表示が出て、戻すと「接続しました」に戻った
    相手がタブを閉じる「相手が退出しました」と表示。チャット欄は無効
    TURN 経由の通話中に coturn を再起動(中継の割り当てが消える)約19秒後に polite 側が restartIce し、新しい経路で接続が回復
    同じ条件で restartIce を無効にした場合1分以上たっても回復せず(ICE restart が回復の決め手であることを確認)
    TURN を一時停止(約30秒)→ 再開停止中に両側が restartIce(先にいた側は5秒後)。再開後3秒ほどで回復
    定員オーバー3人目は入室前の画面に理由を表示し、再接続はしない

    一方で、Playwright のオフライン化では WebSocket 自体は切れず、「実際に Wi-Fi が切れたときに WebSocket の切断に気付くまでの時間」は確認できていません。実機での Wi-Fi とモバイル回線の切り替え、スリープからの復帰、Firefox・Safari・スマホでの挙動も未確認です。また、接続できないまま始まった場合も「接続が切れました」と表示される点は、文言の改善余地として残っています。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • 切断に気付くのが遅いことがある:回線が突然切れると、WebSocket の close イベントがすぐには来ない場合があります。本番では、一定間隔でメッセージを送り合って応答が無ければ切断とみなす仕組み(ハートビート)を入れることが多いです。今回は実装していません。
    • TURN の認証情報の期限:ICE restart は最初に渡した TURN の認証情報を使い続けます。長時間の通話で期限(第6回では1時間)を過ぎた後に経路を探し直す場合は、setConfiguration() で新しい認証情報を渡す必要があります。
    • 再接続の嵐に注意:サーバー側の不具合で接続直後に切れる状態になると、全員が再接続を繰り返します。指数バックオフと上限回数は、サーバーを守る意味もあります。
    • エラーの詳細は利用者に見せない:画面には「何が起きていて、利用者は何をすればよいか」だけを出し、IP アドレスや内部のエラー内容はデバッグ用のログにとどめます。

    発注者向けメモ:「つながる」と「切れても戻る」は別の工数

    正常系(つながる)が動いても、切断からの復帰は別の作り込みとテストが必要です。どこまで対応するかで工数が変わるため、要件として決めておくのがおすすめです。

    • ☐ 想定する切断のパターン(Wi-Fi とモバイル回線の切り替え、スリープ復帰、サーバーのデプロイ)が要件に書かれているか
    • ☐ サーバーの更新(デプロイ)時に、通話中の利用者をどう扱うか(数秒止まってよいか、夜間に限るか)
    • ☐ 再接続中・失敗時に利用者に何を表示し、何をしてもらうかの文言が決まっているか
    • ☐ テスト観点に「回線の切り替え」「サーバー再起動」「TURN の障害」が入っているか
    • ☐ 録画中・チャット中に切断が起きた場合の扱い(どこまで保存されるか)を確認したか

    開発会社への質問例:

    • 「通話中に Wi-Fi からモバイル回線に切り替わったら、どうなりますか?実機でテストしますか?」
    • 「サーバーを更新したとき、通話中の人の映像は途切れますか?何秒くらいで戻りますか?」
    • 「再接続に失敗したとき、利用者の画面には何と表示されますか?」
    • 「切断や再接続の回数を、運用中に把握できるようにしますか?」

    まとめと次回予告

    • 「切れた」は、端末のネットワーク・サーバーとの接続・相手との経路の3つに分けて対処する
    • WebSocket は指数バックオフ+ジッターで再接続し、上限を超えたら利用者に再試行を促す
    • 相手との経路は restartIce で探し直す。検証した Chromium では connectionState の failed も見る必要があった
    • 今回は再接続時に相手とつなぎ直す簡単な方式。完全に途切れさせないには、再接続用のトークンなど追加の設計が必要
    • 回線切り替えなどの異常系は、正常系とは別の工数とテスト観点として見積もる

    次回(第11回)は連載の最終回、「少人数通話へ:メッシュ実装と SFU という選択肢」です。1対1から3〜4人の通話に広げ、人数が増えたときの限界と、SFU という選択肢を整理します。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次