Java の HttpClient とは、Java 11 から標準で入っている HTTP 通信のクラス(java.net.http.HttpClient)で、外部ライブラリなしで GET・POST・JSON 送信・非同期通信・HTTP/2 まで扱えます。 基本は「HttpClient を1つ作って使い回し、HttpRequest を組み立てて send()(同期)か sendAsync()(非同期)で送る」の3ステップです。

この記事では、ローカルに立てたテスト用API(Node.js 22)に対して Java 21.0.10 で実際にリクエストを送り、その出力をそのまま掲載しています。GET のクエリ文字列のエンコード、JSON の POST/PUT/DELETE、Bearer 認証、フォーム送信、multipart のファイルアップロード、2種類のタイムアウトの違い、リダイレクト、HTTP/2、sendAsync で10件を並列に送ったときの時間(約5.3秒→約0.5秒)、ステータス別のエラー処理と指数バックオフのリトライまで、コピペで動く形でまとめました。


Java HttpClient 逆引き早見表

やりたいこと 書き方(要点)
クライアントを作る HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
GET HttpRequest.newBuilder(URI.create(url)).GET().build()
本文を文字列で受け取る client.send(req, HttpResponse.BodyHandlers.ofString())
ステータス・ヘッダー res.statusCode() / res.headers().firstValue("content-type")
クエリを付ける URLEncoder.encode(値, StandardCharsets.UTF_8) で連結
ヘッダーを付ける .header("Accept", "application/json")
POST で JSON .header("Content-Type", "application/json").POST(BodyPublishers.ofString(json))
PUT / DELETE .PUT(BodyPublishers.ofString(json)) / .DELETE()
JSON ⇔ Java Jackson の ObjectMapper#writeValueAsString / readValue
Bearer 認証 .header("Authorization", "Bearer " + token)
フォーム送信 Content-Type: application/x-www-form-urlencoded + エンコードした a=1&b=2
ファイルアップロード multipart/form-data; boundary=... の本文を手組みして ofByteArray
接続タイムアウト HttpClient.Builder#connectTimeout → HttpConnectTimeoutException
応答タイムアウト HttpRequest.Builder#timeout → HttpTimeoutException
リダイレクトを追う .followRedirects(HttpClient.Redirect.NORMAL)(既定は追わない)
HTTP/2 既定で HTTP/2 を試し、使えなければ HTTP/1.1
非同期・並列 client.sendAsync(...) → CompletableFuture.allOf(...)
本文を捨てる BodyHandlers.discarding()(DELETE の 204 など)
閉じる(Java 21+) try (HttpClient client = ...) または client.close()

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java HttpClientの使い方|GET・POSTでJSON送信」


動作確認の環境とテスト用API

項目 内容
Java OpenJDK 21.0.10(java ファイル名.java の単一ファイル実行)
テスト用API Node.js 22 の http/http2 モジュールで自作(HTTP はポート 8080、HTTPS+HTTP/2 は 8443 として記載)
JSON ライブラリ Jackson 2.16.1(jackson-databind、core、annotations)
実行コマンド java -cp "jackson-databind.jar:jackson-core.jar:jackson-annotations.jar" JsonCrud.java

検証環境では別のポート番号で動かしたため、出力に表示されるポート(7331・7332)は記事のコードの 8080・8443 に読み替えてください。 テスト用APIは /echo・/items・/secure・/upload・/slow・/status/{code}・/flaky などのエンドポイントを持つ小さなサーバーです。

Jackson の版について:検証は 2.16.1 で行いました。執筆時点の最新は 2.2x 系と 3.x 系で、2.x 系なら ObjectMapper の使い方は同じです。3.x はパッケージが tools.jackson に変わるため import の書き換えが必要です。

Java 25 との差分:Java 25 では HttpResponse.BodyHandlers.limiting(handler, 上限バイト数) が追加され、受け取る本文の大きさに上限を付けられるようになりました(Java SE 25 API ドキュメントで確認、手元の Java 21 では未実行)。ほかのコードは Java 25 でもそのまま使えます。


GETリクエストの基本(クエリ・ヘッダー・ステータス)

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

public class GetBasics {
    // クエリパラメータを URL エンコードして連結する
    static String query(Map<String, String> params) {
        return params.entrySet().stream()
            .map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
                    + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
            .collect(Collectors.joining("&"));
    }

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

