Gọi HTTP trong Go trông đơn giản tới mức dễ làm sai. Bài này về ba chỗ đó.

http.DefaultClient không có hạn chờ

  http.DefaultClient.Timeout = 0s     <<< KHÔNG có hạn chờ

http.Get(url) dùng DefaultClient, và Timeout bằng 0 nghĩa là chờ vĩnh viễn.

Một dịch vụ bên kia treo là goroutine của bạn treo theo, mãi mãi. Với dịch vụ có tải, đó là cách rò rỉ goroutine nhanh nhất — bài 38 đã nói.

var client = &http.Client{Timeout: 10 * time.Second}

Timeout này bao gồm toàn bộ: kết nối, gửi, chờ, đọc hết body. Đây là hàng rào cuối cùng.

Kiểm soát mịn hơn thì dùng context:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)

Luôn dùng NewRequestWithContext thay vì http.Get trong mã thật — nó cho phép huỷ theo request cha, và đó là điều bài 29 nói về lan truyền huỷ.

Phải đọc hết body

  đọc hết body   :  8 ms
  không đọc hết  : 26 ms

Ba lần chênh lệch cho 200 request.

Nguyên nhân: Go chỉ trả kết nối về pool để tái dùng khi body đã đọc hết và đóng. Bỏ dở phần thân là Go phải đóng cả kết nối TCP, và request sau phải bắt tay lại từ đầu.

Mẫu đúng:

resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()

// ... đọc bình thường ...

// nếu bỏ qua phần còn lại:
io.Copy(io.Discard, resp.Body)

defer resp.Body.Close()bắt buộc trong mọi trường hợp — không đóng là rò rỉ file descriptor, và sau vài giờ bạn nhận too many open files.

Thứ tự cũng quan trọng: kiểm err trước, vì khi có lỗi thì resp là nil và resp.Body.Close() sẽ panic.

Lỗi HTTP không phải lỗi Go

resp, err := client.Do(req)
if err != nil { return err }        // chỉ lỗi MẠNG
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("máy chủ trả %d", resp.StatusCode)
}

err chỉ khác nil khi không hoàn thành được lời gọi: không phân giải được tên miền, không kết nối được, hết giờ. Máy chủ trả 404 hay 500 đều là lời gọi thành công.

Đây đúng bài học ở bài 64 sê-ri Java, và nó gây ra cùng loại lỗi: đưa thân của một trang lỗi HTML vào bộ phân tích JSON.

Cấu hình Transport cho dịch vụ thật

Transport mặc định ổn cho công cụ dòng lệnh, nhưng dịch vụ gọi nhiều nên chỉnh:

var client = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		MaxIdleConns:        100,
		MaxIdleConnsPerHost: 20,     // mặc định chỉ 2
		IdleConnTimeout:     90 * time.Second,
		DialContext: (&net.Dialer{
			Timeout:   5 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
	},
}

MaxIdleConnsPerHost mặc định là 2. Với dịch vụ gọi liên tục một backend, con số đó làm phần lớn request phải bắt tay TCP lại — cùng vấn đề với phép đo ở đầu bài, chỉ ở quy mô lớn hơn.

Tạo Client một lần và dùng lại. Nó an toàn với goroutine. Tạo mới mỗi lần gọi là mất hết pool kết nối, và đó là lỗi hiệu năng phổ biến.

Thử lại

Thư viện chuẩn không có cơ chế thử lại. Tự viết thì nhớ ba điều:

Chỉ thử lại thao tác idempotent — GET, PUT, DELETE. Thử lại POST có thể tạo hai đơn hàng.

Lùi theo cấp số nhân cộng nhiễu ngẫu nhiên, nếu không mọi client cùng thử lại một lúc.

Đọc và đóng body trước khi thử lại, nếu không mỗi lần thử là một kết nối rò rỉ.

for lan := 0; lan < 3; lan++ {
	resp, err := client.Do(req)
	if err == nil && resp.StatusCode < 500 { return resp, nil }
	if resp != nil { io.Copy(io.Discard, resp.Body); resp.Body.Close() }
	time.Sleep(time.Duration(1<<lan) * 100 * time.Millisecond)
}

Chú ý: req.Body chỉ đọc được một lần. Thử lại request có thân thì phải dùng req.GetBody.

Thử ba mươi giây

grep -rn 'http.Get\|http.Post\|http.DefaultClient' --include='*.go' .

Mỗi kết quả là một lời gọi không có hạn chờ. Trong dịch vụ chạy lâu, đó là chỗ sẽ treo vào ngày backend kia gặp sự cố — và nó sẽ kéo dịch vụ của bạn chết theo.

Ngày mai: middleware và router — tự viết middleware trong bảy dòng.