Operasi jaringan selalu melibatkan pihak lain yang bisa lambat, diam, atau terputus di tengah jalan. Tanpa batas waktu, permintaan HTTP yang macet membuat goroutine menunggu tanpa kepastian dan memakan memori. Go menyediakan pengaturan timeout di dua sisi sekaligus: http.Client menghentikan permintaan lambat dari sisi pengirim, sedangkan http.Server memutus koneksi nakal dari sisi penerima. Chapter ini membahas keduanya beserta mekanisme keep-alive.

Bahaya Klien Tanpa Timeout #

http.Client tanpa field Timeout tidak punya batas waktu sama sekali; http.DefaultClient dan fungsi praktis seperti http.Get juga begitu. Satu server yang menggantung cukup menjebak program pada baris tersebut, kadang berjam-jam tanpa error.

Client.Timeout mencakup keseluruhan permintaan sejak koneksi dibuka sampai body selesai dibaca. Ketika batas waktu terlampaui, klien mengembalikan error yang dikenali dengan errors.Is(err, context.DeadlineExceeded).

Timeout Berlapis di Sisi Klien #

Satu permintaan HTTP terdiri dari beberapa tahap yang bisa dibatasi terpisah. Tabel berikut merangkum tiga lapisan yang paling sering dikonfigurasi:

LapisanPengaturanMelindungi dari
Membuka koneksi TCPnet.Dialer{Timeout: ...} pada http.TransportServer tidak bisa dihubungi sama sekali
Menunggu header responsTransport.ResponseHeaderTimeoutServer menerima permintaan, tetapi lambat memproses
Keseluruhan permintaanhttp.Client{Timeout: ...}Gabungan dial, header, dan pembacaan body

Field Client.Timeout adalah payung termudah; lapisan Transport memberi kendali halus untuk klien berat seperti scraper. Konfigurasi http.Transport juga mengatur pool koneksi idle:

transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }

Nilai MaxIdleConns membatasi total koneksi idle di pool, MaxIdleConnsPerHost membatasi per tujuan, dan IdleConnTimeout menutup koneksi yang lama tidak terpakai.

Timeout di Sisi Server #

Server tanpa timeout lebih rentan lagi: klien jahat atau rusak bisa membuka banyak koneksi lalu tidak pernah mengirim apa pun. Tiap koneksi mengikat satu goroutine pembaca, jadi serangan ini murah bagi klien dan mahal bagi server. Empat field http.Server berikut membentuk pertahanan berlapis:

FieldMembatasi waktu untuk
ReadHeaderTimeoutKlien mengirim baris permintaan dan header
ReadTimeoutKlien mengirim keseluruhan permintaan termasuk body
WriteTimeoutServer menulis respons ke klien
IdleTimeoutKoneksi keep-alive menganggur sebelum ditutup server

Konfigurasi lengkapnya dibangun secara eksplisit seperti berikut:

srv := &http.Server{ ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 120 * time.Second, Handler: mux, }

Program Demonstrasi #

Berkas contoh.go di bawah membuktikan ketiga mekanisme memakai server lokal dari package httptest, sehingga pengujian berjalan deterministik tanpa jaringan eksternal:

