Bir senaryo: Bir dış API’ye istek atan küçük bir Go servisi yazdınız. Yerelde kusursuz. Testlerde yeşil. Canlıya aldınız, ilk günler sorunsuz. Sonra trafik arttıkça istekler yavaşlamaya, gecikme grafiği tırmanmaya, sunucuda TIME_WAIT durumundaki bağlantılar birikmeye başladı. Kod değişmedi — sadece yük arttı.
Çoğu zaman suçlu, gözden kaçan tek bir satırdır: yanıt gövdesinin (resp.Body) nasıl kapatıldığı. Ve işin sinsi tarafı, resp.Body.Close() yazmış olmanızın bile yetmeyebilmesidir. Gelin net/http’nin kaputunu açıp mekanizmayı görelim.
Mesele Kapatmak Değil, Bağlantıyı Havuza İade Etmek
Go’nun net/http istemcisi performans için bağlantı havuzu (connection pooling) ve keep-alive kullanır. Yani her istekte yeni bir TCP el sıkışması (üstüne TLS) yapmak yerine, mevcut bağlantıyı yeniden kullanmaya çalışır. Bu, yüksek trafikte hayat kurtaran bir optimizasyondur.
Ama bir HTTP/1.x bağlantısının havuza geri dönüp yeniden kullanılabilmesi için iki koşul vardır:
- Yanıt gövdesi (
resp.Body) sonuna (EOF) kadar okunmalı, - Ardından kapatılmalı (
Close).
İkisinden biri eksikse, Transport o bağlantının temiz olmadığını varsayar ve havuza koymak yerine kapatır. Sonuç: her istek yeni bir bağlantı açar, işi biter bitmez onu çöpe atar. Az trafikte fark etmezsiniz. Yük altında ise sürekli TCP/TLS el sıkışması, dolan efemeral port aralığı ve TIME_WAIT yığını olarak geri döner.
Üç Kılığa Giren Aynı Hata
1. Gövdeyi hiç kapatmamak
En bariz olanı. Gövde bir io.ReadCloser’dır; kapatılmazsa dosya tanımlayıcısı (fd) ve bağlantı sızar. Yeterince tekrarlanırsa uygulama “too many open files” ile çöker.
2. Hatayı kontrol etmeden kapatmak
client.Do hata dönerse resp nil olabilir. Aşağıdaki gibi bir defer, hata anında nil.Body’e erişip panikletir:
resp, err := client.Do(req)
defer resp.Body.Close() // BUG: err != nil ise resp nil, burada panic
if err != nil {
return err
}
Doğrusu, önce hatayı kontrol edip sonra defer etmektir.
3. En sinsisi: kapatmak ama gövdeyi boşaltmamak
Bu, “her şeyi doğru yaptım” sandığınız durumdur. Gövdeyi kapatırsınız ama içeriğini okumazsınız (ör. sadece durum koduna bakıp geçersiniz). Kod çalışır, hata vermez — ama bağlantı yeniden kullanılamaz, çünkü birinci koşul (EOF’a kadar okuma) sağlanmamıştır. İşte gecikmeyi sessizce artıran satır budur.
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close() // gövde okunmadı -> bağlantı havuza dönmez
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("beklenmeyen durum: %d", resp.StatusCode)
}
Doğru Desen
Üç kuralı tek bir yerde toplayalım: önce hatayı kontrol et, gövdeyi defer ile kapat, gövdeyi sonuna kadar tüket.
func fetchUser(client *http.Client, url string) (*User, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("istek oluşturulamadı: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("istek başarısız: %w", err)
}
// Hata kontrolünden SONRA defer: resp burada nil değil.
defer func() {
// Gövdeyi EOF'a kadar boşalt ki bağlantı havuza dönebilsin,
// sonra kapat. io.Discard tahsis yapmadan yutar.
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("beklenmeyen durum: %d", resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("yanıt çözümlenemedi: %w", err)
}
return &user, nil
}
Not: Gövdeyi json.NewDecoder veya io.ReadAll ile tamamen okuduğunuzda birinci koşul kendiliğinden sağlanır. io.Copy(io.Discard, ...) özellikle erken return ettiğiniz (ör. durum kodu hatalıysa) yollarda, gövdenin okunmadan kalmasına karşı bir sigortadır.
İki Bonus Tuzak: Timeout ve Tek Client
Bağlantı sızıntısını çözerken yanında dolaşan iki hatayı da kapatın.
http.Get ve http.DefaultClient’ın timeout’u yoktur. Asılı kalan bir sunucu, isteği yapan goroutine’i sonsuza kadar bloke eder. Her zaman kendi client’ınızı zaman aşımıyla kurun:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, // varsayılan yalnızca 2'dir
IdleConnTimeout: 90 * time.Second,
},
}
MaxIdleConnsPerHost varsayılanı 2’dir. Tek bir host’a yüksek eşzamanlılıkla istek atıyorsanız, havuzdaki 2 boşta bağlantının ötesindeki her istek yeni bağlantı açıp kapatır. Bu değeri iş yükünüze göre yükseltmek, keep-alive’ın gerçekten devreye girmesini sağlar.
Her istekte yeni http.Client oluşturmayın. Havuz Transport’un içindedir; her yeni Client kendi havuzunu kurar ve hiçbir bağlantı paylaşılmaz. Tek bir client örneği oluşturup uygulama boyunca (goroutine’ler arası güvenlidir) paylaşın. Bu deseni dış API çağrılarını Clean Architecture ile yönettiğim yazıda bir adım ileri taşıyorum.
Sorunu Kanıtlamak
Tahminle değil, ölçerek ilerleyin. Yük altında sunucuda ss -tan | grep TIME-WAIT | wc -l ile birikmeyi görebilir; net/http/httptrace paketiyle bir bağlantının yeniden mi kullanıldığını yoksa yeni mi açıldığını (GotConn içindeki Reused alanı) doğrulayabilirsiniz. Bağlantılar yeniden kullanılıyorsa Reused: true görürsünüz — asıl hedefiniz budur.
Özet
resp.Body’i doğru ele almak üç kuraldan ibaret: hatayı kontrol et, gövdeyi boşalt, kapat. Close() tek başına fd sızıntısını önler ama bağlantının yeniden kullanılmasını garanti etmez — bunun için gövdenin sonuna kadar okunması şarttır. Buna timeout’lu, paylaşılan tek bir client eklediğinizde, yük altında sessizce yavaşlayan servis, sabit ve öngörülebilir bir servise dönüşür.
Bu tarz “tek satırlık ama pahalı” tuzakların derlemesi için Yüzlerce Go Reposu İnceledim: En Sık Yapılan 5 Hata yazısına; io, net/http gibi paketlerin gücünü tam kullanmak için Go standart kütüphane yazıma göz atabilirsiniz.