HTTP client — timeout, retry & pool
http.DefaultClient tidak punya timeout. Satu panggilan ke API yang menggantung akan menahan goroutine, koneksi, dan permintaan pengguna selamanya — dan itu cara paling umum kegagalan menyebar antar layanan.
Intisari
- Jangan pernah memakai
http.Getatauhttp.DefaultClientdi kode produksi. Keduanya tanpa timeout. - Buat satu
*http.Clientyang dipakai bersama — ia punya connection pool di dalamnya. Membuat klien per permintaan membuang koneksi. resp.Bodywajib ditutup, dan wajib dibaca sampai habis agar koneksinya dipakai ulang.- Retry hanya untuk yang idempoten dan hanya dengan exponential backoff + jitter.
- Setel
MaxIdleConnsPerHost; default-nya 2, dan itu terlalu kecil untuk layanan yang sering dipanggil.
Klien yang benar
// Dibuat SEKALI, di main, lalu dioper lewat konstruktor.
func NewKlien() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 20 // default 2 — hampir selalu terlalu kecil
tr.MaxConnsPerHost = 50
tr.IdleConnTimeout = 90 * time.Second
tr.ResponseHeaderTimeout = 5 * time.Second
return &http.Client{
Transport: tr,
Timeout: 10 * time.Second, // batas menyeluruh: DNS + koneksi + body
}
}
Timeout pada http.Client mencakup pembacaan body. Untuk unduhan
besar atau respons streaming, batas menyeluruh itu salah — pakai context.WithTimeout per
permintaan sebagai gantinya, dan biarkan Client.Timeout kosong untuk klien khusus itu.
Memakainya dengan benar
func (k *KlienPembayaran) Status(ctx context.Context, id string) (Status, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
k.basis+"/pembayaran/"+url.PathEscape(id), nil)
if err != nil {
return Status{}, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+k.token)
resp, err := k.http.Do(req)
if err != nil {
return Status{}, fmt.Errorf("panggil pembayaran: %w", err)
}
defer func() {
// Baca sisa body SEBELUM menutup, supaya koneksinya bisa dipakai ulang.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return Status{}, fmt.Errorf("pembayaran balas %d", resp.StatusCode)
}
var s Status
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&s); err != nil {
return Status{}, fmt.Errorf("decode respons pembayaran: %w", err)
}
return s, nil
}
Tiga hal di potongan itu yang sering dilewatkan:
NewRequestWithContext, bukanNewRequest— tanpa context, pembatalan tidak sampai ke sini.- Body dibaca sampai habis sebelum ditutup. Body yang ditutup tanpa dibaca membuat koneksinya dibuang, bukan dikembalikan ke pool — dan aplikasimu membuka koneksi TCP baru untuk setiap panggilan.
io.LimitReader. Respons dari layanan lain juga data tidak tepercaya; tanpa batas, respons 5 GB akan menghabiskan memorimu.
Retry: kapan boleh, kapan berbahaya
| Situasi | Retry? |
|---|---|
| Gagal koneksi / DNS | ✅ Ya |
| 408, 429, 502, 503, 504 | ✅ Ya — hormati header Retry-After |
500 pada GET | ✅ Ya |
POST tanpa kunci idempotensi | ❌ Tidak — bisa menagih dua kali |
| 4xx selain 408/429 | ❌ Tidak — permintaanmu yang salah |
| Context sudah kedaluwarsa | ❌ Tidak — anggaran waktunya habis |
func (k *Klien) doRetry(req *http.Request, maks int) (*http.Response, error) {
var errTerakhir error
for percobaan := range maks {
if percobaan > 0 {
// Exponential backoff DENGAN jitter. Tanpa jitter, semua
// container mencoba lagi pada milidetik yang sama — dan
// justru itu yang menjatuhkan layanan yang baru pulih.
jeda := time.Duration(1<<percobaan) * 100 * time.Millisecond
jeda += time.Duration(rand.Int64N(int64(jeda / 2)))
select {
case <-time.After(jeda):
case <-req.Context().Done():
return nil, req.Context().Err()
}
}
resp, err := k.http.Do(req)
if err != nil {
errTerakhir = err
continue
}
if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
errTerakhir = fmt.Errorf("status %d", resp.StatusCode)
}
return nil, errTerakhir
}
Retry tanpa jitter memperburuk pemadaman. Saat sebuah layanan sempat gagal, semua kliennya mencoba lagi setelah jeda yang sama persis — menghasilkan gelombang trafik serentak tepat saat layanan itu paling rapuh. Jitter memecah gelombang itu. Ini pelajaran operasional, bukan detail implementasi.
Dan retry pada POST yang tidak idempoten adalah cara membuat pelanggan ditagih dua
kali. Kalau API tujuan mendukung idempotency key, kirimkan; kalau tidak, jangan retry.
Circuit breaker: berhenti memanggil yang sudah pasti gagal
TERTUTUP ──(kegagalan > ambang)──► TERBUKA
▲ │
│ (setelah jeda)
│ ▼
└──(percobaan berhasil)────── SETENGAH TERBUKA
Saat sebuah dependensi mati, memanggilnya terus hanya menahan goroutine dan koneksimu sendiri sampai timeout — dan permintaan penggunamu ikut lambat meski bagian lain aplikasi baik-baik saja. Circuit breaker membuat panggilan gagal seketika selama dependensi diketahui bermasalah, sehingga kamu bisa menyajikan versi terdegradasi (misalnya dari cache) alih-alih menunggu sia-sia.
Membuatnya bisa diuji
// Klien menerima *http.Client dari luar — tesnya tinggal mengoper
// klien yang menunjuk httptest.Server.
func TestStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"lunas"}`))
}))
defer srv.Close()
k := NewKlienPembayaran(srv.URL, srv.Client())
s, err := k.Status(context.Background(), "abc")
...
}
Ini alasan lain untuk tidak memakai http.DefaultClient: klien yang datang dari
konstruktor bisa diganti di tes. Klien global tidak. Pola yang sama berlaku untuk jam
(func() time.Time) dan untuk pembangkit ID — dependensi tersembunyi selalu jadi tes yang
sulit.
Latihan: tulis klien untuk https://go.dev/VERSION?m=text dengan timeout 2 detik
dan penanganan body yang benar. Lalu jalankan 100 panggilan berturut-turut dan hitung berapa koneksi TCP
yang dibuka (ss -tn | grep -c go.dev). Setelah itu hapus baris io.Copy(io.Discard,
...) dan ulangi — bandingkan angkanya.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.