Ringkasan Eksekutif

Saya baru baca postingan di Go blog yang ngulik kenapa reuse koneksi HTTP/1 di net/http bisa gagal tanpa kelihatan di permukaan, dan gimana Go 1.27 akhirnya nambal ini secara otomatis. Yang bikin saya kaget: cuma gara-gara response body nggak dibaca sampai habis, benchmark 100 request ke index.golang.org turun dari ~710ms di Go 1.26 jadi ~150ms di Go 1.27 — hampir 5x lebih cepat, dan itu cuma pakai jalur HTTP/1 (GODEBUG=http2client=0).

Masalah yang Diselesaikan

Intinya soal biaya bikin koneksi baru. Setiap kali klien buka koneksi baru ke server HTTPS, ada TCP handshake (1 RTT), lalu TLS handshake yang makan 2 RTT kalau pakai TLS 1.2 atau bisa 1 RTT kalau TLS 1.3 — jadi minimal 3 RTT cuma buat siap-siap sebelum request pertama benar-benar terkirim. Di komunikasi lintas region yang RTT-nya bisa 100ms, itu udah 300ms yang hilang sebelum request sungguhan jalan.

Masalahnya, di HTTP/1 (baik 1.0 maupun 1.1), response-response dikirim berurutan di atas satu stream byte yang sama. net/http cuma bisa tahu di mana response berikutnya mulai setelah body response saat ini dibaca tuntas sampai batas Content-Length atau Transfer-Encoding. Kalau kode kita cuma baca status code dan nggak pernah menyentuh resp.Body, sampai Go 1.26 net/http menganggap koneksi itu nggak bisa dipakai ulang, lalu membuang koneksinya dan bikin koneksi baru lagi di request berikutnya — padahal servernya cuma kirim body kecil semacam {"success": true} yang kita abaikan.

Yang lebih bikin geregetan, ini bisa kejadian meski kita "sudah" baca body. encoding/json.Decoder berhenti baca begitu nemu } penutup JSON — kalau server masih nyisain newline di belakangnya, satu byte itu aja cukup bikin koneksi dianggap nggak reusable. HTTP/2 nggak kena masalah ini karena dia multiplexing beberapa stream di satu koneksi, jadi body yang belum dibaca di satu stream nggak ngeblok stream lain.

Fitur Utama dan Contoh Kode

Contoh fungsi yang jadi pemicu masalah ini persis seperti ini:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

Selama ini banyak developer Go nulis workaround sendiri, biasanya nge-drain body dulu sebelum close. Versi paling lengkap yang dibahas di artikel menggabungkan tiga hal: batasi jumlah byte yang di-drain (maksimal 256KB lewat io.CopyN), batasi waktu drain (maksimal 50ms via goroutine + time.After), dan jalankan drain-nya secara asinkron biar nggak mengeblok Close():

func drainAndCloseBody(body io.ReadCloser) {
    drainDone := make(chan struct{}, 1)
    go func() {
        io.CopyN(io.Discard, body, 256<<10) // Drain maksimal 256 KB
        drainDone <- struct{}{}
    }()
    go func() {
        select {
        case <-drainDone:
        case <-time.After(50 * time.Millisecond):
        }
        body.Close()
    }()
}

Nah, inti beritanya: mulai Go 1.27, net/http.Transport sudah melakukan semua ini secara otomatis di dalam dirinya sendiri — nge-drain body HTTP/1 yang belum terbaca secara asinkron, dengan batas 50ms atau 256KB. Ada optimisasi tambahan juga: kalau response punya header Connection: close, Transport langsung skip proses drain karena koneksinya pasti bakal dibuang juga, jadi drain cuma buang-buang kerjaan.

Dampak dan Catatan Upgrade

Rekomendasi barunya simpel: cukup defer resp.Body.Close() seperti kode aslinya, nggak perlu lagi nulis drainAndCloseBody custom. Ini artinya workaround-workaround manual yang selama ini beredar di codebase banyak orang (termasuk yang salah kaprah ikut nge-drain body di koneksi HTTP/2, padahal nggak perlu) sekarang bisa disederhanakan atau dihapus karena net/http sudah menangani ini secara internal sejak Go 1.27. Tim Go juga kasih catatan bahwa perilaku draining otomatis ini masih bisa berubah di versi-versi Go berikutnya seiring ada optimisasi baru yang ditemukan.

---

Pertama diumumkan di Go blog. Baca sumber aslinya untuk detail lengkap.

Baca juga