        Map<String, String> params = new LinkedHashMap<>();   // 順序を保つ
        params.put("keyword", "ノート A5&付箋");
        params.put("page", "1");
        String q = query(params);
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("http://localhost:8080/echo?" + q))
            .header("Accept", "application/json")
            .header("User-Agent", "sample-client/1.0")
            .header("X-Request-Id", "req-001")
            .timeout(Duration.ofSeconds(10))
            .GET()                                   // 省略しても GET
            .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println("status  : " + response.statusCode());
        System.out.println("version : " + response.version());
        System.out.println("type    : " + response.headers().firstValue("content-type").orElse("-"));
        System.out.println("query   : " + q);
        System.out.println("body    : " + response.body());
    }
}

実行結果:

status  : 200
version : HTTP_1_1
type    : application/json
query   : keyword=%E3%83%8E%E3%83%BC%E3%83%88+A5%26%E4%BB%98%E7%AE%8B&page=1
body    : {"method":"GET","query":{"keyword":"ノート A5&付箋","page":"1"},"rawQuery":"?keyword=%E3%83%8E%E3%83%BC%E3%83%88+A5%26%E4%BB%98%E7%AE%8B&page=1","userAgent":"sample-client/1.0","accept":"application/json","xRequestId":"req-001","protocol":"HTTP/1.1"}

URLEncoder.encode() で値をエンコードすると、日本語・空白・& が安全に送れます。 URLEncoder はフォーム形式のエンコードなので空白は + になりますが、サーバー側(今回は Node.js の URLSearchParams)では元の ノート A5&付箋 に戻っていることが確認できます。

部品 役割 ポイント
HttpClient 接続を管理し、リクエストを送る アプリで1つ作って使い回す(スレッドセーフ)
HttpRequest URL・メソッド・ヘッダー・本文 不変オブジェクト。同じリクエストを何度でも送れる
BodyPublishers 送る本文 ofString、ofByteArray、ofFile、noBody
HttpResponse ステータス・ヘッダー・本文 version() で HTTP/1.1 か HTTP/2 かもわかる
BodyHandlers 受け取る本文の形 ofString、ofByteArray、ofFile、ofInputStream、discarding

ファイルに保存するなら BodyHandlers.ofFile(Path.of("out.json")) を使います。


POSTでJSONを送る・PUT・DELETE(Jackson で変換)

JSON を送るときは、Java のオブジェクトを Jackson の ObjectMapper で文字列にし、Content-Type: application/json を付けて BodyPublishers.ofString() で送ります。 受け取った JSON は readValue() で record やクラスに戻します。

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;

public class JsonCrud {
    record NewItem(String name, int price, List<String> tags) {}
    record Item(long id, String name, int price, List<String> tags) {}

    static final String BASE = "http://localhost:8080";
    static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    static final ObjectMapper MAPPER = new ObjectMapper();   // 使い回す(スレッドセーフ)

    static HttpRequest.Builder json(String path) {
        return HttpRequest.newBuilder(URI.create(BASE + path))
            .timeout(Duration.ofSeconds(10))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json");
    }

    public static void main(String[] args) throws Exception {
        // POST:Java オブジェクト → JSON 文字列 → 送信
        String body = MAPPER.writeValueAsString(new NewItem("ノート A5", 180, List.of("文具", "紙")));
        HttpResponse<String> created = CLIENT.send(
            json("/items").POST(HttpRequest.BodyPublishers.ofString(body)).build(),
            HttpResponse.BodyHandlers.ofString());
        Item item = MAPPER.readValue(created.body(), Item.class);   // JSON → record
        System.out.println("POST   " + created.statusCode() + " location=" + created.headers().firstValue("location").orElse("-"));
        System.out.println("       " + item);

        // GET
        HttpResponse<String> got = CLIENT.send(json("/items/" + item.id()).GET().build(), HttpResponse.BodyHandlers.ofString());
        System.out.println("GET    " + got.statusCode() + " " + got.body());

        // PUT
        String update = MAPPER.writeValueAsString(new NewItem("ノート A5(方眼)", 200, List.of("文具")));
        HttpResponse<String> put = CLIENT.send(
            json("/items/" + item.id()).PUT(HttpRequest.BodyPublishers.ofString(update)).build(),
            HttpResponse.BodyHandlers.ofString());
        System.out.println("PUT    " + put.statusCode() + " " + put.body());

        // DELETE(レスポンス本文なし)
        HttpResponse<Void> del = CLIENT.send(json("/items/" + item.id()).DELETE().build(), HttpResponse.BodyHandlers.discarding());
        System.out.println("DELETE " + del.statusCode());

        // 削除後の GET は 404
        HttpResponse<String> gone = CLIENT.send(json("/items/" + item.id()).GET().build(), HttpResponse.BodyHandlers.ofString());
        System.out.println("GET    " + gone.statusCode() + " " + gone.body());
    }
}