go
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func main() {
	// Bagian 1: client.Timeout memutus permintaan yang terlalu lama.
	tsLambat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(100 * time.Millisecond) // server sengaja lambat mengirim respons
		fmt.Fprintln(w, "akhirnya selesai")
	}))
	defer tsLambat.Close()

	klienCepat := &http.Client{Timeout: 30 * time.Millisecond}

	_, err := klienCepat.Get(tsLambat.URL + "/lambat")
	fmt.Println("permintaan lambat gagal karena timeout:", err != nil)
	fmt.Println("error bertipe deadline:", errors.Is(err, context.DeadlineExceeded))
	if err != nil && strings.Contains(err.Error(), "context deadline") {
		pesan := err.Error()
		i := strings.Index(pesan, "context deadline")
		fmt.Println("pesan inti error:", pesan[i:])
	}

	// Bagian 2: ReadHeaderTimeout memutus klien yang tersambung tapi diam.
	tsKetat := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	}))
	tsKetat.Config.ReadHeaderTimeout = 60 * time.Millisecond
	tsKetat.Start()
	defer tsKetat.Close()

	koneksi, err := net.Dial("tcp", strings.TrimPrefix(tsKetat.URL, "http://"))
	if err != nil {
		fmt.Println("gagal terhubung ke server:", err)
		return
	}
	// klien nakal: sudah tersambung, tetapi tidak pernah mengirim permintaan.
	time.Sleep(200 * time.Millisecond)

	buf := make([]byte, 1)
	_, err = koneksi.Read(buf)
	fmt.Println("server memutus klien yang diam:", errors.Is(err, io.EOF))
	koneksi.Close()

	// Bagian 3: keep-alive memakai ulang koneksi TCP yang sama.
	var jumlahKoneksi int64
	dialer := &net.Dialer{Timeout: 2 * time.Second}
	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			jumlahKoneksi++
			return dialer.DialContext(ctx, network, addr)
		},
	}
	klien := &http.Client{Transport: transport, Timeout: 2 * time.Second}

	tsStabil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "pong")
	}))
	defer tsStabil.Close()

	ping := func() {
		resp, err := klien.Get(tsStabil.URL)
		if err != nil {
			return
		}
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}

	ping()
	ping()
	fmt.Println("dua permintaan keep-alive, koneksi TCP dibuat:", jumlahKoneksi)

	req, err := http.NewRequest(http.MethodGet, tsStabil.URL, nil)
	if err != nil {
		return
	}
	req.Close = true // kirim header Connection: close
	resp, err := klien.Do(req)
	if err == nil {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}
	fmt.Println("setelah Connection: close, koneksi TCP dibuat:", jumlahKoneksi)

	ping() // koneksi lama sudah ditutup, klien harus mendial ulang
	fmt.Println("permintaan setelah koneksi ditutup, koneksi TCP dibuat:", jumlahKoneksi)
}

Output yang Dihasilkan:

text
permintaan lambat gagal karena timeout: true
error bertipe deadline: true
pesan inti error: context deadline exceeded (Client.Timeout exceeded while awaiting headers)
server memutus klien yang diam: true
dua permintaan keep-alive, koneksi TCP dibuat: 1
setelah Connection: close, koneksi TCP dibuat: 1
permintaan setelah koneksi ditutup, koneksi TCP dibuat: 2

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 17-21: httptest.NewServer menjalankan server sungguhan di loopback; handler-nya tidur 100 milidetik untuk mensimulasikan server lambat.
  2. Baris 23: klien diberi Timeout: 30 * time.Millisecond, jauh di bawah waktu kerja handler, sehingga permintaan pasti terpotong.
  3. Baris 25-27: hasil Get diperiksa dengan errors.Is(err, context.DeadlineExceeded) — error timeout klien berakar pada context.
  4. Baris 28-32: pesan error memuat URL yang berubah tiap eksekusi, jadi hanya bagian mulai context deadline yang dicuplik agar output stabil.
  5. Baris 35-39: httptest.NewUnstartedServer mengizinkan field ReadHeaderTimeout diisi sebelum server dinyalakan dengan Start().
  6. Baris 42-53: program menjadi klien nakal lewat net.Dial mentah — tersambung tetapi tidak mengirim permintaan; Read mengembalikan io.EOF karena server menutup koneksi setelah tenggat 60 milidetik.
  7. Baris 56-64: http.Transport kustom menyisipkan penghitung di DialContext sehingga tiap koneksi TCP baru tercatat di jumlahKoneksi.
  8. Baris 71-78: fungsi ping membaca body sampai habis sebelum menutup — wajib agar koneksi kembali ke pool untuk dipakai ulang.
  9. Baris 80-82: dua permintaan berurutan hanya menghasilkan satu dial TCP; inilah bukti keep-alive bekerja.
  10. Baris 88-97: req.Close = true mengirim header Connection: close; koneksi ditutup dan permintaan berikutnya mendial ulang — penghitung naik jadi dua.

