C# Tips | ログ・例外・診断:リトライ処理

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

はじめに:リトライ処理は「一度コケても、あきらめずにもう一歩踏み込む」仕組み

業務システムでは、外部サービスや DB、ファイルアクセスなど「環境に左右される処理」がたくさんあります。
ネットワークが一瞬不安定だった
ロックがかかっていて、たまたま今だけ失敗した
外部 API が一瞬だけ 500 を返した

こういう「たまたま今だけダメだった」系の失敗は、少し待ってもう一度やれば成功することが多いです。
その「もう一度やる」を、きれいな形で書くのが リトライ処理ユーティリティ です。

ここでは、初心者向けに

なぜリトライが必要なのか
「何回まで」「どの例外だけ」リトライするかの考え方
同期版・非同期版のシンプルなリトライユーティリティ
実務でよく使う「待ち時間を伸ばしながらリトライ(指数バックオフ)」

を、例題付きでかみ砕いて説明します。


リトライ処理の基本イメージをつかむ

「同じ処理を、決めた回数だけやり直す」

まずは、頭の中のイメージから。

ある処理を実行する
失敗したら、少し待ってもう一度やる
それでもダメなら、さらにもう一度やる
それでもダメなら、あきらめて例外を投げる

これをコードにすると、いわゆる「for 文+try-catch」の形になります。

public static void Retry(Action action, int maxRetryCount)
{
    for (int i = 0; i < maxRetryCount; i++)
    {
        try
        {
            action();
            return; // 成功したら抜ける
        }
        catch
        {
            if (i == maxRetryCount - 1)
            {
                throw; // 最後の試行でも失敗したら、そのまま例外を投げる
            }

            // ここで少し待ってから次のループへ、など
        }
    }
}
C#

ここでの重要ポイントは、
「リトライとは“例外を握りつぶす”ことではなく、“決めた回数だけ再挑戦して、それでもダメならちゃんと失敗として扱う”こと」
という感覚です。


どの例外をリトライするかを決める

「全部リトライ」は危険。リトライすべきは“一時的な失敗”だけ

リトライ処理で一番大事なのは、「何でもかんでもリトライしない」 ことです。

ユーザー入力が不正(ArgumentException
ビジネスルール違反(「在庫が足りません」など)
プログラムバグ(NullReferenceException など)

こういうものは、何回やり直しても成功しません。
リトライすべきなのは、主に「一時的な環境要因による失敗」です。

例えば、こんなイメージです。

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

なので、リトライユーティリティには「どの例外ならリトライ対象にするか」を渡せるようにしておくと安全です。

public static void Retry(
    Action action,
    int maxRetryCount,
    Func<Exception, bool> shouldRetry)
{
    for (int i = 0; i < maxRetryCount; i++)
    {
        try
        {
            action();
            return;
        }
        catch (Exception ex)
        {
            if (!shouldRetry(ex) || i == maxRetryCount - 1)
            {
                throw;
            }

            Thread.Sleep(500); // 0.5 秒待ってから再試行
        }
    }
}
C#

使い方の例です。

Retry(
    () => CallExternalApi(),
    maxRetryCount: 3,
    shouldRetry: ex => ex is HttpRequestException);
C#

ここでの重要ポイントは、
「リトライ対象の例外を“明示的に絞る”ことで、無意味なリトライや無限ループを防ぐ」
という設計です。


非同期版リトライユーティリティ(async/await 対応)

Task を返す処理も同じ考え方で包む

今どきの業務コードは、外部 API や DB アクセスが async なことが多いので、
非同期版のリトライユーティリティも用意しておくと便利です。

public static async Task RetryAsync(
    Func<Task> action,
    int maxRetryCount,
    Func<Exception, bool> shouldRetry,
    TimeSpan delay)
{
    for (int i = 0; i < maxRetryCount; i++)
    {
        try
        {
            await action();
            return;
        }
        catch (Exception ex)
        {
            if (!shouldRetry(ex) || i == maxRetryCount - 1)
            {
                throw;
            }

            await Task.Delay(delay);
        }
    }
}
C#

使い方の例です。

await RetryAsync(
    async () => await CallExternalApiAsync(),
    maxRetryCount: 3,
    shouldRetry: ex => ex is HttpRequestException,
    delay: TimeSpan.FromSeconds(1));
C#

ここでの重要ポイントは、
「同期版と非同期版で“同じルール”を持たせる」ことです。
どちらも「最大回数」「対象例外」「待ち時間」を引数で渡せるようにしておくと、使い回しやすくなります。


実務でよく使う「待ち時間を伸ばしながらリトライ(指数バックオフ)」

毎回同じ間隔で叩き続けるのはマナー違反

外部サービスや DB に対してリトライするとき、
毎回同じ間隔(例えば 1 秒)で連続して叩き続けると、相手に負荷をかけてしまいます。

そこでよく使われるのが 指数バックオフ(exponential backoff) です。
ざっくり言うと、

1 回目の失敗 → 0.5 秒待つ
2 回目の失敗 → 1 秒待つ
3 回目の失敗 → 2 秒待つ

のように、失敗するたびに待ち時間を伸ばしていく やり方です。

簡易的な実装例です。

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

    for (int i = 0; i < maxRetryCount; i++)
    {
        try
        {
            await action();
            return;
        }
        catch (Exception ex)
        {
            if (!shouldRetry(ex) || i == maxRetryCount - 1)
            {
                throw;
            }

            await Task.Delay(delay);
            delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // 待ち時間を倍にする
        }
    }
}
C#