実行結果:

POST   201 location=/items/1
       Item[id=1, name=ノート A5, price=180, tags=[文具, 紙]]
GET    200 {"id":1,"name":"ノート A5","price":180,"tags":["文具","紙"]}
PUT    200 {"id":1,"name":"ノート A5(方眼)","price":200,"tags":["文具"]}
DELETE 204
GET    404 {"error":"item 1 not found"}

読み取りのポイント

  • POST の 201 と Location ヘッダーで、作成されたリソースの場所がわかります。
  • DELETE の 204(本文なし)は BodyHandlers.discarding() で受けると、HttpResponse<Void> として扱えます。
  • 404 でも例外にはなりません。 HttpClient は通信が成立すればステータスに関係なく HttpResponse を返すので、ステータスの判定は自分で書きます(後述のエラー処理の節)。
  • ObjectMapper は生成に時間がかかるため static final で使い回します。 スレッドセーフなので複数スレッドから同時に使えます。

Bearer認証・フォーム送信・ファイルアップロード

import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

public class AuthFormUpload {
    static final String BASE = "http://localhost:8080";
    static final HttpClient CLIENT = HttpClient.newHttpClient();

    // application/x-www-form-urlencoded の本文を作る
    static HttpRequest.BodyPublisher form(Map<String, String> data) {
        String s = data.entrySet().stream()
            .map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
                    + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
            .collect(Collectors.joining("&"));
        return HttpRequest.BodyPublishers.ofString(s);
    }

    // multipart/form-data の本文を手組みする(テキスト項目+ファイル1つ)
    static byte[] multipart(String boundary, Map<String, String> fields, String fileField, Path file, String fileType) throws Exception {
        var out = new ByteArrayOutputStream();
        String crlf = "\r\n";
        for (var e : fields.entrySet()) {
            out.write(("--" + boundary + crlf
                + "Content-Disposition: form-data; name=\"" + e.getKey() + "\"" + crlf + crlf
                + e.getValue() + crlf).getBytes(StandardCharsets.UTF_8));
        }
        out.write(("--" + boundary + crlf
            + "Content-Disposition: form-data; name=\"" + fileField + "\"; filename=\"" + file.getFileName() + "\"" + crlf
            + "Content-Type: " + fileType + crlf + crlf).getBytes(StandardCharsets.UTF_8));
        out.write(Files.readAllBytes(file));
        out.write((crlf + "--" + boundary + "--" + crlf).getBytes(StandardCharsets.UTF_8));
        return out.toByteArray();
    }

    public static void main(String[] args) throws Exception {
        // 1) Bearer 認証:トークンなし → 401、あり → 200
        HttpRequest noToken = HttpRequest.newBuilder(URI.create(BASE + "/secure")).build();
        HttpResponse<String> r1 = CLIENT.send(noToken, HttpResponse.BodyHandlers.ofString());
        System.out.println("Bearerなし " + r1.statusCode() + " " + r1.headers().firstValue("www-authenticate").orElse("") + " " + r1.body());

        String token = "demo-token-123";                     // 実際は環境変数などから読む
        HttpRequest withToken = HttpRequest.newBuilder(URI.create(BASE + "/secure"))
            .header("Authorization", "Bearer " + token)
            .build();
        HttpResponse<String> r2 = CLIENT.send(withToken, HttpResponse.BodyHandlers.ofString());
        System.out.println("Bearerあり " + r2.statusCode() + " " + r2.body());

        // 2) フォーム送信
        Map<String, String> data = new LinkedHashMap<>();
        data.put("name", "山田 花子");
        data.put("comment", "在庫確認 & 発注");
        HttpRequest formReq = HttpRequest.newBuilder(URI.create(BASE + "/form"))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(form(data))
            .build();
        System.out.println("form       " + CLIENT.send(formReq, HttpResponse.BodyHandlers.ofString()).body());

        // 3) ファイルアップロード(multipart/form-data)
        String boundary = "----JavaBoundary" + UUID.randomUUID();
        Map<String, String> fields = Map.of("title", "備品リスト");
        byte[] body = multipart(boundary, fields, "file", Path.of("items.csv"), "text/csv");
        HttpRequest upReq = HttpRequest.newBuilder(URI.create(BASE + "/upload"))
            .header("Content-Type", "multipart/form-data; boundary=" + boundary)
            .POST(HttpRequest.BodyPublishers.ofByteArray(body))
            .build();
        System.out.println("upload     " + CLIENT.send(upReq, HttpResponse.BodyHandlers.ofString()).body());
    }
}

