Memilih REST sinkron berarti menerima satu kenyataan: kode kita kini bergantung pada proses lain yang berjalan di mesin lain, di luar kendali kita. Panggilan bisa berhasil, bisa dijawab dengan error dari server, atau bisa gagal lebih dini karena jaringan putus sebelum kata pertama sampai. Chapter ini membangun panggilan antarservice yang bertanggung jawab dengan java.net.http.HttpClient — HTTP client bawaan JDK yang sudah kita kenal di Section Networking — lalu memperlakukan kegagalan bukan sebagai kejadian langka, melainkan sebagai salah satu hasil normal setiap panggilan. Sebuah server tiruan di alamat loopback memainkan peran svc-inventori, lengkap dengan satu endpoint yang sengaja sakit.

HttpClient Warga JDK #

HttpClient tinggal di java.net.http sejak Java 11 dan tidak butuh dependensi apa pun — satu alasan kuat memilihnya untuk panggilan antarservice di aplikasi yang ingin tetap ramping. Objek HttpRequest dibangun lewat builder dan bersifat immutable: satu request bisa dikirim berkali-kali, dan satu HttpClient boleh dipakai bersama banyak request — kelas tersebut aman lintas thread.

Dua mode pengiriman tersedia. Method send(...) memblokir hingga jawaban tiba — sederhana dan cukup untuk sebagian besar kasus. Method sendAsync(...) mengembalikan CompletableFuture untuk aliran non-blokir ala reactive. Sejak Java 21, ada jalan ketiga: send(...) di atas virtual threads (JEP 444) memberi gaya penulisan blokir yang tetap hemat sumber daya — ribuan panggilan menunggu tanpa ribuan thread OS.

Panggilan dengan Tiga Nasib #

Program berikut menyiapkan svc-inventori dengan dua endpoint — /stok/A1 sehat, /stok/B2 selalu menjawab 503 — lalu memanggil keduanya, dan terakhir memanggil endpoint sehat setelah server dimatikan:

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;

