利用者登録のPOSTを送った後、応答が来なかったとします。サーバー側では登録が完了しているかもしれません。同じPOSTをそのまま送り直した場合と、サーバーが同じ操作として扱えるキーを付けた場合を比べます。
コードの前提
JDK 17の標準APIだけで動くサンプルです。元の記事ではSpringのControllerから呼んでいました。ここでは同じ呼び出し順を小さく再現し、ローカルサーバーが最初のHTTP応答を返さないケースを確認します。POSTではメモリへ保存した後、HTTPヘッダーを送らずに待ちます。
サンプルコード ZIPには4つのJavaファイル、run.sh、READMEが入っています。展開先でJDK 17を使い、sh run.sh を実行してください。
| 操作 | HTTP要求数 | サーバーの保存件数 | 呼び出し側で確認する結果 |
|---|---|---|---|
| 読み取り専用のGETを再試行 | 2 | 0 | HTTP 200 |
| キーなしPOSTを無条件に再試行 | 2 | 2 | HTTP 201、登録ID 2 |
| 同一キー・同一本文のPOSTを再試行 | 2 | 1 | HTTP 201、最初の登録ID 1 |
| 同一キーで本文を変えて送信 | 2 | 1 | 2回目はHTTP 409 |
| キーなしPOSTで応答が来ず停止 | 1 | 1 | タイムアウト。呼び出し側では保存されたか分からない |
コード設計
Controllerは業務操作を呼び出し、UserRequestServiceがAPIの契約を基に再試行するかを決めます。AbstractApiはネットワークの処理を担当し、更新を繰り返してよいかをHTTP送受信だけから判断しません。