実行結果(items.csv は2行のCSV、29バイト):

Bearerなし 401 Bearer {"error":"unauthorized"}
Bearerあり 200 {"message":"ok","user":"demo"}
form       {"contentType":"application/x-www-form-urlencoded","raw":"name=%E5%B1%B1%E7%94%B0+%E8%8A%B1%E5%AD%90&comment=%E5%9C%A8%E5%BA%AB%E7%A2%BA%E8%AA%8D+%26+%E7%99%BA%E6%B3%A8","parsed":{"name":"山田 花子","comment":"在庫確認 & 発注"}}
upload     {"contentType":"multipart/form-data","parts":[{"name":"title","filename":null,"type":null,"bytes":15,"text":"備品リスト"},{"name":"file","filename":"items.csv","type":"text/csv","bytes":29}]}

3つの送り方の違いを表にまとめます。

送り方 Content-Type 本文の作り方 主な用途
JSON application/json ObjectMapper#writeValueAsString REST API
フォーム application/x-www-form-urlencoded key=value&...(値は URLEncoder) ログイン画面、OAuth のトークン取得
マルチパート multipart/form-data; boundary=... 境界文字列で区切った各パート ファイルアップロード

HttpClient には multipart 用の部品がないため、本文を手組みします。 ポイントは次の4つです。

  • 各パートは --境界 で始め、ヘッダーと内容の間に空行(\r\n\r\n)を入れる
  • 改行は \r\n(CRLF)
  • 最後は --境界-- で閉じる
  • 境界文字列は本文に現れない値にする(UUID を混ぜると安心)

トークンやパスワードはコードに直書きせず、環境変数(System.getenv("API_TOKEN"))や設定ファイルから読み込みます。 認証まわりのステータスの意味(401 と 403 の違い)は別記事で詳しく扱っています。


タイムアウト・リダイレクト・HTTP/2

2種類のタイムアウトの違い

設定場所 メソッド 対象 超えたときの例外
HttpClient.Builder connectTimeout(Duration) TCP接続(TLSハンドシェイク含む)の確立まで HttpConnectTimeoutException
HttpRequest.Builder timeout(Duration) レスポンスのヘッダーを受け取るまで(リクエストごと) HttpTimeoutException

どちらも既定では HttpClient 側の上限がありません(接続はOSのTCP設定まで待ちます)。相手が応答しないとスレッドが待ち続けるため、両方を必ず設定します。HttpConnectTimeoutException は HttpTimeoutException のサブクラスなので、まとめて catch することもできます。

import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import java.time.Duration;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;

public class TimeoutRedirectH2 {
    static String get(HttpClient client, String url, Duration timeout) {
        long t0 = System.currentTimeMillis();
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(url)).timeout(timeout).build();
            HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
            return res.statusCode() + " " + res.version() + " " + res.uri() + " " + res.body()
                + " (" + (System.currentTimeMillis() - t0) + "ms)";
        } catch (Exception e) {
            return e.getClass().getName() + ": " + e.getMessage() + " (" + (System.currentTimeMillis() - t0) + "ms)";
        }
    }

    // 自己署名証明書(開発用)を信頼する SSLContext を作る
    static SSLContext trust(String pemPath) throws Exception {
        KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
        ks.load(null, null);
        try (InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Path.of(pemPath))) {
            ks.setCertificateEntry("dev", CertificateFactory.getInstance("X.509").generateCertificate(in));
        }
        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(ks);
        SSLContext ctx = SSLContext.getInstance("TLS");
        ctx.init(null, tmf.getTrustManagers(), null);
        return ctx;
    }

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();

        System.out.println("[1] 応答待ちタイムアウト(サーバーが3秒かかる、上限1秒)");
        System.out.println("    " + get(client, "http://localhost:8080/slow?ms=3000", Duration.ofSeconds(1)));

        System.out.println("[2] 接続先ポートが閉じている");
        System.out.println("    " + get(client, "http://localhost:8081/echo", Duration.ofSeconds(5)));

        System.out.println("[3] リダイレクト:既定(NEVER)");
        System.out.println("    " + get(client, "http://localhost:8080/redirect", Duration.ofSeconds(5)));

        HttpClient follow = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        System.out.println("[4] リダイレクト:NORMAL");
        System.out.println("    " + get(follow, "http://localhost:8080/redirect", Duration.ofSeconds(5)));

        HttpClient h2 = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)      // 既定も HTTP_2(使えなければ HTTP/1.1 に切り替わる)
            .sslContext(trust("cert.pem"))
            .build();
        System.out.println("[5] HTTPS + HTTP/2");
        System.out.println("    " + get(h2, "https://localhost:8443/echo", Duration.ofSeconds(5)));
        System.out.println("[6] http:// への HTTP/2 指定(h2c の Upgrade に非対応のサーバー)");
        System.out.println("    " + get(h2, "http://localhost:8080/echo", Duration.ofSeconds(5)));
    }
}

