← Semua pembelajaran / Go Nol → Enterprise
Fase 3 · HTTP — Request sampai Response

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.

Sumber asli pkg.go.dev Resmi Rangkuman ~8 menit baca

Intisari

  • Jangan pernah memakai http.Get atau http.DefaultClient di kode produksi. Keduanya tanpa timeout.
  • Buat satu *http.Client yang dipakai bersama — ia punya connection pool di dalamnya. Membuat klien per permintaan membuang koneksi.
  • resp.Body wajib 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:

  1. NewRequestWithContext, bukan NewRequest — tanpa context, pembatalan tidak sampai ke sini.
  2. 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.
  3. io.LimitReader. Respons dari layanan lain juga data tidak tepercaya; tanpa batas, respons 5 GB akan menghabiskan memorimu.

Retry: kapan boleh, kapan berbahaya

SituasiRetry?
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.