再試行処理は「一時的な失敗に負けない仕組み」
業務システムでは、外部 API、DB、メッセージキュー、ファイル I/O など、「たまに失敗するけれど、少し待ってもう一度やれば成功する」処理がたくさんあります。
ネットワークの瞬断、相手サーバーの一時的な負荷、ロック競合などは、まさにそういう「一時的な失敗」です。
ここで「1 回失敗したら即エラー」で終わらせると、システム全体としては「弱い」挙動になります。
そこで出てくるのが「再試行処理(リトライ)」です。
ただし、場当たり的に try-catch を書いて再試行すると、回数や待ち時間、ログの出し方がバラバラになり、保守がつらくなります。
だからこそ、「再試行」という考え方をユーティリティとして切り出しておくと、コードが読みやすくなり、挙動も統一できます。
再試行処理の基本パターンをまず押さえる
「最大回数」と「待ち時間」を決めてループする
一番シンプルな再試行は、こんな形です。
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
callExternalApi();
return; // 成功したらメソッドを抜ける
} catch (TemporaryException e) {
if (attempt == maxAttempts) {
throw e; // 最後の試行でも失敗したら諦める
}
Thread.sleep(1000); // 1秒待ってから再試行
}
}
Javaやっていることはシンプルで、「最大 N 回まで試す」「失敗したら少し待ってもう一度」「全部ダメなら諦める」というだけです。
ただし、このままだと問題がいくつかあります。
Thread.sleep(1000) が生で書かれていて意図が分かりにくい。
どの例外を「再試行すべき一時的な失敗」とみなすかがハードコードされている。
ログがないので、何回失敗して何回目で成功したのか分からない。
これらを整理して「再試行ユーティリティ」に落とし込むと、実務でかなり使いやすくなります。
実務で使える「再試行ユーティリティ」の最小形
関数を受け取って「成功するまで(または回数上限まで)実行する」
まずは、「戻り値を返す処理」を再試行するユーティリティの例です。
import java.util.function.Supplier;
public final class Retry {
private Retry() {}
public static <T> T retry(int maxAttempts, long waitMillis, Supplier<T> action) {
RuntimeException lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return action.get(); // 成功したら即 return
} catch (RuntimeException e) {
lastException = e;
if (attempt == maxAttempts) {
throw lastException; // 上限に達したら諦める
}
sleep(waitMillis);
}
}
throw new IllegalStateException("Unreachable");
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Sleep interrupted", e);
}
}
}
Java使う側はこうなります。
String result = Retry.retry(3, 1000, () -> callExternalApi());
ここで深掘りしたいポイントは三つです。
一つ目は、「再試行のロジック(ループ・回数・待ち時間)をユーティリティに閉じ込めている」ことです。
呼び出し側は「3 回まで、1 秒間隔で再試行する」という意図だけを指定すればよく、ループや sleep の細かい書き方を意識しなくて済みます。
二つ目は、「処理を Supplier<T> として渡している」ことです。
これにより、「戻り値がある任意の処理」を再試行対象にできます。
外部 API 呼び出し、DB クエリ、ファイル読み込みなど、いろいろな処理を同じユーティリティで扱えます。
三つ目は、「InterruptedException を正しく扱っている」ことです。
スリープ中に割り込みがかかったら、割り込みフラグを復元しつつ IllegalStateException を投げています。
これにより、「再試行中にアプリ終了やキャンセルがかかった」ケースでも、異常状態として上位に伝わります。
どの例外を「再試行対象」にするかを設計する
すべての RuntimeException を再試行するのは危険
先ほどの最小形では、RuntimeException を全部再試行対象にしていましたが、実務ではこれは危険です。
例えば、NullPointerException や IllegalArgumentException のような「プログラムのバグ」や「入力値の誤り」は、何回やっても成功しません。
再試行すべきなのは、「一時的な失敗」のみです。
ネットワークの一時的なエラー、タイムアウト、ロック競合など、「時間をおけば成功する可能性があるもの」だけを対象にする必要があります。
再試行対象の例外をフィルタリングする
そこで、「どの例外なら再試行するか」を判定する関数を受け取る形に拡張してみます。
import java.util.function.Predicate;
import java.util.function.Supplier;
public final class Retry {
private Retry() {}
public static <T> T retry(
int maxAttempts,
long waitMillis,
Supplier<T> action,
Predicate<Throwable> retryOn) {
Throwable last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return action.get();
} catch (Throwable e) {
last = e;
if (!retryOn.test(e) || attempt == maxAttempts) {
// 再試行対象でない、または上限に達したらそのまま投げる
if (e instanceof RuntimeException re) {
throw re;
}
throw new RuntimeException(e);
}
sleep(waitMillis);
}
}
throw new IllegalStateException("Unreachable");
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Sleep interrupted", e);
}
}
}
Java使う側はこう書けます。
String result = Retry.retry(
3,
1000,
() -> callExternalApi(),
e -> e instanceof TemporaryException || e instanceof TimeoutException
);
Javaここで深掘りしたいのは、「再試行対象の例外を『ポリシー』として外から渡せるようにする」という設計です。
これにより、「この API は TimeoutException だけ再試行」「この DB アクセスはロック競合だけ再試行」といった細かい制御が可能になります。
ユーティリティは「再試行の枠組み」だけを提供し、「何を再試行すべきか」は呼び出し側が決める、という責務分担がきれいになります。
待ち時間を工夫する:固定間隔と指数バックオフ
固定間隔(毎回同じ時間待つ)
先ほどの例は「毎回 1 秒待つ」という固定間隔でした。
これは実装が簡単で、軽い負荷のシステムでは十分です。
ただし、外部サービスが重くなっているときに、全クライアントが一斉に「1 秒ごとに再試行」すると、相手をさらに苦しめてしまうことがあります。
そこでよく使われるのが「指数バックオフ」です。
指数バックオフ(回数に応じて待ち時間を伸ばす)
指数バックオフは、「1 回目は 1 秒、2 回目は 2 秒、3 回目は 4 秒…」のように、試行回数が増えるほど待ち時間を伸ばしていく戦略です。
public static <T> T retryWithBackoff(
int maxAttempts,
long initialWaitMillis,
Supplier<T> action,
Predicate<Throwable> retryOn) {
Throwable last = null;
long wait = initialWaitMillis;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return action.get();
} catch (Throwable e) {
last = e;
if (!retryOn.test(e) || attempt == maxAttempts) {
if (e instanceof RuntimeException re) {
throw re;
}
throw new RuntimeException(e);
}
sleep(wait);
wait *= 2; // 待ち時間を倍にしていく
}
}
throw new IllegalStateException("Unreachable");
}
Java使う側はこうです。
String result = Retry.retryWithBackoff(
5,
500,
() -> callExternalApi(),
e -> e instanceof TemporaryException
);
Javaここでの重要ポイントは、「相手が苦しいときほど、こちらは間隔を空けてあげる」という考え方です。
これにより、外部サービスへの負荷を抑えつつ、一定時間内に成功する可能性を高められます。
クラウドサービスや外部 API では、ドキュメントに「指数バックオフでリトライしてほしい」と明記されていることも多いです。
戻り値がない処理(void)を再試行する
Runnable を使ったバージョン
戻り値が不要な処理(例: 「メッセージを送るだけ」「ログを書くだけ」)を再試行したい場合は、Runnable を使ったオーバーロードを用意しておくと便利です。
import java.util.function.Predicate;
public final class Retry {
// 先ほどの retry(...) はそのまま
public static void retryVoid(
int maxAttempts,
long waitMillis,
Runnable action,
Predicate<Throwable> retryOn) {
retry(maxAttempts, waitMillis, () -> {
action.run();
return null;
}, retryOn);
}
}
Java使う側はこうなります。
Retry.retryVoid(
3,
1000,
() -> sendMessage(),
e -> e instanceof TemporaryException
);
Javaこうしておくと、「戻り値がある処理」と「戻り値がない処理」を同じユーティリティで扱えます。
呼び出し側のコードも、「この処理は再試行される」という意図がメソッド名からはっきり読み取れます。
ログとメトリクスを組み込むと「運用で強い」再試行になる
何回目で成功したかをログに出す
実務では、「どれくらい再試行が発生しているか」「何回目で成功しているか」を知りたくなることが多いです。
そこで、ユーティリティの中でログを出すようにしておくと、運用時のトラブルシュートが楽になります。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// クラス内
private static final Logger log = LoggerFactory.getLogger(Retry.class);
Javaループ内でこうします。
log.warn("Retry attempt {} failed: {}", attempt, e.toString());
Java最後に諦めるときには、
log.error("All {} retry attempts failed", maxAttempts, last);
Javaのように出しておくと、「どの処理がどれだけ失敗しているか」がログから追いやすくなります。
メトリクス(カウンタ)と組み合わせる
さらに一歩進めると、再試行回数や失敗回数をメトリクス(Prometheus など)に送る設計もあります。
これにより、「この API は平均何回再試行しているか」「最近再試行が急増していないか」といったことをダッシュボードで可視化できます。
ここまで来ると少し中級者向けですが、「再試行は運用とセットで考える」という感覚を持っておくと、設計の質が一段上がります。
まとめ:初心者が再試行ユーティリティで身につけるべき感覚
再試行処理は、「とりあえず catch してもう一回やる」ではなく、「何回まで・どの間隔で・どの例外だけ・どうログを残すか」を設計する行為です。
押さえておきたいポイントは次の通りです。
再試行の枠組み(回数・待ち時間・ループ)はユーティリティに閉じ込める。
どの例外を再試行対象にするかは、Predicate<Throwable> などで外から渡せるようにする。
待ち時間は固定間隔だけでなく、指数バックオフも選択肢として持っておく。
スリープ中の InterruptedException は、割り込みフラグを復元しつつラップして投げる。
ログやメトリクスと組み合わせると、「運用で強い」再試行になる。