実行結果(cert.pem は開発用に作った自己署名証明書):

[1] 応答待ちタイムアウト(サーバーが3秒かかる、上限1秒)
    java.net.http.HttpTimeoutException: request timed out (1028ms)
[2] 接続先ポートが閉じている
    java.net.ConnectException: null (11ms)
[3] リダイレクト:既定(NEVER)
    302 HTTP_1_1 http://localhost:7331/redirect  (18ms)
[4] リダイレクト:NORMAL
    200 HTTP_1_1 http://localhost:7331/echo?from=redirect {"method":"GET","query":{"from":"redirect"},"rawQuery":"?from=redirect","userAgent":"Java-http-client/21.0.10","xRequestId":null,"protocol":"HTTP/1.1"} (24ms)
[5] HTTPS + HTTP/2
    200 HTTP_2 https://localhost:7332/echo {"method":"GET","query":{},"rawQuery":"","userAgent":"Java-http-client/21.0.10","xRequestId":null,"protocol":"HTTP/2.0"} (360ms)
[6] http:// への HTTP/2 指定(h2c の Upgrade に非対応のサーバー)
    200 HTTP_1_1 http://localhost:7331/echo {"method":"GET","query":{},"rawQuery":"","userAgent":"Java-http-client/21.0.10","xRequestId":null,"protocol":"HTTP/1.1"} (30ms)

出力からわかること

  • [1] リクエストの timeout(1秒) で、約1秒後に HttpTimeoutException: request timed out になりました。
  • [2] 閉じたポートへの接続は即座に ConnectException で、getMessage() は null でした。ログには e.getMessage() だけでなく、例外の型と原因(e.toString() や e.getCause())を出すと調べやすくなります。
  • [3] 既定ではリダイレクトを追わず、302 がそのまま返ります。 Redirect.NORMAL にすると [4] のように移動先の 200 を受け取れます。NORMAL は https から http への移動は追いません(ALWAYS は追う)。
  • [5] HTTPS では ALPN(TLSの中でプロトコルを決める仕組み)で HTTP/2 が選ばれ、HTTP_2 になりました。 [6] の http:// では、サーバーが HTTP/2 へのアップグレード(h2c)に対応していないため HTTP/1.1 で通信しています。HTTP/2 を指定しても、使えないときは自動で HTTP/1.1 になるので、指定による失敗は起きません。
  • User-Agent を指定しないと Java-http-client/21.0.10 が送られます。

接続タイムアウト(HttpConnectTimeoutException)を再現する

接続を受け付けない(accept() しない)ServerSocket を待ち行列1で作って埋めておき、connectTimeout(2秒)・timeout(10秒) の HttpClient で接続しました(ConnectTimeoutDemo.java)。

実行結果:

java.net.http.HttpConnectTimeoutException: HTTP connect timed out (2087ms)

connectTimeout の2秒を少し過ぎたところで HttpConnectTimeoutException: HTTP connect timed out になりました。リクエストの timeout(10秒) より先に、接続の上限で打ち切られています。

自己署名証明書は、上のコードの trust() のように「その証明書だけを信頼する SSLContext」を作って渡します。 すべての証明書を無条件に信頼するやり方は検証が行われなくなるため避け、本番では正規の証明書を使います。


sendAsyncで非同期・並列リクエスト

sendAsync() は送信してすぐ CompletableFuture<HttpResponse<T>> を返します。 複数を同時に投げて CompletableFuture.allOf() で待てば、待ち時間を重ねられます。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;

