C# Tips | ログ・例外・診断:指数バックオフ

C# C#
スポンサーリンク

はじめに:指数バックオフは「だんだん距離を取りながら、もう一度だけ試す」リトライ戦略

外部 API、クラウドサービス、DB、メッセージキューなどにアクセスするとき、
一時的なエラー(ネットワークの瞬断、サーバーの一時負荷など)は「少し時間をおいて再試行すれば成功する」ことがよくあります。

ただし、失敗するたびに毎回同じ間隔で連打すると、相手のサービスにさらに負荷をかけ、自分のアプリ側も無駄にリソースを消費し、ログは「失敗→即リトライ→即失敗…」で埋まります。

そこで登場するのが 指数バックオフ(Exponential Backoff) です。
ざっくり言うと、「失敗するたびに、次のリトライまでの待ち時間を“倍々”に伸ばしていく」戦略です。

ここでは、初心者向けに

指数バックオフの考え方
C# でのシンプルな実装
どの例外をリトライ対象にするか
ログとの組み合わせ方
実務での使いどころと注意点

を、例題付きで丁寧に解説します。


指数バックオフの基本イメージ

待ち時間が「一定」ではなく「倍々に増えていく」

まずは数字でイメージをつかみましょう。
初回の待ち時間を 500ms にした場合、例えば次のようになります。

1 回目の失敗後の待ち時間:0.5 秒
2 回目の失敗後の待ち時間:1.0 秒
3 回目の失敗後の待ち時間:2.0 秒
4 回目の失敗後の待ち時間:4.0 秒

このように、待ち時間が 2 倍ずつ増えていきます。
もちろん、倍率は 2 倍に限らず、1.5 倍や 3 倍などにすることもあります。

ここでの重要ポイントは、「失敗が続くほど“間を空けて様子を見る”」という姿勢です。
すぐに連打するのではなく、「相手が回復する時間」を意識して距離を取るイメージです。


C# でのシンプルな非同期指数バックオフ実装

基本形:ExecuteWithExponentialBackoffAsync

まずは、async/await に対応したシンプルな指数バックオフ付きリトライユーティリティを書いてみます。

public static class RetryPolicy
{
    public static async Task ExecuteWithExponentialBackoffAsync(
        Func<Task> action,
        int maxRetryCount,
        TimeSpan initialDelay,
        Func<Exception, bool> shouldRetry)
    {
        var delay = initialDelay;

        for (int attempt = 1; attempt <= maxRetryCount; attempt++)
        {
            try
            {
                await action();
                return; // 成功したら終了
            }
            catch (Exception ex)
            {
                // リトライ対象外、または最後の試行なら、そのまま投げる
                if (!shouldRetry(ex) || attempt == maxRetryCount)
                {
                    throw;
                }

                // 次のリトライまで待つ
                await Task.Delay(delay);

                // 待ち時間を倍にする(指数バックオフ)
                delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
            }
        }
    }
}
C#

使い方の例です。

await RetryPolicy.ExecuteWithExponentialBackoffAsync(
    async () => await CallExternalApiAsync(),
    maxRetryCount: 5,
    initialDelay: TimeSpan.FromMilliseconds(500),
    shouldRetry: ex => ex is HttpRequestException);
C#

ここでの重要ポイントは三つあります。
一つ目は、「どの処理をリトライするか」を Func<Task> で渡していることです。これにより、任意の非同期処理を包めます。
二つ目は、「最大リトライ回数」と「初期待ち時間」を引数で指定できることです。これにより、処理ごとにポリシーを変えられます。
三つ目は、「shouldRetry で“どの例外だけリトライ対象にするか”を制御している」ことです。ここが安全性の要です。


どの例外をリトライ対象にするか

「一時的な失敗」だけを狙い撃ちする

指数バックオフは便利ですが、何でもかんでもリトライしてはいけません。
リトライすべきなのは、主に「一時的な環境要因による失敗」です。

例えば、次のようなものです。