/** svc-pesanan memanggil svc-inventori lewat REST dan menangani kegagalannya. */
public class PanggilInventori {
    public static void main(String[] args) throws Exception {
        // "svc-inventori" lokal: satu endpoint sehat, satu endpoint gagal
        HttpServer inventori = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        inventori.createContext("/stok/A1", exchange -> {
            byte[] body = "{\"kode\":\"A1\",\"jumlah\":7}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        inventori.createContext("/stok/B2", exchange -> {
            byte[] body = "{\"error\":\"gudang sibuk\"}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(503, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        inventori.start();

        String dasar = "http://127.0.0.1:" + inventori.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();

        try {
            System.out.println("skenario 1 (sukses) : " + panggil(klien, dasar + "/stok/A1"));
            System.out.println("skenario 2 (503)    : " + panggil(klien, dasar + "/stok/B2"));
        } finally {
            inventori.stop(0); // service inventori "dimatikan"
        }

        // Klien baru (pool koneksi baru) menabrak port yang sudah tidak dilayani
        HttpClient klienBaru = HttpClient.newHttpClient();
        System.out.println("skenario 3 (mati)   : " + panggil(klienBaru, dasar + "/stok/A1"));
    }

    /** Satu panggilan GET dengan penanganan status respons dan kegagalan koneksi. */
    static String panggil(HttpClient klien, String url) {
        HttpRequest permintaan = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Accept", "application/json")
                .GET()
                .build();
        try {
            HttpResponse<String> respons =
                    klien.send(permintaan, HttpResponse.BodyHandlers.ofString());
            if (respons.statusCode() == 200) {
                return "200 -> " + respons.body();
            }
            return respons.statusCode() + " -> stok tidak diketahui, jangan lempar mentah";
        } catch (IOException e) {
            return "koneksi gagal (" + e.getClass().getSimpleName() + ") -> fallback cache";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "terputus";
        }
    }
}

Output yang Dihasilkan:

text
skenario 1 (sukses) : 200 -> {"kode":"A1","jumlah":7}
skenario 2 (503)    : 503 -> stok tidak diketahui, jangan lempar mentah
skenario 3 (mati)   : koneksi gagal (ConnectException) -> fallback cache

Mari kita bedah bagian-bagian kuncinya:

  1. HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0): server uji yang hanya mendengarkan alamat loopback, dengan port 0 agar sistem operasi memilih port bebas. Trik ini memungkinkan kita menguji perilaku HTTP nyata tanpa layanan eksternal dan tanpa membuka port ke jaringan luar.
  2. Dua createContext(...): setiap path punya handler sendiri. Handler /stok/B2 sengaja mengirim status 503 dengan body JSON error — mensimulasikan service tetangga yang kelebihan beban, kasus paling sering di produksi.
  3. panggil(klien, url): satu method untuk seluruh skenario. Cabang pertama memeriksa respons.statusCode() == 200 sebelum memakai body; cabang kedua memetakan status apa pun yang bukan 200 menjadi keputusan domain (“stok tidak diketahui”), bukan exception yang lepas mentah ke pemanggil.
  4. catch (IOException e): kegagalan sebelum respons terbentuk — koneksi ditolak, timeout, koneksi diputus — masuk kategori ini. ConnectException yang tampil pada skenario 3 adalah turunan IOException; kami mencetak nama kelasnya agar jenis kegagalannya terlihat, lalu memilih fallback.
  5. catch (InterruptedException e): send(...) yang diblokir bisa dibangunkan lewat interrupt. Kebiasaan yang benar: pulihkan status interrupt dengan Thread.currentThread().interrupt() sebelum kembali.
  6. Skenario 3 memakai HttpClient baru: klien pertama masih menyimpan koneksi keep-alive lama ke server; klien baru dipakai agar panggilan ketiga benar-benar menabrak port yang sudah tidak dilayani siapa pun.

Memetakan Status ke Keputusan #

Status HTTP hanyalah angka sampai kode kita memberinya arti. Pemetaan yang sehat di sisi klien:

KondisiContohArti untuk klienTindakan wajar
2xx200, 201permintaan berhasilbaca body, lanjutkan
4xx404, 409, 422permintaan kita keliruperbaiki permintaan; jangan di-retry
5xx500, 503server tetangga bermasalahretry terbatas, fallback, catat
TimeoutHttpTimeoutExceptiontidak ada jawaban tepat wakturetry terbatas, fallback
Koneksi gagalConnectExceptionservice tidak dapat dihubungifallback, laporan kesehatan

Pembeda baris 4xx dan 5xx penting: me-retry permintaan yang salah format hanya membakar waktu dan menambah beban tetangga. Retry masuk akal untuk kegagalan yang transien — gangguan sesaat yang kemungkinan hilang bila dicoba lagi.

Kontrak yang Tidak Boleh Disepelekan #

Body JSON pada skenario 1 dibaca sebagai string mentah; chapter JSON over HTTP (Section Networking) sudah membahas membangun dan mem-parsing JSON manual. Pada proyek nyata, pustaka seperti Jackson menangani pemetaan ke record — yang wajib dijaga adalah kontraknya: field yang diharapkan divalidasi sebelum dipakai, karena respons tetangga bisa berubah bentuk antar rilis. Uji kontrak (consumer-driven contract test) mencegah kejutan itu.

Tiga nasib panggilan di atas — sukses, ditolak, koneksi mati — adalah bahan baku pola yang lebih besar: bagaimana sistem tetap melayani pengguna ketika tetangga sedang sakit lama. Chapter berikutnya membahas resilience patterns: fallback, bulkhead, dan load shedding yang menjaga kegagalan tetap terkurung.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PanggilInventori.java

Unduh berkas
java
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;

/** svc-pesanan memanggil svc-inventori lewat REST dan menangani kegagalannya. */
public class PanggilInventori {
    public static void main(String[] args) throws Exception {
        // "svc-inventori" lokal: satu endpoint sehat, satu endpoint gagal
        HttpServer inventori = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        inventori.createContext("/stok/A1", exchange -> {
            byte[] body = "{\"kode\":\"A1\",\"jumlah\":7}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        inventori.createContext("/stok/B2", exchange -> {
            byte[] body = "{\"error\":\"gudang sibuk\"}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "application/json");
            exchange.sendResponseHeaders(503, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        inventori.start();

        String dasar = "http://127.0.0.1:" + inventori.getAddress().getPort();
        HttpClient klien = HttpClient.newHttpClient();

        try {
            System.out.println("skenario 1 (sukses) : " + panggil(klien, dasar + "/stok/A1"));
            System.out.println("skenario 2 (503)    : " + panggil(klien, dasar + "/stok/B2"));
        } finally {
            inventori.stop(0); // service inventori "dimatikan"
        }

        // Klien baru (pool koneksi baru) menabrak port yang sudah tidak dilayani
        HttpClient klienBaru = HttpClient.newHttpClient();
        System.out.println("skenario 3 (mati)   : " + panggil(klienBaru, dasar + "/stok/A1"));
    }

    /** Satu panggilan GET dengan penanganan status respons dan kegagalan koneksi. */
    static String panggil(HttpClient klien, String url) {
        HttpRequest permintaan = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header("Accept", "application/json")
                .GET()
                .build();
        try {
            HttpResponse<String> respons =
                    klien.send(permintaan, HttpResponse.BodyHandlers.ofString());
            if (respons.statusCode() == 200) {
                return "200 -> " + respons.body();
            }
            return respons.statusCode() + " -> stok tidak diketahui, jangan lempar mentah";
        } catch (IOException e) {
            return "koneksi gagal (" + e.getClass().getSimpleName() + ") -> fallback cache";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "terputus";
        }
    }
}