public class AsyncParallel {
    public static void main(String[] args) throws Exception {
        // Java 21 から HttpClient は AutoCloseable:try-with-resources で閉じられる
        try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()) {
            List<HttpRequest> requests = IntStream.rangeClosed(1, 10)
                .mapToObj(i -> HttpRequest.newBuilder(URI.create("http://localhost:8080/slow?ms=500&n=" + i))
                    .timeout(Duration.ofSeconds(5)).build())
                .toList();

            // 1) send を順番に 10 回
            long t0 = System.currentTimeMillis();
            for (HttpRequest r : requests) client.send(r, HttpResponse.BodyHandlers.ofString());
            System.out.println("send 逐次 10件     : " + (System.currentTimeMillis() - t0) + "ms");

            // 2) sendAsync で 10 件同時に投げ、allOf で待つ
            long t1 = System.currentTimeMillis();
            List<CompletableFuture<String>> futures = requests.stream()
                .map(r -> client.sendAsync(r, HttpResponse.BodyHandlers.ofString())
                    .thenApply(res -> res.statusCode() + ":" + res.uri().getQuery()))
                .toList();
            CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
            System.out.println("sendAsync 並列10件 : " + (System.currentTimeMillis() - t1) + "ms");
            System.out.println("結果(投げた順)   : " + futures.stream().map(CompletableFuture::join).toList());

            // 3) 失敗は exceptionally / handle で受ける
            String r = client.sendAsync(HttpRequest.newBuilder(URI.create("http://localhost:8080/slow?ms=3000"))
                        .timeout(Duration.ofMillis(500)).build(), HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .exceptionally(e -> "失敗: " + e.getClass().getSimpleName() + " / cause=" + e.getCause().getClass().getSimpleName())
                .join();
            System.out.println("タイムアウト時     : " + r);
        }
    }
}

実行結果(各リクエストはサーバー側で500ミリ秒待つ):

send 逐次 10件     : 5317ms
sendAsync 並列10件 : 533ms
結果(投げた順)   : [200:ms=500&n=1, 200:ms=500&n=2, 200:ms=500&n=3, 200:ms=500&n=4, 200:ms=500&n=5, 200:ms=500&n=6, 200:ms=500&n=7, 200:ms=500&n=8, 200:ms=500&n=9, 200:ms=500&n=10]
タイムアウト時     : 失敗: CompletionException / cause=HttpTimeoutException

逐次 send では 500ms × 10件で約5.3秒、sendAsync で並列にすると約0.5秒でした。 結果のリストは完了順ではなく投げた順に並ぶので、リクエストとの対応が崩れません。

非同期で失敗すると、例外は CompletionException に包まれて届きます。 本当の原因は getCause() で取り出します(上の出力では HttpTimeoutException)。

相手のサーバーにも負荷がかかるため、大量に送るときは同時実行数を制限します(例:Semaphore(10) で同時10件まで)。仮想スレッドで書く場合は Executors.newVirtualThreadPerTaskExecutor() の中で send() を呼ぶだけで並列になります。


ステータス別のエラー処理とリトライ(指数バックオフ)

HttpClient は 4xx・5xx でも例外を投げません。 例外になるのは「通信そのものが失敗したとき」(接続できない、タイムアウトなど)です。そのため、ステータスの判定と、どのエラーを再試行するかを自分で決めます。

状況 HttpClient の結果 再試行 対応
2xx HttpResponse - 本文を処理
400 / 401 / 403 / 404 / 422 HttpResponse しない 入力・認証・URL を見直す
429 Too Many Requests HttpResponse する Retry-After に従って待つ
502 / 503 / 504 HttpResponse する 指数バックオフで待つ
500 HttpResponse 原則しない 相手のログ・本文を確認
接続できない ConnectException する(回数制限) ホスト・ポート・起動状態
接続タイムアウト HttpConnectTimeoutException する ネットワーク経路を確認
応答タイムアウト HttpTimeoutException 条件付き 冪等なリクエストに限る

指数バックオフとは、再試行の待ち時間を 200ms → 400ms → 800ms… と倍々に伸ばす方法で、ジッター(乱数のばらつき)を加えると多数のクライアントが同時に再送するのを防げます。考え方と間隔の決め方は別記事「指数バックオフとは?リトライ間隔の決め方とジッターの実装」で詳しく解説しているので、ここでは HttpClient に組み込んだ完成形を示します。

import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;

public class RetryClient {
    /** 2xx 以外で終わったときの例外(status で 4xx/5xx を見分けられる) */
    static class HttpStatusException extends IOException {
        final int status;
        HttpStatusException(int status, String body) { super("HTTP " + status + " " + body); this.status = status; }
    }

