C# で実装する PlayFab API のリトライ処理

オンラインゲームのバックエンドとして PlayFab を利用している場合の、API 呼び出しのリトライ処理について説明します。

## リトライの必要性

クラウドベースの API 呼び出しでは、ネットワーク障害や一時的なサーバーエラーが発生することがあります。

一時的な問題において、リトライを行うことで自動的に回復することもできます。

Microsoft は、クラウドベースのアプリケーションやサービスの設計・構築におけるベストプラクティスをまとめた「クラウド設計パターン」の中で、「再試行パターン」を紹介しています。このパターンは、システムの安定性を向上させる有効な手法として推奨されています。

https://learn.microsoft.com/ja-jp/azure/architecture/patterns/retry



## リトライの対象

リトライは、リトライは一時的な問題に限り有効です。

リクエストに問題がある場合、リトライを行っても意味がないだけでなく無駄に負荷をかけることになります。


PlayFab の API は、エラーが発生した際にエラーコードを返却します。

「グローバル API メソッドのエラー コード」のページにて「再試行が安全なコード」が挙げられているます。

https://learn.microsoft.com/ja-jp/gaming/playfab/api-references/global-api-method-error-codes


次のエラーコードであればリトライを行います。

> - APIClientRequestRateLimitExceeded (1199): 多くの呼び出しが一挙に実行されていることを示します。
> - APIConcurrentRequestLimitExceeded (1342): 同時呼び出しが多すぎることを示します。
> - ConcurrentEditError (1133): 通常、多すぎる呼び出しが同時に実行されているか、非常に速く連続して実行されていることを示します。
> - DataUpdateRateExceeded (1287): 通常、多すぎる呼び出しが同時に実行されているか、非常に速く連続して実行されていることを示します。
> - DownstreamServiceUnavailable (1127): PlayFab またはサードパーティのサービスに一時的な問題が発生していることを示します。
> - ServiceUnavailable (1123): PlayFab に一時的な問題が発生しているか、クライアントがあまりにも多くの API 呼び出しを短い間隔で行っていることを示します。 このリクエストを再試行する場合は、指数バックオフ戦略を適切に使用することが重要です。


それに加え以下の HTTP ステータスコードが返ってきた場合は、一時的なエラーの可能性が高いためリトライすることとします。

- 500: Internal Server Error
- 502: Bad Gateway
- 503: Service Unavailable



## リトライ間隔

一時的なエラーの場合でも、エラー発生時に過剰なリトライを行うと、システムにさらなる負荷をかけ、問題を悪化させる可能性があります。そのため、リトライ間隔の設定には指数バックオフ戦略が採用されます。

指数バックオフ戦、リトライするたびに間隔をだんだん増やしていくリトライ戦略です。
さらに、同時にタイミングで多くのクライアントからリクエストが行かないようにジッター(ゆらぎ)を加えることも良く行われます。

https://en.wikipedia.org/wiki/Exponential_backoff


ただし、PlayFab で API 呼び出しのスロットリングが発生した場合は、スロットリングが解除されるまでの待機時間が、返却されたエラーの RetryAfterSeconds プロパティに設定されています。

https://learn.microsoft.com/ja-jp/gaming/playfab/features/throttling/best-practices

RetryAfterSeconds プロパティが設定されている場合は、その時間を優先して待機することとします。


## リトライ回数

ユーザーがゲームをプレイしているゲーム本体からの API 呼び出しを想定しているため、リトライ回数および待ち時間に上限を設けることとします。

## 実装

Unity で PlayFab API 呼び出しをリトライ処理付きで実行するためのユーティリティクラスです。

```cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using PlayFab;
using PlayFab.SharedModels;
using PlayFab.Json;

/// <summary>
/// PlayFab API 呼び出しのリトライ処理を行うユーティリティクラスです。
/// </summary>
public static class PlayFabRetryHandler
{
    /// <summary>
    /// リトライ可能なエラーかどうかを判定します。
    /// </summary>
    /// <param name="error"><seealso cref="PlayFabError"/> を指定します。</param>
    /// <returns>リトライ可能なら true、それ以外は false を返します。</returns>
    private static bool IsRetryableError(PlayFabError error)
    {
        // PlayFabErrorCode ベースの判定
        switch (error.Error)
        {
            case PlayFabErrorCode.APIClientRequestRateLimitExceeded:
            case PlayFabErrorCode.APIConcurrentRequestLimitExceeded:
            case PlayFabErrorCode.ConcurrentEditError:
            case PlayFabErrorCode.DataUpdateRateExceeded:
            case PlayFabErrorCode.DownstreamServiceUnavailable:
            case PlayFabErrorCode.ServiceUnavailable:
                return true;
        }

        // HTTPステータスコードベースの判定
        return IsRetryableHttpStatusCode(error.HttpCode);
    }

    /// <summary>
    /// HTTPステータスコードがリトライ可能かどうかを判定します。
    /// </summary>
    /// <param name="httpCode">HTTPステータスコード</param>
    /// <returns>リトライ可能なら true、それ以外は false を返します。</returns>
    public static bool IsRetryableHttpStatusCode(int httpCode)
    {
        return httpCode == 500 || httpCode == 502 || httpCode == 503 || httpCode == 429;
    }

    /// <summary>
    /// PlayFab API呼び出しをリトライ処理付きで実行します。
    /// </summary>
    /// <typeparam name="TRequest">リクエスト型を指定します。</typeparam>
    /// <typeparam name="TResult">リザルト型を指定します。</typeparam>
    /// <param name="apiCall">PlayFab API呼び出しデリゲートを指定します。</param>
    /// <param name="request">リクエストデータを指定します。</param>
    /// <param name="onSuccess">成功時のコールバックを指定します。</param>
    /// <param name="onFailure">失敗時のコールバックを指定します。</param>
    /// <param name="maxRetries">最大リトライ回数を指定します。</param>
    /// <param name="initialBackoff">初回バックオフ時間（秒）を指定します。</param>
    /// <param name="maxTotalWaitTime">最大総待機時間（秒）を指定します。</param>
    public static void ExecuteWithRetry<TRequest, TResult>(
        Action<TRequest, Action<TResult>, Action<PlayFabError>, object, Dictionary<string, string>> apiCall,
        TRequest request,
        Action<TResult> onSuccess,
        Action<PlayFabError> onFailure,
        object customData = null,
        Dictionary<string, string> extraHeaders = null,
        int maxRetries = 5,
        float initialBackoff = 1f,
        float maxTotalWaitTime = 60f)
        where TRequest : PlayFabRequestCommon
        where TResult : PlayFabResultCommon
    {
        ExecuteWithRetryInternal(apiCall, request, onSuccess, onFailure, customData, extraHeaders, maxRetries, initialBackoff, maxTotalWaitTime, 0, 0f);
    }

    private static void ExecuteWithRetryInternal<TRequest, TResult>(
        Action<TRequest, Action<TResult>, Action<PlayFabError>, object, Dictionary<string, string>> apiCall,
        TRequest request,
        Action<TResult> onSuccess,
        Action<PlayFabError> onFailure,
        object customData,
        Dictionary<string, string> extraHeaders,
        int maxRetries,
        float initialBackoff,
        float maxTotalWaitTime,
        int attempt,
        float totalWaitTime)
        where TRequest : PlayFabRequestCommon
        where TResult : PlayFabResultCommon
    {
        apiCall(request,
            result =>
            {
                // リクエスト成功
                onSuccess?.Invoke(result);
            },
            async error =>
            {
                // リクエストエラー
                if (!IsRetryableError(error) || attempt >= maxRetries || totalWaitTime >= maxTotalWaitTime)
                {
                    if (!IsRetryableError(error))
                    {
                        // リトライできない場合
                        Debug.LogWarning($"Non-retryable error occurred: {error.ErrorMessage}");
                    }
                    else if (attempt >= maxRetries)
                    {
                        // リトライ回数超過の場合
                        Debug.LogWarning("Maximum retry attempts reached.");
                    }
                    else
                    {
                        // 総待機時間の上限を超える場合
                        Debug.LogWarning("Maximum total wait time exceeded.");
                    }
                    onFailure?.Invoke(error);
                    return;
                }

                // ジッター付き指数バックオフ
                float baseWaitTime = initialBackoff * Mathf.Pow(2, attempt);
                float waitTime = error.RetryAfterSeconds.HasValue && error.RetryAfterSeconds.Value > 0
                    ? error.RetryAfterSeconds.Value
                    : UnityEngine.Random.Range(0.5f * baseWaitTime, 1.5f * baseWaitTime);

                if (totalWaitTime + waitTime > maxTotalWaitTime)
                {
                    // 総待機時間の上限を超える場合
                    Debug.LogWarning("Maximum total wait time exceeded.");
                    onFailure?.Invoke(error);
                    return;
                }

                totalWaitTime += waitTime;

                Debug.LogWarning($"Retry attempt {attempt + 1} after {waitTime} seconds. Total wait time: {totalWaitTime} seconds.");

                // 待機後に再帰呼び出し
                await Task.Delay(TimeSpan.FromSeconds(waitTime));
                ExecuteWithRetryInternal(apiCall, request, onSuccess, onFailure, customData, extraHeaders,
                    maxRetries, initialBackoff, maxTotalWaitTime, attempt + 1, totalWaitTime);
            }, customData, extraHeaders);
    }

}
```


リトライ処理付きで PlayFab API を呼び出す使用例は次の通りです。

```cs
using UnityEngine;
using PlayFab.ClientModels;

public class PlayFabRetryExample : MonoBehaviour
{

    private void GetUserDataWithRetry(string playFabId, List<string> keys)
    {
        var request = new GetUserDataRequest()
        {
            Keys = keys,
            PlayFabId = playFabId,
        };

        PlayFabRetryHandler.ExecuteWithRetry<GetUserDataRequest, GetUserDataResult>(
            PlayFabClientAPI.GetUserData,
            request,
            onSuccess: result =>
            {
                Debug.Log("Successfully retrieved user data.");
            },
            onFailure: error =>
            {
                Debug.LogError($"Failed to retrieve user data: {error.ErrorMessage}");
            });
    }
}
```