使い方の例です。

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

ここでの重要ポイントは、
「リトライは“相手に優しく”行う。連打ではなく、間隔を伸ばしながら様子を見る」
という考え方です。
外部サービスとの連携では、ほぼ必須のマナーだと思っておいていいです。


ログとの組み合わせ:何回目のリトライかを残す

「何回目で成功したか」「結局何回失敗したか」を見える化する

リトライ処理は、ログと組み合わせるとさらに威力を発揮します。
「何回目で成功したか」「どの例外で何回リトライしたか」を残しておくと、
後から「この API はよく 3 回目でやっと成功しているな」といった分析ができます。

先ほどの非同期リトライにログを足してみます。

public static async Task RetryWithLoggingAsync(
    Func<Task> action,
    int maxRetryCount,
    Func<Exception, bool> shouldRetry,
    TimeSpan initialDelay,
    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#

ここでの重要ポイントは、
「リトライの“回数”と“文脈(どの処理か)”をログに残すことで、後から“リトライ頼みになっていないか”をチェックできる」
ということです。
リトライは便利ですが、乱用すると「常にギリギリで動いている不安定なシステム」になります。ログで健康状態を見ましょう。


どこにリトライを入れるか:層の設計の話

「呼び出し側」ではなく「外部依存の近く」に置く

リトライ処理は、どこに書くかも大事です。
基本的には、外部依存(DB、外部 API、メッセージキューなど)に一番近い層 に置くのがきれいです。

リポジトリ層で DB アクセスにリトライをかける
外部 API クライアントクラスで HTTP 呼び出しにリトライをかける

こうしておくと、上の層(サービス層や UI 層)は
「このメソッドを呼べば、内部で必要なリトライはやってくれる」と信じて書けます。

ここでの重要ポイントは、
「リトライは“インフラ寄りの責務”。業務ロジックの中にベタベタ書かない」
という設計の意識です。
リトライのポリシーを変えたいときも、その層だけ直せば済むようにしておくと楽です。


まとめ:リトライ処理は“あきらめる前に、賢くもう一歩だけ踏み込む”ための道具

リトライ処理ユーティリティの本質は、

一時的な失敗に対して
決めた回数だけ
相手に優しく(間隔を空けながら)再挑戦し
それでもダメなら、ちゃんと失敗として扱う

という「大人なあきらめ方」をコードにすることです。

押さえておきたいポイントをまとめると、

リトライすべきは「一時的な失敗」だけ。ビジネスエラーやバグはリトライしない。
「最大回数」「対象例外」「待ち時間」をパラメータ化したユーティリティにしておくと再利用しやすい。
非同期処理用に RetryAsyncRetryWithBackoffAsync のような形を用意しておくと実務で便利。
指数バックオフで待ち時間を伸ばしながらリトライするのが、外部サービスへのマナー。
リトライの回数や結果をログに残し、「リトライ頼みになっていないか」を後から確認できるようにする。

ここまで腹落ちしていれば、
「とりあえず catch してもう一回やってみる」から卒業して、
“設計されたリトライ”をユーティリティとして組み込めるようになります。

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