    static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
    static final Set<Integer> RETRYABLE = Set.of(429, 502, 503, 504);
    static final int MAX_ATTEMPTS = 4;
    static final long BASE_MS = 200, CAP_MS = 3000;

    static String getWithRetry(String url) throws IOException, InterruptedException {
        HttpRequest req = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(3)).build();
        for (int attempt = 1; ; attempt++) {
            long wait;
            try {
                HttpResponse<String> res = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
                int s = res.statusCode();
                if (s >= 200 && s < 300) return res.body();
                if (!RETRYABLE.contains(s) || attempt == MAX_ATTEMPTS) throw new HttpStatusException(s, res.body());
                // Retry-After(秒)があれば優先する
                wait = res.headers().firstValue("retry-after").map(v -> Long.parseLong(v) * 1000).orElse(backoff(attempt));
                System.out.printf("  試行%d: %d → %dms 待って再試行%n", attempt, s, wait);
            } catch (ConnectException | HttpTimeoutException e) {
                if (attempt == MAX_ATTEMPTS) throw e;
                wait = backoff(attempt);
                System.out.printf("  試行%d: %s → %dms 待って再試行%n", attempt, e.getClass().getSimpleName(), wait);
            }
            Thread.sleep(wait);
        }
    }

    // 指数バックオフ+フルジッター:0 〜 min(CAP, BASE × 2^(n-1)) の乱数
    static long backoff(int attempt) {
        long exp = Math.min(CAP_MS, BASE_MS * (1L << (attempt - 1)));
        return ThreadLocalRandom.current().nextLong(exp + 1);
    }

    public static void main(String[] args) throws InterruptedException {
        String[][] cases = {
            {"[A] 503 が2回続いてから成功", "http://localhost:8080/flaky?key=a&fail=2"},
            {"[B] 429 + Retry-After: 1", "http://localhost:8080/status/429"},
            {"[C] 404 は再試行しない", "http://localhost:8080/status/404"},
            {"[D] 接続できない", "http://localhost:8081/"}};
        for (String[] c : cases) {
            System.out.println(c[0]);
            try {
                System.out.println("  → 成功: " + getWithRetry(c[1]));
            } catch (HttpStatusException e) {
                System.out.println("  → " + (e.status < 500 ? "4xx" : "5xx") + " として処理: " + e.getMessage());
            } catch (IOException e) {
                System.out.println("  → 通信エラー: " + e.getClass().getSimpleName());
            }
        }
    }
}

実行結果(待ち時間は乱数なので実行ごとに変わります):

[A] 503 が2回続いてから成功
  試行1: 503 → 109ms 待って再試行
  試行2: 503 → 7ms 待って再試行
  → 成功: {"attempt":3,"message":"ok"}
[B] 429 + Retry-After: 1
  試行1: 429 → 1000ms 待って再試行
  試行2: 429 → 1000ms 待って再試行
  試行3: 429 → 1000ms 待って再試行
  → 4xx として処理: HTTP 429 {"status":429}
[C] 404 は再試行しない
  → 4xx として処理: HTTP 404 {"status":404}
[D] 接続できない
  試行1: ConnectException → 112ms 待って再試行
  試行2: ConnectException → 22ms 待って再試行
  試行3: ConnectException → 379ms 待って再試行
  → 通信エラー: ConnectException

[A] は3回目で成功、[B] の 429 は Retry-After: 1 に従って1秒ずつ待って4回で打ち切り、[C] の 404 は再試行しても結果が変わらないので1回で終了、[D] は上限に達したら例外を呼び出し元へ投げています。

POST の再試行には注意が必要です。 応答タイムアウトのときはサーバー側で処理が完了している場合があり、再送すると二重登録になることがあります。GET・PUT・DELETE のような冪等(何回送っても結果が同じ)なリクエストだけを再試行するか、POST には冪等キー(Idempotency-Key ヘッダーなど)を付けるとよいでしょう。


よくあるエラーと直し方

各エラーを try/catch で表示する小さなプログラム(Pitfalls.java)で、実際に起こしたエラーの出力です。