ネットワークの一時障害(HttpRequestException
外部サービスの一時的な 5xx エラー
DB の一時的なロックやタイムアウト(特定の例外)

逆に、次のようなものはリトライしても意味がありません。

ユーザー入力エラー(ArgumentException など)
ビジネスルール違反(「在庫不足」「締め切り超過」など)
プログラムバグ(NullReferenceException など)

これらは「何回やり直しても成功しない」タイプのエラーです。

そのため、shouldRetry には「一時的な失敗だけを判定するロジック」を書きます。

bool ShouldRetry(Exception ex)
{
    if (ex is HttpRequestException)
    {
        return true;
    }

    // 必要なら、特定のステータスコードだけ許可するなどの判定もここに書く
    return false;
}
C#

ここでの重要ポイントは、「リトライ対象の例外を明示的に絞る」ことです。
“直してもらわないといけないエラー”はリトライせず、すぐに失敗として扱うべきです。


ログと組み合わせた指数バックオフユーティリティ

「何回目で成功したか」「どこで諦めたか」を残す

実務では、指数バックオフの挙動をログに残しておくと、
「この API はよく 3 回目でやっと成功しているな」といった分析ができるようになります。

ILogger と組み合わせた例を見てみます。

public static class RetryPolicyWithLogging
{
    public static async Task ExecuteWithExponentialBackoffAsync(
        Func<Task> action,
        int maxRetryCount,
        TimeSpan initialDelay,
        Func<Exception, bool> shouldRetry,
        ILogger logger,
        string context)
    {
        var delay = initialDelay;

        for (int attempt = 1; attempt <= maxRetryCount; attempt++)
        {
            try
            {
                await action();

                if (attempt > 1)
                {
                    logger.LogInformation(
                        "指数バックオフリトライ成功。Context={Context} Attempts={Attempts}",
                        context,
                        attempt);
                }

                return;
            }
            catch (Exception ex)
            {
                if (!shouldRetry(ex) || attempt == maxRetryCount)
                {
                    logger.LogError(
                        ex,
                        "指数バックオフリトライ失敗(打ち切り)。Context={Context} Attempts={Attempts}",
                        context,
                        attempt);
                    throw;
                }

                logger.LogWarning(
                    ex,
                    "指数バックオフリトライ対象の例外。Context={Context} Attempt={Attempt} 次は {Delay} 後に再試行します。",
                    context,
                    attempt,
                    delay);

                await Task.Delay(delay);
                delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
            }
        }
    }
}
C#

使い方の例です。

await RetryPolicyWithLogging.ExecuteWithExponentialBackoffAsync(
    async () => await CallExternalApiAsync(),
    maxRetryCount: 5,
    initialDelay: TimeSpan.FromMilliseconds(500),
    shouldRetry: ex => ex is HttpRequestException,
    logger: _logger,
    context: "顧客情報連携API");
C#

ここでの重要ポイントは、「リトライの回数と文脈(どの処理か)をログに残す」ことです。
これにより、「リトライ頼みになっていないか」「どの処理が不安定か」を後から可視化できます。


実務での使いどころと注意点

どこに組み込むか:外部依存の“近く”に置く

指数バックオフ付きリトライは、主に「外部依存の近く」に置くのがきれいです。

外部 API クライアントクラス(HTTP クライアント)
DB アクセスを包むリポジトリ層
メッセージキューやストレージへのアクセスクラス

こういった場所で、「このメソッドは内部で指数バックオフ付きリトライを行う」と決めておくと、
上位のサービス層や UI 層は「ただ呼ぶだけ」で済みます。

逆に、サービス層や UI 層のあちこちに「for+try-catch+Delay」が散らばると、
どこで何回リトライしているのか分からなくなり、調整も困難になります。

ここでの重要ポイントは、「リトライポリシーを“インフラ寄りの層”に閉じ込める」ことです。
そうすることで、ポリシー変更(回数や待ち時間の調整)も一箇所で済みます。

やりすぎ注意:リトライは“魔法”ではない

指数バックオフは強力ですが、万能ではありません。
次のような点には注意が必要です。

最大リトライ回数を大きくしすぎると、ユーザーから見たレスポンスが極端に遅くなる
外部サービス側が完全に落ちている場合、何回待っても無駄
「リトライすれば何とかなる」と思って、根本原因の調査をサボりがちになる

大事なのは、「リトライは一時的な揺らぎへの対処であって、恒久的な解決ではない」という意識です。
ログを見て、「いつも 4 回目でやっと成功している」ような処理があれば、
根本的な改善(キャッシュ、設計見直し、サービス側の調整など)を検討すべきです。


まとめ:指数バックオフは“賢く距離を取りながら再挑戦する”ためのユーティリティ

指数バックオフの本質を一言で言うと、

「一時的な失敗に対して、
すぐに連打するのではなく、
待ち時間を伸ばしながら、決めた回数だけ丁寧に再挑戦する」

というリトライの作法です。

押さえておきたいポイントを整理すると、次のようになります。

待ち時間を「一定」ではなく「倍々に増やす」ことで、相手にも自分にも優しいリトライになる。
リトライ対象は「一時的な失敗」に絞り、ビジネスエラーやバグはリトライしない。
Func<Task>、最大回数、初期待ち時間、shouldRetry をパラメータにしたユーティリティにすると再利用しやすい。
ILogger と組み合わせて、「何回目で成功したか」「どこで諦めたか」をログに残すと、後から健康状態を分析できる。
外部依存の近く(API クライアント、リポジトリなど)に組み込み、上位層からは意識せず使えるようにする。

ここまでイメージできていれば、「とりあえず同じ処理を連打する」段階から卒業して、
“設計されたリトライ戦略”として指数バックオフをユーティリティに組み込めるようになります。

タイトルとURLをコピーしました