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:
| Lapisan | Pengaturan | Melindungi dari |
|---|---|---|
| Membuka koneksi TCP | net.Dialer{Timeout: ...} pada http.Transport | Server tidak bisa dihubungi sama sekali |
| Menunggu header respons | Transport.ResponseHeaderTimeout | Server menerima permintaan, tetapi lambat memproses |
| Keseluruhan permintaan | http.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:
| Field | Membatasi waktu untuk |
|---|---|
ReadHeaderTimeout | Klien mengirim baris permintaan dan header |
ReadTimeout | Klien mengirim keseluruhan permintaan termasuk body |
WriteTimeout | Server menulis respons ke klien |
IdleTimeout | Koneksi 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:
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:
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: 2Mari kita telaah bagian-bagian penting program di atas:
- Baris 17-21:
httptest.NewServermenjalankan server sungguhan di loopback; handler-nya tidur 100 milidetik untuk mensimulasikan server lambat. - Baris 23: klien diberi
Timeout: 30 * time.Millisecond, jauh di bawah waktu kerja handler, sehingga permintaan pasti terpotong. - Baris 25-27: hasil
Getdiperiksa denganerrors.Is(err, context.DeadlineExceeded)— error timeout klien berakar padacontext. - Baris 28-32: pesan error memuat URL yang berubah tiap eksekusi, jadi hanya bagian mulai
context deadlineyang dicuplik agar output stabil. - Baris 35-39:
httptest.NewUnstartedServermengizinkan fieldReadHeaderTimeoutdiisi sebelum server dinyalakan denganStart(). - Baris 42-53: program menjadi klien nakal lewat
net.Dialmentah — tersambung tetapi tidak mengirim permintaan;Readmengembalikanio.EOFkarena server menutup koneksi setelah tenggat 60 milidetik. - Baris 56-64:
http.Transportkustom menyisipkan penghitung diDialContextsehingga tiap koneksi TCP baru tercatat dijumlahKoneksi. - Baris 71-78: fungsi
pingmembaca body sampai habis sebelum menutup — wajib agar koneksi kembali ke pool untuk dipakai ulang. - Baris 80-82: dua permintaan berurutan hanya menghasilkan satu dial TCP; inilah bukti keep-alive bekerja.
- Baris 88-97:
req.Close = truemengirim headerConnection: 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 #
- Dokumentasi package net/http — klien dan server HTTP.
- Dokumentasi http.Client
— rincian field
Timeout. - Dokumentasi http.Server — field timeout sisi server.
- Dokumentasi net/http/httptest — server pengujian lokal.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.go
Unduh berkaspackage 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)
}