URLに空白 → java.lang.IllegalArgumentException: Illegal character in query at index 32: http://localhost:7331/echo?q=ノート A5
スキームなし → java.lang.IllegalArgumentException: invalid URI scheme localhost
制限ヘッダー Host → java.lang.IllegalArgumentException: restricted header name: "Host"
Content-Type なしでJSON送信 → {"error":"content-type must be application/json"}
5xx でも例外にならない → 500
エラー・症状 原因 直し方
IllegalArgumentException: Illegal character in query URL に空白や日本語をそのまま入れた 値を URLEncoder.encode() してから連結
IllegalArgumentException: invalid URI scheme http:// を書き忘れた スキームから書く
IllegalArgumentException: restricted header name: "Host" HttpClient が管理するヘッダーを指定した Host・Connection・Content-Length・Expect・Upgrade は指定しない
415 / サーバーで JSON が読めない Content-Type を付けていない .header("Content-Type", "application/json")
ConnectException(メッセージ null) 接続先が起動していない・ポート違い 例外の型で判定し、URL・ポート・起動状態を確認
HttpTimeoutException: request timed out 応答が timeout() を超えた 上限を見直す、非同期・リトライを検討
HttpConnectTimeoutException 接続確立が connectTimeout() を超えた 経路・ファイアウォール・DNS を確認
SSLHandshakeException: PKIX path building failed 信頼されていない証明書 正規の証明書を使う、開発時はその証明書だけを信頼する
CompletionException sendAsync の失敗が包まれている getCause() で元の例外を取り出す

HttpURLConnection・Apache HttpClient 5・OkHttp・Spring RestClientとの比較

項目 java.net.http.HttpClient HttpURLConnection Apache HttpClient 5 OkHttp Spring RestClient
提供 JDK 標準(Java 11+) JDK 標準(初期から) 外部ライブラリ 外部ライブラリ Spring Framework 6.1+
依存の追加 不要 不要 必要 必要 Spring に含まれる
非同期 sendAsync(CompletableFuture) なし あり コールバック なし(非同期は WebClient)
HTTP/2 標準対応 なし 対応 対応 下で使うクライアント次第
JSON 変換 自分で(Jackson など) 自分で 自分で 自分で 自動(メッセージコンバーター)
選ぶ目安 依存を増やしたくない、まずこれ 既存コードの保守 細かな制御・豊富な設定 Android・Kotlin Spring Boot のアプリ

Spring RestClient は、内部の通信部分に JDK の HttpClient や Apache HttpClient を選んで使えます。 Spring Boot のアプリなら RestClient、Spring を使わないツールやライブラリなら標準の HttpClient、という選び方がわかりやすいでしょう。

HttpClient は使い回し、終了時に close する

HttpClient は内部に接続プールとスレッドを持つため、リクエストごとに作らず、アプリで1つ(設定が違うなら設定ごとに1つ)を使い回します。 Java 21 から HttpClient は AutoCloseable になり、close()(実行中のリクエストの完了を待って閉じる)、shutdown()、shutdownNow()、awaitTermination() が使えるようになりました。短いプログラムでは上の AsyncParallel.java のように try-with-resources で、常駐するアプリでは終了処理の中で close() を呼びます。


よくある質問

Q. HttpClient は Java 8 で使えますか? A. 使えません。java.net.http.HttpClient は Java 11 で正式に追加されました(Java 9・10 では incubator モジュール)。Java 8 では HttpURLConnection か外部ライブラリを使います。

Q. 4xx や 5xx のときに例外を投げてほしいのですが? A. HttpClient にはその設定がないため、statusCode() を見て自分で例外を投げます。この記事の RetryClient.java の HttpStatusException のように、ステータスを持たせた例外にしておくと呼び出し側で 4xx と 5xx を見分けやすくなります。

Q. プロキシ経由で通信するには? A. HttpClient.newBuilder().proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 8080))) のように指定します。システムのプロキシ設定に従う場合は ProxySelector.getDefault() を渡します。


まとめ

  • java.net.http.HttpClient は Java 11+ の標準機能で、GET・POST・JSON・HTTP/2・非同期まで外部ライブラリなしで扱える
  • JSON は Jackson で変換し、Content-Type: application/json を必ず付ける
  • クエリとフォームの値は URLEncoder.encode()、multipart は CRLF と境界文字列で手組みする
  • connectTimeout と timeout は既定で上限なし。両方を設定し、例外の型で原因を見分ける
  • 4xx/5xx は例外にならないので statusCode() で分岐し、429・503 などだけを指数バックオフで再試行する
  • sendAsync + allOf で並列化すると、10件の待ち時間が約5.3秒から約0.5秒になった
  • HttpClient は1つを使い回し、Java 21 以降は close() で閉じる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java HttpClientの使い方|GET・POSTでJSON送信」

株式会社RJCでは、JavaのHTTP通信とAPI連携の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?