Klien HTTP tanpa batas waktu adalah undangan kegagalan berantai. Satu server yang lambat merespons akan menahan thread pemanggil tanpa kepastian; bila thread itu bagian dari pool yang melayani pengguna, antrean permintaan ikut membengkak dan gangguan kecil berubah menjadi gangguan besar. Karena itu ada dua disiplin yang wajib melekat pada setiap klien produksi: timeout memastikan setiap penantian punya batas, dan retry memulihkan kegagalan sementara tanpa membebani server yang sedang bergulat. Keduanya harus dipasang bersama — retry tanpa timeout hanya memperpanjang penderitaan. Chapter ini membahas kedua lapisan timeout pada HttpClient, jenis-jenis exception yang muncul, lalu merakit loop retry dengan backoff yang teruji pada server lokal yang sengaja gagal.
Dua Lapis Timeout #
HttpClient memberi kita dua batas waktu yang berbeda perannya. connectTimeout diatur pada builder client dan membatasi lama pembukaan koneksi TCP — dari SYN sampai tersambung. timeout diatur pada HttpRequest dan membatasi seluruh riwayat permintaan: koneksi, pengiriman, menunggu jawaban, sampai body lengkap diterima. Pemisahan ini penting karena koneksi sering diambil dari pool yang sudah terbuka (cepat), sementara jawaban aplikasi bisa lambat karena server sibuk memproses.
Bila batas permintaan terlampaui, send() melempar HttpTimeoutException — subclass IOException dari package java.net.http yang perlu ditangkap secara eksplisit. Program berikut membuktikannya dengan server lokal yang sengaja menunda balasan satu detik, melawan permintaan yang hanya rela menunggu 200 milidetik:
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
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.nio.charset.StandardCharsets;
import java.time.Duration;
public class KlienTimeout {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
// Server sengaja membalas sangat lambat
server.createContext("/lambat", exchange -> {
try {
Thread.sleep(1000);
byte[] body = "akhirnya tiba".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
} catch (IOException | InterruptedException e) {
// klien sudah memutuskan menunggu — abaikan
}
});
server.start();
try {
HttpClient klien = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/lambat"))
.timeout(Duration.ofMillis(200))
.GET()
.build();
try {
klien.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("respons tiba (tidak diharapkan)");
} catch (HttpTimeoutException e) {
System.out.println("jenis galat : " + e.getClass().getSimpleName());
System.out.println("pesan : " + e.getMessage());
}
} finally {
server.stop(0);
}
}
}Output yang Dihasilkan:
jenis galat : HttpTimeoutException
pesan : request timed outMari kita telaah baris-baris kode di atas:
- Baris 17-27 (handler
/lambat): handler tidur satu detik sebelum membalas, meniru server yang sibuk. BlokcatchmenelanIOExceptionyang mungkin muncul saat menulis respons ke klien yang sudah pergi — klien memang sudah berhenti menunggu. - Baris 32-33 (
connectTimeout(Duration.ofSeconds(2))): batas pembukaan koneksi. Karena server lokal tersambung seketika, lapisan ini tidak terpicu; yang menjegal adalah lapisan berikutnya. - Baris 35-40 (
.timeout(Duration.ofMillis(200))): batas menyeluruh permintaan dipasang pada builderHttpRequest, bukan pada client. Setiap permintaan boleh punya batas berbeda — pembaruan kecil 200 ms, unduhan besar 30 detik. - Baris 42-47 (
catch (HttpTimeoutException e)): exception ini muncul tepat saat batas 200 ms terlampaui, jauh sebelum server selesai tidur. Handler menampilkan nama class dan pesannya agar kita mengenali bentuknya di produksi.
Sekelum tipologi galat koneksi: ConnectException berarti koneksi ditolak (biasanya tidak ada layanan di port tujuan), HttpConnectTimeoutException berarti batas connectTimeout habis saat membuka koneksi, HttpTimeoutException berarti batas permintaan keseluruhan habis, dan SocketTimeoutException muncul dari operasi socket tingkat rendah. Keempatnya bermuara ke IOException, jadi satu blok catch (IOException e) pada level strategi sudah cukup menangkap semuanya.
Retry dengan Backoff #
Kegagalan sementara — server me-restart, jaringan tersendat, beban puncak — adalah kenyataan operasional, dan meminta ulang sering kali memulihkannya. Kunci kebijakan retry yang sehat ada tiga. Pertama, batasi jumlah percobaan; tanpa batas, klien berputar selamanya. Kedua, beri jeda yang membesar (exponential backoff) — 100 ms, 200 ms, 400 ms — agar server yang sedang bergulat mendapat ruang pulih; praktik produksi menambah jitter (acak kecil) agar banyak klien tidak menyerempet waktu yang sama. Ketiga, hanya percobaan ulang yang aman diulang — inilah syarat idempotensi.
Program berikut membangun loop lengkap tersebut. Server lokal sengaja membalas 503 Service Unavailable dua kali sebelum sukses — dihitung dengan AtomicInteger sehingga perilakunya selalu sama — dan klien membalas kegagalan dengan jeda yang membesar:
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
public class KlienRetry {
public static void main(String[] args) throws Exception {
// Penghitung panggilan: dua kegagalan pertama disengaja
AtomicInteger panggilan = new AtomicInteger(0);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/tugas", exchange -> {
int nomor = panggilan.incrementAndGet();
if (nomor <= 2) {
exchange.sendResponseHeaders(503, -1);
exchange.close();
} else {
byte[] body = "tugas diterima".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
});
server.start();
try {
HttpClient klien = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/tugas"))
.GET()
.build();
int maksCoba = 4;
for (int coba = 1; coba <= maksCoba; coba++) {
try {
HttpResponse<String> res = klien.send(req,
HttpResponse.BodyHandlers.ofString());
System.out.println("percobaan " + coba + ": status " + res.statusCode());
if (res.statusCode() / 100 == 2) {
System.out.println("body : " + res.body());
break;
}
} catch (IOException e) {
System.out.println("percobaan " + coba + ": galat "
+ e.getClass().getSimpleName());
}
if (coba < maksCoba) {
long jeda = 100L * (1L << (coba - 1)); // 100 lalu 200 ms
Thread.sleep(jeda);
System.out.println("jeda : " + jeda + " ms lalu coba lagi");
}
}
} finally {
server.stop(0);
}
}
}Output yang Dihasilkan:
percobaan 1: status 503
jeda : 100 ms lalu coba lagi
percobaan 2: status 503
jeda : 200 ms lalu coba lagi
percobaan 3: status 200
body : tugas diterimaMari kita telaah logikanya baris demi baris:
- Baris 15-30 (handler dengan
AtomicInteger): penghitungincrementAndGet()naik setiap permintaan tiba dan tidak pernah kehilangan nilai meski handler dipanggil dari thread berbeda. Panggilan pertama dan kedua membalas503tanpa body (-1), panggilan ketiga mengirim balasan sukses — skenario gagal-dua-kali yang selalu terulang identik. - Baris 40 (
for (int coba = 1; coba <= maksCoba; coba++)): batas atas percobaan memastikan loop berhenti meski server tidak pernah sembuh. Praktik produksi lazim memakai 3-5 percobaan total. - Baris 42-51 (blok try dalam): dua jalur kegagalan ditangani dengan adil — status tidak sukses (
503) dan exceptionIOException. Keduanya menuju logika jeda yang sama, karena keduanya sama-sama layak dicoba ulang. - Baris 53-56 (
100L * (1L << (coba - 1))): rumus backoff eksponensial — geser bit satu langkah berarti menggandakan. Percobaan pertama menunggu 100 ms, kedua 200 ms; pada produksi deretnya lanjut ke 400, 800, dan seterusnya. - Baris 48-49 (
break): percobaan berhenti segera setelah sukses. Tanpabreak, klien akan terus mengirim sisa antrean percobaan yang tidak diperlukan.
Timeout dan retry menjaga satu permintaan tetap terkendali. Pertanyaan berikutnya berada di level sebaliknya: bagaimana satu client melayani ribuan permintaan tanpa membuka koneksi baru setiap kali — urusan pool, keep-alive, dan pemakaian ulang HttpClient yang kita bahas pada Chapter Connection Management.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
KlienRetry.java
Unduh berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
public class KlienRetry {
public static void main(String[] args) throws Exception {
// Penghitung panggilan: dua kegagalan pertama disengaja
AtomicInteger panggilan = new AtomicInteger(0);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/tugas", exchange -> {
int nomor = panggilan.incrementAndGet();
if (nomor <= 2) {
exchange.sendResponseHeaders(503, -1);
exchange.close();
} else {
byte[] body = "tugas diterima".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
});
server.start();
try {
HttpClient klien = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/tugas"))
.GET()
.build();
int maksCoba = 4;
for (int coba = 1; coba <= maksCoba; coba++) {
try {
HttpResponse<String> res = klien.send(req,
HttpResponse.BodyHandlers.ofString());
System.out.println("percobaan " + coba + ": status " + res.statusCode());
if (res.statusCode() / 100 == 2) {
System.out.println("body : " + res.body());
break;
}
} catch (IOException e) {
System.out.println("percobaan " + coba + ": galat "
+ e.getClass().getSimpleName());
}
if (coba < maksCoba) {
long jeda = 100L * (1L << (coba - 1)); // 100 lalu 200 ms
Thread.sleep(jeda);
System.out.println("jeda : " + jeda + " ms lalu coba lagi");
}
}
} finally {
server.stop(0);
}
}
}
KlienTimeout.java
Unduh berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
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.nio.charset.StandardCharsets;
import java.time.Duration;
public class KlienTimeout {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
// Server sengaja membalas sangat lambat
server.createContext("/lambat", exchange -> {
try {
Thread.sleep(1000);
byte[] body = "akhirnya tiba".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
} catch (IOException | InterruptedException e) {
// klien sudah memutuskan menunggu — abaikan
}
});
server.start();
try {
HttpClient klien = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/lambat"))
.timeout(Duration.ofMillis(200))
.GET()
.build();
try {
klien.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("respons tiba (tidak diharapkan)");
} catch (HttpTimeoutException e) {
System.out.println("jenis galat : " + e.getClass().getSimpleName());
System.out.println("pesan : " + e.getMessage());
}
} finally {
server.stop(0);
}
}
}