はじめに:リトライ処理は「一度コケても、あきらめずにもう一歩踏み込む」仕組み
業務システムでは、外部サービスや 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 層)は
「このメソッドを呼べば、内部で必要なリトライはやってくれる」と信じて書けます。
ここでの重要ポイントは、
「リトライは“インフラ寄りの責務”。業務ロジックの中にベタベタ書かない」
という設計の意識です。
リトライのポリシーを変えたいときも、その層だけ直せば済むようにしておくと楽です。
まとめ:リトライ処理は“あきらめる前に、賢くもう一歩だけ踏み込む”ための道具
リトライ処理ユーティリティの本質は、
一時的な失敗に対して
決めた回数だけ
相手に優しく(間隔を空けながら)再挑戦し
それでもダメなら、ちゃんと失敗として扱う
という「大人なあきらめ方」をコードにすることです。
押さえておきたいポイントをまとめると、
リトライすべきは「一時的な失敗」だけ。ビジネスエラーやバグはリトライしない。
「最大回数」「対象例外」「待ち時間」をパラメータ化したユーティリティにしておくと再利用しやすい。
非同期処理用に RetryAsync/RetryWithBackoffAsync のような形を用意しておくと実務で便利。
指数バックオフで待ち時間を伸ばしながらリトライするのが、外部サービスへのマナー。
リトライの回数や結果をログに残し、「リトライ頼みになっていないか」を後から確認できるようにする。
ここまで腹落ちしていれば、
「とりあえず catch してもう一回やってみる」から卒業して、
“設計されたリトライ”をユーティリティとして組み込めるようになります。