Keep-Alive dan Connection: close #

Protokol HTTP/1.1 mengirim banyak permintaan lewat satu koneksi TCP yang sama, mekanisme yang disebut keep-alive. Membuka koneksi baru mahal: ada handshaking TCP dan salinan TLS bila HTTPS dipakai. Karena itu pool pada http.Transport menyimpan koneksi idle untuk permintaan berikutnya. Demo di atas menunjukkan efeknya secara terukur: dua permintaan berurutan hanya memerlukan satu koneksi.

Koneksi yang dipakai ulang punya masa hidup terbatas. Field IdleConnTimeout menutup koneksi idle yang lama tak terpakai, sementara header Connection: close meminta penutupan segera setelah satu respons. Penutupan ini berguna untuk operasi sekali jalan seperti health check yang tidak ingin menahan slot pool.

Dengan timeout di kedua sisi dan pool koneksi sehat, layanan kita tahan terhadap pihak yang nakal. Timeout tetaplah pemutus darurat; cara yang lebih halus adalah membatalkan permintaan aktif lewat context, topik chapter berikutnya.

Referensi #

Berkas Sumber

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

contoh.go

Unduh berkas
go
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func main() {
	// Bagian 1: client.Timeout memutus permintaan yang terlalu lama.
	tsLambat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(100 * time.Millisecond) // server sengaja lambat mengirim respons
		fmt.Fprintln(w, "akhirnya selesai")
	}))
	defer tsLambat.Close()

	klienCepat := &http.Client{Timeout: 30 * time.Millisecond}

	_, err := klienCepat.Get(tsLambat.URL + "/lambat")
	fmt.Println("permintaan lambat gagal karena timeout:", err != nil)
	fmt.Println("error bertipe deadline:", errors.Is(err, context.DeadlineExceeded))
	if err != nil && strings.Contains(err.Error(), "context deadline") {
		pesan := err.Error()
		i := strings.Index(pesan, "context deadline")
		fmt.Println("pesan inti error:", pesan[i:])
	}

	// Bagian 2: ReadHeaderTimeout memutus klien yang tersambung tapi diam.
	tsKetat := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	}))
	tsKetat.Config.ReadHeaderTimeout = 60 * time.Millisecond
	tsKetat.Start()
	defer tsKetat.Close()

	koneksi, err := net.Dial("tcp", strings.TrimPrefix(tsKetat.URL, "http://"))
	if err != nil {
		fmt.Println("gagal terhubung ke server:", err)
		return
	}
	// klien nakal: sudah tersambung, tetapi tidak pernah mengirim permintaan.
	time.Sleep(200 * time.Millisecond)

	buf := make([]byte, 1)
	_, err = koneksi.Read(buf)
	fmt.Println("server memutus klien yang diam:", errors.Is(err, io.EOF))
	koneksi.Close()

	// Bagian 3: keep-alive memakai ulang koneksi TCP yang sama.
	var jumlahKoneksi int64
	dialer := &net.Dialer{Timeout: 2 * time.Second}
	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			jumlahKoneksi++
			return dialer.DialContext(ctx, network, addr)
		},
	}
	klien := &http.Client{Transport: transport, Timeout: 2 * time.Second}

	tsStabil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "pong")
	}))
	defer tsStabil.Close()

	ping := func() {
		resp, err := klien.Get(tsStabil.URL)
		if err != nil {
			return
		}
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}

	ping()
	ping()
	fmt.Println("dua permintaan keep-alive, koneksi TCP dibuat:", jumlahKoneksi)

	req, err := http.NewRequest(http.MethodGet, tsStabil.URL, nil)
	if err != nil {
		return
	}
	req.Close = true // kirim header Connection: close
	resp, err := klien.Do(req)
	if err == nil {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}
	fmt.Println("setelah Connection: close, koneksi TCP dibuat:", jumlahKoneksi)

	ping() // koneksi lama sudah ditutup, klien harus mendial ulang
	fmt.Println("permintaan setelah koneksi ditutup, koneksi TCP dibuat:", jumlahKoneksi)
}