Controller
このサンプルのControllerは起動用のクラスです。各ケースでサーバーを作り直し、HTTP結果だけでなく、実際の要求数と保存件数を照合します。キーなしPOSTでタイムアウトしたケースは1回で止め、例外から登録結果を決めません。
public final class SampleController {
public static void main(String[] args) throws Exception {
try (var api = new LocalUserApi(true)) {
var client = new UserRequestService(api.baseUrl());
check(client.findUsers(), 200, "0");
checkCounts(api, 2, 0);
System.out.println("GET retried: requests=2, saved=0");
}
try (var api = new LocalUserApi(true)) {
var client = new UserRequestService(api.baseUrl());
check(client.registerWithoutKeyAndRetryForComparison("Aki"), 201, "2");
checkCounts(api, 2, 2);
System.out.println("POST without key, blindly retried: requests=2, saved=2");
}
try (var api = new LocalUserApi(true)) {
var client = new UserRequestService(api.baseUrl());
check(client.registerUser("Aki", "registration-7"), 201, "1");
checkCounts(api, 2, 1);
System.out.println("POST with same key and body: requests=2, saved=1, id=1");
}
try (var api = new LocalUserApi(false)) {
var client = new UserRequestService(api.baseUrl());
check(client.registerUser("Aki", "registration-7"), 201, "1");
check(client.registerUser("Yui", "registration-7"), 409,
"same key with different name");
checkCounts(api, 2, 1);
System.out.println("POST with same key and changed body: HTTP 409, saved=1");
}
try (var api = new LocalUserApi(true)) {
var client = new UserRequestService(api.baseUrl());
try {
client.registerUser("Aki", null);
throw new AssertionError("expected unknown result after response timeout");
} catch (AbstractApi.ResponseTimeout expected) {
checkCounts(api, 1, 1);
System.out.println("POST without key, stopped: requests=1, saved=1");
}
}
}
private static void check(AbstractApi.Response response, int status, String body) {
if (response.status() != status || !response.body().equals(body)) {
throw new AssertionError("unexpected response: " + response);
}
}
private static void checkCounts(LocalUserApi api, int requests, int saved) {
if (api.requests() != requests || api.savedUsers() != saved) {
throw new AssertionError("requests=" + api.requests() + ", saved=" + api.savedUsers());
}
}
}
UserRequestService
読み取り専用のGETには再試行1回を許可します。登録POSTは、相手が同じキーと同じ本文を再送として扱う契約を持つときだけ、同じキーを載せて再試行します。registerWithoutKeyAndRetryForComparison は二重登録を起こす比較専用メソッドです。
import java.io.IOException;
import java.net.URL;
import java.util.Map;
final class UserRequestService extends AbstractApi {
UserRequestService(URL baseUrl) {
super(baseUrl);
}
Response findUsers() throws IOException {
return send(new Request("GET", "/users", null, Map.of()),
RetryPolicy.RETRY_ONCE);
}
Response registerUser(String name, String key) throws IOException {
boolean hasKey = key != null && !key.isBlank();
return send(registration(name, hasKey ? key : null),
hasKey ? RetryPolicy.RETRY_ONCE : RetryPolicy.STOP);
}
Response registerWithoutKeyAndRetryForComparison(String name) throws IOException {
return send(registration(name, null), RetryPolicy.RETRY_ONCE);
}
private Request registration(String name, String key) {
var headers = key == null
? Map.of("Content-Type", "text/plain; charset=UTF-8")
: Map.of("Content-Type", "text/plain; charset=UTF-8", "Idempotency-Key", key);
return new Request("POST", "/users", name, headers);
}
}
受信側では最初のPOSTを保存した直後に応答を止めます。同じキーと同じ名前を受けたら最初のIDを返し、同じキーで名前が違えば409を返します。この例ではキー判定と保存を1つの同期区間に置きました。複数インスタンスで動く実サービスでは、業務操作や利用者を含めたキーの範囲と有効期間を決め、保存結果と重複防止を永続ストレージで扱います。
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
final class LocalUserApi implements AutoCloseable {
private record Registration(String name, int id) {}
private final AtomicInteger requests = new AtomicInteger();
private final CountDownLatch releaseFirstRequest = new CountDownLatch(1);
private final Map<String, Registration> registrationsByKey = new HashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool();
private final HttpServer server;
private final boolean suppressFirstResponse;
private int savedUsers;
LocalUserApi(boolean suppressFirstResponse) throws IOException {
this.suppressFirstResponse = suppressFirstResponse;
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(executor);
server.createContext("/users", this::handle);
server.start();
}
URL baseUrl() throws IOException {
return new URL("http://127.0.0.1:" + server.getAddress().getPort());
}
int requests() {
return requests.get();
}
synchronized int savedUsers() {
return savedUsers;
}
private void handle(HttpExchange exchange) throws IOException {
int attempt = requests.incrementAndGet();
try (exchange) {
AbstractApi.Response response = switch (exchange.getRequestMethod()) {
case "GET" -> new AbstractApi.Response(200, Integer.toString(savedUsers()));
case "POST" -> {
String name = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
yield register(name, exchange.getRequestHeaders().getFirst("Idempotency-Key"));
}
default -> new AbstractApi.Response(405, "method not allowed");
};
if (suppressFirstResponse && attempt == 1) {
try {
releaseFirstRequest.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return;
}
byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(response.status(), bytes.length);
exchange.getResponseBody().write(bytes);
}
}
private synchronized AbstractApi.Response register(String name, String key) {
Registration prior = registrationsByKey.get(key);
if (prior != null) {
if (!prior.name().equals(name)) {
return new AbstractApi.Response(409, "same key with different name");
}
return new AbstractApi.Response(201, Integer.toString(prior.id()));
}
int id = ++savedUsers;
if (key != null) {
registrationsByKey.put(key, new Registration(name, id));
}
return new AbstractApi.Response(201, Integer.toString(id));
}
@Override
public void close() {
releaseFirstRequest.countDown();
server.stop(0);
executor.shutdownNow();
}
}
AbstractApi
1回の送信ごとに新しい接続オブジェクトを作ります。応答の読み取りがタイムアウトしたときだけ、Serviceが選んだ方針に従って最大1回再試行します。409のようにHTTP応答を受け取った場合は、その結果を呼び出し側へ返します。再試行時に本文やキーを作り直さないよう、同じRequestを送ります。
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
abstract class AbstractApi {
private final URL baseUrl;
AbstractApi(URL baseUrl) {
this.baseUrl = baseUrl;
}
enum RetryPolicy {
STOP, RETRY_ONCE
}
record Request(String method, String path, String body, Map<String, String> headers) {}
record Response(int status, String body) {}
static final class ResponseTimeout extends IOException {
ResponseTimeout(SocketTimeoutException cause) {
super("Timed out while reading the HTTP response", cause);
}
}
protected Response send(Request request, RetryPolicy policy) throws IOException {
try {
return sendOnce(request);
} catch (ResponseTimeout timeout) {
if (policy == RetryPolicy.STOP) {
throw timeout;
}
return sendOnce(request);
}
}
private Response sendOnce(Request request) throws IOException {
var connection = (HttpURLConnection) new URL(baseUrl, request.path()).openConnection();
try {
connection.setConnectTimeout(1000);
connection.setReadTimeout(500);
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod(request.method());
request.headers().forEach(connection::setRequestProperty);
if (request.body() != null) {
byte[] bytes = request.body().getBytes(StandardCharsets.UTF_8);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
try (var body = connection.getOutputStream()) {
body.write(bytes);
}
} else {
connection.connect();
}
try {
int status = connection.getResponseCode();
try (var body = status >= 400 ? connection.getErrorStream()
: connection.getInputStream()) {
return new Response(status, body == null ? ""
: new String(body.readAllBytes(), StandardCharsets.UTF_8));
}
} catch (SocketTimeoutException timeout) {
throw new ResponseTimeout(timeout);
}
} finally {
connection.disconnect();
}
}
}
配布ZIPを展開し、次のコマンドを実行します。
#!/bin/sh
set -eu
cd -- "$(dirname -- "$0")"
mkdir -p build
javac --release 17 -d build AbstractApi.java UserRequestService.java LocalUserApi.java SampleController.java
java -cp build SampleControllersh run.sh
GET retried: requests=2, saved=0
POST without key, blindly retried: requests=2, saved=2
POST with same key and body: requests=2, saved=1, id=1
POST with same key and changed body: HTTP 409, saved=1
POST without key, stopped: requests=1, saved=1レビューでは「その操作を2回送ったとき、保存件数と応答はどうなるか」「初回の結果を確認できないとき、照会・手動対応・再試行のどれを選ぶか」を確認します。
