🚀

UnityWebRequestで進捗がない場合にタイムアウトを行う

2025/02/21に公開

UnityWebRequestのタイムアウト処理の課題

UnityWebRequestのtimeoutプロパティは通信全体のタイムアウトであるため短い時間(例えば1秒)を設定すると、すぐにタイムアウトが発生してしまうので長めに設定するしかない。
ところが、長い時間を設定すると通信が途切れているのに設定した時間が過ぎるまでユーザーにタイムアウトを通知できないなどの問題がある。
これを解決するためには、timeoutには長めの時間を設定しておき、通信の進捗を監視して自前で任意の時間でタイムアウトさせる必要がある。

参考にした記事

参考にした記事はこちら。ほぼそのままなんだけど元記事ではダウンロードの進捗だけを監視している。しかし、POSTとPUTの場合にはアップロードの進捗も監視する必要があるので、本記事ではそこをカバーした内容となっている。

https://zenn.dev/nekomimi_daimao/articles/d87d1bb7a2c8b1

POST、PUTでアップロードの進捗を監視する理由

POSTやPUTでは画像のアップロードなど送信を先に行い、送信が完了してからサーバーからレスポンスを受け取る。
送信時はrequest.uploadedBytesが加算され、受信時はrequest.donwloadedBytesが加算されるため、POSTやPUTでは送信が完了するまではrequest.donwloadedBytesが0のままである。
このため、ダウンロード進捗だけを見てタイムアウト判定するとちょっとサイズが大きい画像のアップロードなどをすると通信自体は発生しているのに誤ってタイムアウト判定が起きてしまう。
そのため、POSTとPUTではアップロードの進捗も監視する必要がある。

タイムアウト処理のコード

UniTask、UniRxの使用を前提とする。

using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UniRx;
using UnityEngine.Networking;
using UnityEngine;

public class UnityWebRequestHelper
{
    /// <summary>
    /// 通信なしタイムアウトを設定してリクエストを送信します
    /// </summary>
    /// <param name="request">リクエスト</param>
    /// <param name="cancellationToken">キャンセルトークン</param>
    /// <param name="noProgressTimeout">通信なしタイムアウト時間(秒)。
    /// この時間ごとに通信の進捗をチェックします。
    /// 短すぎるとタイムアウトが頻繁に起きてしまうので注意してください。
    /// </param>
    /// <exception cref="TimeoutException">通信なしタイムアウトが発生した場合</exception>
    /// <returns></returns>
    public static async UniTask SendRequestWithNoProgressTimeout(UnityWebRequest request, CancellationToken cancellationToken, float noProgressTimeout)
    {
        using var compositeDisposable = new CompositeDisposable();
        bool isNoProgressTimeout = false;

        try
        {
            // 一定時間データが受信できない場合、通信なしタイムアウトと判定してリクエストを中断する
            Observable.Interval(TimeSpan.FromSeconds(noProgressTimeout))
                .Select(_ => new
                {
                    DownloadedBytes = request.downloadedBytes,
                    UploadedBytes = request.uploadedBytes
                })
                .Pairwise() // 前回と今回の進捗を取得
                .Where(pair =>
                {
                    // ダウンロード進捗は常にチェック
                    bool isDownloadedProgress = pair.Previous.DownloadedBytes != pair.Current.DownloadedBytes;

                    // アップロード進捗はPOST/PUTの場合のみチェック。それ以外は常にtrue
                    bool isUploadedProgress = true;
                    if (request.method == UnityWebRequest.kHttpVerbPOST || request.method == UnityWebRequest.kHttpVerbPUT)
                    {
                        isUploadedProgress = pair.Previous.UploadedBytes != pair.Current.UploadedBytes;
                    }

                    // どちらも進捗がなければタイムアウトと判定する
                    return !isDownloadedProgress && !isUploadedProgress;
                })
                .Subscribe(pair =>
                {
                    isNoProgressTimeout = true;
                    request.Abort();
                })
                .AddTo(compositeDisposable);

            await request.SendWebRequest().WithCancellation(cancellationToken);
        }
        catch (Exception e)
        {
            // 通信なしタイムアウトが発生した場合、ログを出力して例外をスローする
            if (isNoProgressTimeout)
            {
                Debug.LogError($"通信なしタイムアウトが発生しました: method={request.method}, uri={request.uri}, error={e}");
                throw new TimeoutException("通信がタイムアウトしました。");
            }
            else
            {
                // それ以外の例外はそのままスローする
                throw;
            }
        }
    }
}

使い方

このコードを使用する場合は、UnityWebRequestのtimeoutは通信全体の時間のタイムアウトなので長めに設定しておく必要がある。
noProgressTimeoutの時間ごとにタイムアウト判定をしているので、それよりも長い時間を設定しておく。
サンプルコードはRESTful APIを叩くイメージにした。

var request = ... // UnityWebRequestを生成
var cancellationToken = ... // キャンセルトークン
var noProgressTimeout = 5; // 5秒ごとにタイムアウト判定
request.timeout = 60 * 5; // 通信全体のタイムアウト時間(秒)

try
{
    await UnityWebRequestHelper.SendRequestWithNoProgressTimeout(request, cancellationToken);

    // 200番台の成功ステータス
    // API側は失敗時はHTTPステータスを200以外で返すようにする前提
    var resonseBody = request.downloadHandler.text != null ? request.downloadHandler.text : string.Empty;

    // JSONをパースするなどの処理
}
catch(UnityWebRequestException e)
{
    // 200番台以外の失敗ステータスとかコネクションエラーなど
}
catch(TimeoutException e)
{
    // タイムアウト。ダイアログ表示など
}

Discussion