Mert Özen Her Satırda Daha İleri
Backend Go ile Backend — #19

Go'da HTTP Client: http.Client, Timeout'lar, context ile İptal, Transport'u Yeniden Kullanmak ve Retry

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
Go'da HTTP Client: http.Client, Timeout'lar, context ile İptal, Transport'u Yeniden Kullanmak ve Retry

Go'da http.DefaultClient'ın timeout'u yoktur; üretimde paylaşılan tek bir http.Client, her aşama için Transport timeout'ları, deneme başına Client.Timeout ve toplam bütçe için context kullanılır. Hangi timeout'un hangi aşamayı kapsadığı, Go 1.27'nin küçük body'leri Close'ta okuyup bağlantıyı geri kazanması, MaxIdleConnsPerHost, 4xx/5xx kontrolü, backoff, jitter ve Retry-After ile retry, idempotency ve httptest ile client testi.

Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; Bookstore module'ünün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenip testlerden geçer. Connection reuse ölçümü Go 1.27'deki bir davranış değişikliğini gösterdiği için iki sürümle de çalıştırıldı. Bookstore.Api'nin stok endpoint'i bu yazıda Go ile yazılmış küçük bir taklit sunucuyla temsil ediliyor; .NET servisinin kendisi çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Go servisi, stok bilgisini .NET tarafındaki Bookstore.Api'den alıyor. Bir lab aynı paylaşılan http.Client ile arka arkaya 50 istek gönderiyor ve sunucu tarafında kaç yeni TCP bağlantısı açıldığını sayıyor. İkinci ve üçüncü run çağrısında cevabın body'si hiç okunmadan sadece Close ediliyor:

same := func() *http.Client { return shared }
readAndClose := func(r *http.Response) { io.Copy(io.Discard, r.Body); r.Body.Close() }
closeOnly := func(r *http.Response) { r.Body.Close() }

run("read + Close, 1 KB", same, "small", readAndClose)
run("Close without reading, 1 KB", same, "small", closeOnly)
run("Close without reading, 1 MB", same, "big", closeOnly)

Go 1.26.11 ve Go 1.27.4 ile kaç bağlantı açılır?

$ GOTOOLCHAIN=go1.26.11 go run ./labs/19-reuse
read + Close, 1 KB                  1 connections,  +3 goroutines
Close without reading, 1 KB        50 connections,  +0 goroutines
Close without reading, 1 MB        50 connections,  +0 goroutines
...
$ go run ./labs/19-reuse      # Go 1.27.4
read + Close, 1 KB                  1 connections,  +3 goroutines
Close without reading, 1 KB         1 connections,  +3 goroutines
Close without reading, 1 MB        50 connections,  +0 goroutines
...

Go 1.26'da okunmadan kapatılan her body bağlantıyı da kapattı ve her istek yeni bir TCP bağlantısıyla başladı. Go 1.27, 14'te andığımız değişiklikle küçük body'leri Close sırasında kendisi okuyor ve bağlantıyı pool'a geri koyuyor; 1 MB'lık body'de ise hâlâ bağlantıyı kapatıyor. Bu yazıda Go'da HTTP client yazmayı görüyoruz: http.Client ve Transport, hangi timeout'un neyi kapsadığı, context ile iptal, body ve connection reuse, retry ve test. Sonunda Bookstore'un stok servisini çağıran bir inventory.Client olacak.

http.Client, Transport ve Varsayılanlar

