Article

⏱️ JavaのCompletableFuture:orTimeoutとcompleteOnTimeoutの違いと注意点

java, backend, concurrency

非同期処理で外部APIやDBを呼び出していると、返事が来ないまま待ち続けることがあります。Javaの CompletableFuture にはタイムアウト用のAPIがありますが、タイムアウトしたら元の処理も停止するという理解は危険です。

2026年10月時点のJava SE 27公式APIを参照し、実務で使い分けたい3つの方法と、通信・DB処理に設定すべき本当のタイムアウトを整理します。以下のサンプルはJava 11でも実行できます。

結論:何がタイムアウトするのか

メソッド 制限時間を過ぎたら 裏で動く処理は止まる?
orTimeout(time, unit) Futureを TimeoutException で異常完了 止まるとは限らない
completeOnTimeout(value, time, unit) Futureを指定した代替値で正常完了 止まるとは限らない
get(time, unit) 呼び出し元の待機を例外で打ち切る 止まるとは限らない

Future は「今はまだ結果が出ていない処理の結果を、後から受け取るための入れ物」です。正常完了は値が確定した状態、異常完了は例外が結果として確定した状態です。

orTimeout と completeOnTimeout は、呼び出したものと同じ CompletableFuture を返す点にも注意してください。元のFutureとは別の、独立した処理を作るわけではありません。

そのまま動かせるサンプル

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class TimeoutDemo {
    static String slowOperation() {
        try {
            Thread.sleep(1_500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", e);
        }
        return "API result";
    }

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        try {
            CompletableFuture<String> errorResult =
                    CompletableFuture.supplyAsync(TimeoutDemo::slowOperation, executor)
                            .orTimeout(100, TimeUnit.MILLISECONDS);

            try {
                System.out.println(errorResult.join());
            } catch (CompletionException e) {
                if (e.getCause() instanceof TimeoutException) {
                    System.out.println("タイムアウト");
                } else {
                    throw e;
                }
            }

            CompletableFuture<String> fallbackResult =
                    CompletableFuture.supplyAsync(TimeoutDemo::slowOperation, executor)
                            .completeOnTimeout("cached value", 100, TimeUnit.MILLISECONDS);

            System.out.println(fallbackResult.join()); // cached value
        } finally {
            executor.shutdown();
        }
    }
}

supplyAsync は非同期の仕事を始めるメソッド、ExecutorService は実行に使うスレッドを管理する仕組みです。join は結果が出るまで待ち、Futureが異常完了した場合は例外を送出します。TimeUnit.MILLISECONDS は100という数字をミリ秒として扱う指定です。

この例では通常、100ミリ秒の期限が約1.5秒の仕事より先に来るので、前者は「タイムアウト」、後者は「cached value」を表示します。しかし、それを表示した時点で slowOperation が中断されたわけではありません。

ハマりどころ1:タイムアウトしても通信やDB処理が残る

CompletableFuture は実行元の仕事を必ず停止させる仕組みではありません。公式APIの cancel(true) でも、引数の mayInterruptIfRunning は実行中の処理の割り込みに使われないと説明されています。

たとえばHTTP呼び出しで結果の受け取りを200ミリ秒であきらめても、通信自体が継続する可能性があります。このまま再試行すると、同じAPIに複数のリクエストが届くこともあります。注文登録・メール送信・DB更新など**副作用(データを変更する処理)**を持つ操作では特に問題です。

実務では「Futureの結果を待つ期限」と「外部の仕事を止める設定」を別々に設計します。通信ライブラリのリクエスト期限、DBクエリのタイムアウト、キャンセルへの対応、そして重複実行を防ぐ冪等性(同じ操作を繰り返しても結果が壊れない性質)を確認しましょう。

ハマりどころ2:get(timeout)はFuture自体を異常完了にしない

CompletableFuture<String> future =
        CompletableFuture.supplyAsync(() -> "done");

try {
    String result = future.get(100, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    // 待機した側の期限切れ。
    // futureは後から正常完了する可能性がある。
}

これは考え方を示す抜粋です。実際には InterruptedException と ExecutionException も処理してください。

get(timeout) はこの呼び出しがどれだけ待つかを制限するだけです。一方で orTimeout は、指定時間までに完了していないFuture自体を異常完了させます。あとからFutureを再利用したい場合、この違いは重要です。

ハマりどころ3:代替値で障害を隠さない

completeOnTimeout("cached value", ...) のような代替値は、表示用のおすすめ情報などには適しています。ただし決済結果や在庫引当などの成功・失敗が重要な処理で、タイムアウトを成功したかのように返すのは危険です。

代替値を返す場合は、タイムアウトが発生した事実をログ・メトリクス(件数や遅延の観測値)で把握し、ユーザーにも「古い情報を表示している」など必要な意味を伝える設計にしましょう。

HTTP通信なら、通信そのものにも期限を指定する

Java標準 HttpClient では、接続とリクエストに期限を設定できます。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.time.Duration;

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(1))
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com/api/data"))
        .timeout(Duration.ofSeconds(2))
        .GET()
        .build();

connectTimeout は接続を確立する段階の期限、HttpRequest.Builder.timeout はリクエストの期限です。上のコードは設定のみで、実際に送信するには client.send または client.sendAsync を呼びます。

CompletableFuture.orTimeout だけに頼らず、実際の入出力処理にもタイムアウトを設定することがポイントです。

実務での使い分け

  • 期限超過を明確な失敗として扱う:orTimeout
  • 期限超過でも利用可能な代替データを返す:completeOnTimeout
  • 呼び出し側の待機だけ制限し、Futureの完了状態は変えない:get(timeout, unit)
  • HTTPやDBの処理時間そのものを制限する:通信・DBライブラリの設定を使用
  • 再試行時の二重更新を防ぐ:冪等性・キャンセル設計・監視も組み合わせる

タイムアウトを「実際の処理停止」と混同しないだけで、障害時の無駄な負荷や意図しない二重処理を減らせます。

参考・一次情報