Java Tips | 基本ユーティリティ:再試行処理

Java Java
スポンサーリンク

再試行処理は「一時的な失敗に負けない仕組み」

業務システムでは、外部 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 を全部再試行対象にしていましたが、実務ではこれは危険です。
例えば、NullPointerExceptionIllegalArgumentException のような「プログラムのバグ」や「入力値の誤り」は、何回やっても成功しません。

再試行すべきなのは、「一時的な失敗」のみです。
ネットワークの一時的なエラー、タイムアウト、ロック競合など、「時間をおけば成功する可能性があるもの」だけを対象にする必要があります。

再試行対象の例外をフィルタリングする

そこで、「どの例外なら再試行するか」を判定する関数を受け取る形に拡張してみます。

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 は、割り込みフラグを復元しつつラップして投げる。
ログやメトリクスと組み合わせると、「運用で強い」再試行になる。

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