http.Client ince bir katman: redirect'leri, cookie'leri ve toplam süreyi yönetiyor. Asıl işi yapan http.Transport; connection pool, TLS (Transport Layer Security; HTTPS'in şifreleme katmanı), proxy ve HTTP/2 onun içinde. .NET'teki karşılıklarıyla düşünürsen Client ≈ HttpClient, Transport ≈ SocketsHttpHandler. Varsayılanlar şöyle:

  • http.DefaultClient (ve http.Get) Timeout: 0 ile geliyor, yani toplam süre sınırı yok.
  • http.DefaultTransport bağlantı kurmak için 30 sn, TLS handshake için 10 sn tanıyor ve idle bağlantıları 90 sn tutuyor; cevap header'larını beklemek için bir sınırı yok.
  • MaxIdleConnsPerHost varsayılan olarak 2: aynı sunucuya en fazla iki boşta bağlantı saklanıyor.

.NET'teki eski new HttpClient() tuzağının Go karşılığı her istekte yeni bir Transport oluşturmak; doğrusu, client'ı bir kere oluşturup bütün goroutine'ler arasında paylaşmak. http.Client ve Transport aynı anda birden çok goroutine'den kullanılmak için tasarlandı. External Service Integration ve Resilience serisinin IHttpClientFactory yazısında anlattığımız handler ömrüne bir fark var: Go'nun Transport'unda bir bağlantının en uzun ömrünü sınırlayan bir ayar yok, IdleConnTimeout sadece boşta bekleyen bağlantıları kapatıyor.

Timeout'lar: Hangi Aşamayı Kim Sayar?

Bir HTTP isteği birkaç aşamadan geçiyor ve Go'da her aşamanın kendi ayarı var:

19-client-timeouts-tr.png

Ayarların davranışını labs/19-timeouts'ta yavaş cevap veren bir taklit stok servisiyle denedim. slow-headers cevaba başlamadan önce 2 saniye bekliyor; slow-body header'ları hemen gönderip body'yi 2 saniye sonra yazıyor:

fmt.Println("http.DefaultClient.Timeout =", http.DefaultClient.Timeout)

fmt.Println("Client.Timeout = 1s:")
c := &http.Client{Timeout: time.Second}
get(c, context.Background(), base+"slow-headers")
get(c, context.Background(), base+"slow-body")

fmt.Println("Transport.ResponseHeaderTimeout = 1s:")
t := http.DefaultTransport.(*http.Transport).Clone()
t.ResponseHeaderTimeout = time.Second
c = &http.Client{Transport: t}
get(c, context.Background(), base+"slow-headers")
get(c, context.Background(), base+"slow-body") // kapsamaz: header'lar hızlı geldi

fmt.Println("context.WithTimeout 1s, no client timeout:")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
get(http.DefaultClient, ctx, base+"slow-body")
$ go run ./labs/19-timeouts
http.DefaultClient.Timeout = 0s
Client.Timeout = 1s:
  no response                 after 1s: Get "http://127.0.0.1:52716/api/stock/slow-headers": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
  200 OK                      after 1s: context deadline exceeded (Client.Timeout or context cancellation while reading body)
Transport.ResponseHeaderTimeout = 1s:
  no response                 after 1s: Get "http://127.0.0.1:52716/api/stock/slow-headers": net/http: timeout awaiting response headers
  200 OK                      after 2s: <nil>
context.WithTimeout 1s, no client timeout:
  200 OK                      after 1s: context deadline exceeded
a 500 is not an error:
  500 Internal Server Error   after 0s: <nil>
Transport.TLSHandshakeTimeout = 1s:
  no response                 after 1s: Get "https://127.0.0.1:52717/api/stock/9780441172719": net/http: TLS handshake timeout

Üç şey dikkat çekiyor. Client.Timeout body okunurken de sayıyor: Do başarılı döndü, 200 geldi, hata io.ReadAll'dan çıktı. ResponseHeaderTimeout ise header'lar gelince duruyor; yavaş body'de istek 2 saniye sürdü ve hiçbir timeout devreye girmedi. context.WithTimeout Client.Timeout ile aynı aralığı kapsıyor, farkı çağırana ait olması: handler'daki r.Context() istemci bağlantıyı kopardığında da iptal oluyor ve bu iptal dış servise yapılan çağrıya kadar iniyor. TLS satırı için bağlantıyı kabul edip hiç cevap vermeyen bir TCP sunucusu kullandım. net.Dialer.Timeout'u denemedim; bunun için yerel makinede cevap vermeyen bir adres gerekiyor.

Kural olarak üç katman kullan: her aşamaya kısa bir Transport timeout'u (yavaş bağlantıyı erken fark etmek için), tek bir denemeyi sınırlayan Client.Timeout ve bütün işin, yani retry'lar dahil toplam sürenin bütçesi olan context.

Body'yi Kapatmak ve Connection Reuse

Önce Tahmin Et'teki lab'ın tamamı:

$ go run ./labs/19-reuse
read + Close, 1 KB                  1 connections,  +3 goroutines
Close without reading, 1 KB         1 connections,  +3 goroutines
Close without reading, 1 MB        50 connections,  +0 goroutines
new Transport per request          50 connections, +150 goroutines
no Close at all, 1 KB              50 connections, +150 goroutines
100 requests, 20 at a time, MaxIdleConnsPerHost=0   92 connections
100 requests, 20 at a time, MaxIdleConnsPerHost=20  20 connections

"+3 goroutines" açık duran bir bağlantı: client'ın okuma ve yazma goroutine'leri ile sunucunun bağlantı goroutine'i. Go 1.27.4'ün kaynak kodunda bu boşaltmanın sınırı 256 KB ve 50 ms; daha büyük ya da yavaş bir body'de bağlantı yine kapanıyor. Her istekte yeni Transport ve hiç Close etmemek ise en kötü iki durum: 50 istek 50 bağlantı ve 150 goroutine bıraktı ve bunlar program bitene ya da sunucu bağlantıyı kapatana kadar duruyor. Uzun çalışan bir serviste bu, dosya tanımlayıcılarının tükenmesi demek. Son iki satır, aynı anda 20 istek gönderen bir servisin varsayılan MaxIdleConnsPerHost=2 ile neredeyse her isteği yeni bağlantıyla yaptığını gösteriyor; pool'a sadece iki bağlantı geri dönebiliyor. Kural değişmedi: defer resp.Body.Close() her zaman ve body'yi işin bittiğinde sonuna kadar oku ya da boyutunu io.LimitReader ile sınırla.

Bookstore: inventory.Client

Yeni internal/inventory package'ı yukarıdaki kuralları tek yerde topluyor. Client bir kere oluşturuluyor ve her aşamanın timeout'u açıkça yazılıyor:

// NewClient her aşama için açık timeout'ları olan bir Client döner.
func NewClient(baseURL string) *Client {
    transport := &http.Transport{
        Proxy:                 http.ProxyFromEnvironment,
        DialContext:           (&net.Dialer{Timeout: 2 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
        TLSHandshakeTimeout:   3 * time.Second,
        ResponseHeaderTimeout: 3 * time.Second, // isteğin bitişinden cevap header'larına kadar
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   32, // varsayılan 2
        IdleConnTimeout:       90 * time.Second,
        ForceAttemptHTTP2:     true,
    }
    return &Client{
        BaseURL: baseURL,
        HTTP:    &http.Client{Transport: transport, Timeout: 5 * time.Second}, // tek deneme, body dahil
        Retries: 2,
        Backoff: 100 * time.Millisecond,
    }
}
func (c *Client) getJSON(ctx context.Context, path string, dst any) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
    if err != nil {
        return err
    }
    req.Header.Set("Accept", "application/json")
    resp, err := c.HTTP.Do(req)
    if err != nil {
        return err // cevap yok: bağlantı, timeout ya da iptal
    }
    defer resp.Body.Close() // her zaman; yoksa connection pool'a hiç dönmez

    switch {
    case resp.StatusCode == http.StatusNotFound:
        return ErrNotFound
    case resp.StatusCode != http.StatusOK: // http.Client için 4xx ya da 5xx bir hata değildir
        body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
        return &StatusError{Code: resp.StatusCode, Body: string(body), RetryAfter: retryAfter(resp.Header)}
    }
    if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(dst); err != nil {
        return fmt.Errorf("decode stock level: %w", err)
    }
    return nil
}

http.Client için 404 ya da 500 bir hata değil, cevap; err sadece cevap hiç gelmediğinde dolu. Status code'u kontrol etmek bu yüzden senin işin. Beklenmeyen bir cevap, body'nin ilk 256 byte'ı ile birlikte *StatusError olarak dönüyor; çağıran taraf errors.As ile status code'a bakabiliyor, 404 ise ErrNotFound sentinel'i oluyor. url.PathEscape, ISBN'in (kitaplara verilen 13 haneli uluslararası numara) içinde / gibi karakterler olsa bile path'in bozulmamasını sağlıyor; 15'teki NormalizeISBN zaten bunları reddediyor ama client çağıranına güvenmiyor.

Retry, Backoff ve Idempotency

Bir istek ancak tekrar gönderildiğinde aynı sonucu veriyorsa, yani idempotent ise retry güvenli. GET öyle; sipariş oluşturan bir POST değil. Stock geçici olabilecek hataları iki kez daha deniyor:

// retry, call başarılı olana, kalıcı olarak başarısız olana ya da ctx bitene kadar çalıştırır.
func (c *Client) retry(ctx context.Context, call func() error) error {
    wait := c.Backoff
    for attempt := 0; ; attempt++ {
        err := call()
        if err == nil || attempt == c.Retries || !temporary(err) || ctx.Err() != nil {
            return err
        }
        d := wait / 2
        if wait > 0 {
            d += rand.N(wait) // jitter: birlikte hata alan çağıranlar birlikte tekrar denemez
        }
        var se *StatusError
        if errors.As(err, &se) && se.RetryAfter > d {
            d = se.RetryAfter // ne kadar bekleneceğini en iyi sunucu bilir
        }
        select {
        case <-time.After(d):
        case <-ctx.Done():
            return errors.Join(err, context.Cause(ctx))
        }
        wait *= 2
    }
}
// temporary, bir deneme daha yapmanın işe yarayıp yaramayacağını söyler.
func temporary(err error) bool {
    var se *StatusError
    if errors.As(err, &se) {
        switch se.Code {
        case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError,
            http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
            return true
        }
        return false
    }
    var ue *url.Error // http.Client.Do'nun transport hataları için döndürdüğü tip
    return errors.As(err, &ue)
}

Bekleme her denemede iki katına çıkıyor ve rastgele bir jitter (rastgele sapma) ekleniyor; aynı anda hata alan yüz client aynı milisaniyede tekrar denemiyor. Sunucu Retry-After gönderdiyse onun söylediği süre bekleniyor. Context bittiğinde bekleme yarıda kesiliyor. labs/19-retry önce 503, sonra Retry-After: 1 ile 429 dönen bir sunucuya bağlanıyor:

$ go run ./labs/19-retry
  server: call 1 at 0s
  server: call 2 at 70ms
  server: call 3 at 1.07s
{ISBN:9780441172719 Available:12 Warehouse:IST-1} <nil> after 1.07s
  server: call 1 at 0s
  server: call 2 at 80ms
stock service: 429 Too Many Requests: "slow down\n"
context deadline exceeded after 500ms

İkinci çağrıda 500 ms'lik bir bütçe vardı; client Retry-After'ın istediği bir saniyeyi beklemeye başladı, context bitince durdu ve iki hatayı errors.Join ile birlikte döndü. Go'nun Transport'u da kendi içinde küçük bir retry yapıyor: pool'dan alınan bir bağlantı sunucu tarafından kapatılmışsa ve istek tekrar gönderilebilir durumdaysa isteği yeni bir bağlantıyla bir kez daha gönderiyor. Kaynak kodda bu karar Request.isReplayable'da: body'si olmayan ya da GetBody'si olan GET, HEAD, OPTIONS ve TRACE istekleri ile Idempotency-Key header'ı taşıyan istekler. POST'u güvenle tekrarlamak için de aynı yöntem kullanılıyor: istemci bir key üretiyor, sunucu aynı key'le gelen ikinci isteği yeniden işlemiyor. .NET'te bu işleri Polly ve Microsoft.Extensions.Http.Resilience yapıyor; External Service Integration ve Resilience serisinin Polly v8 yazısındaki circuit breaker ve rate limiter gibi parçalar Go'da standart kütüphanede yok. Gerekiyorsa küçük tutup kendin yazıyorsun ya da bir kütüphane seçiyorsun.

httptest ile Test

12'de handler'ları httptest ile test etmiştik; client testinde ters yönde kullanılıyor: gerçek bir sunucu yerine test içinde açılan bir sunucu, Bookstore.Api gibi davranıyor:

// stub Bookstore.Api gibi cevap verir; fail ilk cevapların
// status code'larını listeler.
func stub(t *testing.T, fail ...int) (*Client, *atomic.Int32) {
    t.Helper()
    var calls atomic.Int32
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/stock/{isbn}", func(w http.ResponseWriter, r *http.Request) {
        if n := int(calls.Add(1)); n <= len(fail) {
            w.Header().Set("Retry-After", "0")
            http.Error(w, "try again", fail[n-1])
            return
        }
        if r.PathValue("isbn") != "9780441172719" {
            http.NotFound(w, r)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"isbn":"9780441172719","available":12,"warehouse":"IST-1"}`))
    })
    srv := httptest.NewServer(mux)
    t.Cleanup(srv.Close)
    c := NewClient(srv.URL)
    c.Backoff = time.Millisecond
    return c, &calls
}
$ go test -v -run 'TestStock$' ./internal/inventory
=== RUN   TestStock
...
--- PASS: TestStock (0.01s)
    --- PASS: TestStock/ok (0.00s)
    --- PASS: TestStock/two_503_then_ok (0.01s)
    --- PASS: TestStock/429_then_ok (0.00s)
    --- PASS: TestStock/three_503:_retries_used_up (0.00s)
    --- PASS: TestStock/400_is_not_retried (0.00s)
PASS

Testler bekleme süresini 1 ms'ye indirip retry sayısını sunucu tarafındaki sayaçla kontrol ediyor. Üçüncü bir test, hiç cevap vermeyen bir sunucuya 300 ms'lik bir context ile gidip client'ın bu sürede durduğunu doğruluyor. Zamanı tamamen kontrol etmek istersen Go 1.27'nin httptest.NewTestServer'ı 12'deki synctest ile birlikte kullanılabiliyor.

Üretimde Dikkat

Her dış çağrının süresini ve sonucunu log'a ve metriklere yaz. 13'teki slog satırına status, attempt ve duration_ms eklemek, bir dış servisin yavaşladığını kendi servisinin yavaşlamasından ayırmanın en kolay yolu; metriklerin ayrıntısı Observability serisinde.

Retry'ı katmanlar arasında çarpma. API gateway 3 kez, Go servisi 3 kez, .NET servisi de veritabanına 3 kez denerse tek bir hata 27 isteğe dönüşür. Retry'ı tek bir katmanda yap ve toplam süreyi context ile sınırla.

Connection pool'u yüke göre ayarla. Aynı servise eş zamanlı 20 istek gönderiyorsan MaxIdleConnsPerHost'u en az o kadar yap; aksi hâlde her istek TCP ve TLS handshake'ini baştan yapar. Sunucunun idle bağlantıyı kapatma süresi client'ın IdleConnTimeout'undan kısaysa pool'daki bağlantılar sessizce ölür; Go bunu tekrar gönderilebilir istekler için kendisi düzeltiyor, diğerleri için hata görürsün.

Sık Düşülen Tuzaklar

http.Get ya da http.DefaultClient ile production'a çıkmak. Timeout yok; cevap vermeyen bir servis goroutine'leri sonsuza kadar bekletir.

Her istekte yeni http.Client ve Transport. Connection pool kaybolur, her istek yeni bağlantı açar ve eskiler idle olarak kalır.

resp.Body.Close()'u unutmak. Bağlantı ve iki goroutine istek başına sızar; err == nil olduğunda her zaman defer resp.Body.Close().

4xx ve 5xx'i hata saymamak. err == nil sadece bir cevap geldiğini söyler; status code'u kontrol etmeden body'yi decode etmek, bir hata sayfasını stok bilgisi gibi okumaya çalışmak demek.

POST'u körlemesine tekrarlamak. Sunucu ilk isteği işleyip cevabı gönderemediyse ikinci deneme ikinci bir sipariş oluşturur; ya tekrar deneme ya da bir Idempotency-Key kullan.

Küçük Bir Deneme

★☆☆ labs/19-reuse'a 300 KB'lık bir endpoint ekle ve Go 1.27.4'te okunmadan kapatılan body için kaç bağlantı açıldığına bak. Sonucu kaynak koddaki 256 KB sınırıyla açıkla.

★★☆ inventory.Client'a log/slog ile her denemeyi yazan bir log ekle (attempt, status, duration_ms) ve retryAfter'ın HTTP-date biçimini de http.ParseTime ile okumasını sağla. Tablo testine bir satır ekle.

★★★ orders.Service'in stok kontrolünü inventory.Client'a bağla: BOOKSTORE_INVENTORY_URL ayarı varsa sipariş yerleştirilmeden önce her satırın stoğu Bookstore.Api'den okunsun, yoksa bugünkü katalog kullanılsın. Stok servisi cevap vermediğinde siparişin 503 mü almalı, yoksa yerel stokla mı devam etmeli? Kararını yaz ve 12'deki fake'lerle test et.

Bu yazıyla Go ile Backend serisi bitiyor. On dokuz yazıda Go'nun felsefesinden ve toolchain'inden başlayıp tipler, interface'ler, error handling, generics, goroutine ve channel'lar, net/http ile framework'süz bir API, JSON, SQL Server, test, production ve sürüm yükseltmesini gördük; son beş yazıda da standart kütüphanenin her gün kullanılan package'larını: string'ler, zaman, collection'lar, dosyalar ve HTTP client. Bookstore'un Go servisi artık siparişi alan, stoğu düşen, log'u rapora çeviren ve başka bir servisi güvenle çağıran, tek binary'lik küçük bir uygulama. Buradan devam etmek istersen gRPC ve GraphQL serisi servisler arası iletişimi, Observability serisi log'ların yanına metrik ve trace eklemeyi, Docker ve Kubernetes serileri de bu binary'yi production'da çalıştırmayı ayrıntılı anlatıyor. Go'yu ikinci backend dili olarak denemek için en iyi yol, bu serideki kodu alıp kendi ekibindeki küçük bir yan servisi Go ile yazmak.