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

net/http ile HTTP Server: ServeMux Pattern Routing, Handler, Middleware ve Timeout'lar

Mert Özen 15 Kas 2026 13 dk 4 görüntülenme
net/http ile HTTP Server: ServeMux Pattern Routing, Handler, Middleware ve Timeout'lar

Go'da bir HTTP servisi için standart kütüphane yeterli: ServeMux 1.22'den beri method ve wildcard pattern'lerini en spesifik olan kazanır kuralıyla eşliyor, middleware ise bir handler'ı saran sıradan bir fonksiyon. Bookstore API'sinin httpapi package'ına taşınması, RequestID ve Logging middleware'leri, CORS ve CrossOriginProtection, 307 redirect, pattern çakışması panic'i, WriteTimeout'un handler'ı durdurmaması ve slowloris'e karşı http.Server timeout'ları.

Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; module'ün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenir. ServeMux'taki method ve wildcard pattern'leri Go 1.22, http.CrossOriginProtection Go 1.25, trailing slash redirect'inin 307 olması Go 1.26 ile gelmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) bir yönetim raporu 2 saniye sürüyor. Server'ı WriteTimeout'u 1 saniye olan bir http.Server ile çalıştırıyoruz:

func routes() *http.ServeMux {
    slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // fazla uzun süren bir rapor
        fmt.Fprintln(w, "report ready")
    })
    mux := http.NewServeMux()
    mux.Handle("GET /slow", slow)
    mux.Handle("GET /slow-guarded", http.TimeoutHandler(slow, 500*time.Millisecond, "report timed out\n"))
    return mux
}
srv := &http.Server{
    Addr:              addr,
    Handler:           routes(),
    ReadHeaderTimeout: 2 * time.Second,
    WriteTimeout:      1 * time.Second,
}
log.Fatal(srv.ListenAndServe())

İstemci /slow'u çağırınca ne alır: 1 saniye sonra bir 503 mü, 2 saniye sonra bir 200 mü, yoksa bir 500 mü?

go run ./labs/09-timeouts server
curl -sS -i http://127.0.0.1:52711/slow
curl: (52) Empty reply from server

Hiçbiri. Handler 2 saniye boyunca çalışmaya devam etti, cevabını yazdı, ama yazma deadline'ı geçtiği için bu cevap istemciye hiç gitmedi; bağlantı boş kapandı. WriteTimeout handler'ı durdurmuyor, sadece cevabın yazılabileceği süreyi sınırlıyor. Aynı handler'ı http.TimeoutHandler ile saran /slow-guarded ise 500 ms sonra düzgün bir 503 Service Unavailable döndürdü. Bu yazıda net/http'nin handler modelini, ServeMux'ın pattern routing'ini, middleware zincirini ve http.ListenAndServe'ün neden production için yetmediğini Bookstore API'si üzerinden göreceğiz.

Handler, HandlerFunc ve ServeMux

net/http'nin bütün modeli tek method'lu bir interface'e dayanıyor: http.Handler, yani ServeHTTP(w http.ResponseWriter, r *http.Request). http.HandlerFunc bu imzadaki sıradan bir fonksiyonu Handler'a çeviren bir tip. ServeMux da bir Handler: gelen isteğin path'ine bakıp doğru handler'a yönlendiriyor. 01'den beri handler'ları main'in içinde closure olarak yazıyorduk; endpoint sayısı arttığı için onları internal/httpapi'deki bir API tipinin method'larına taşıdım:

// API, handler'ların ihtiyaç duyduklarını tutar. main onu bir kez kurar.
type API struct {
    Books catalog.Store
}

// Routes bütün endpoint'leri yeni bir ServeMux'a kaydeder.
func (a *API) Routes() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /hello", a.hello)
    mux.HandleFunc("GET /api/books", a.listBooks)
    mux.HandleFunc("GET /api/books/{isbn}", a.getBook)
    mux.HandleFunc("GET /api/authors", a.listAuthors)
    return mux
}
func (a *API) getBook(w http.ResponseWriter, r *http.Request) {
    book, err := a.Books.ByISBN(r.PathValue("isbn"))
    if errors.Is(err, catalog.ErrBookNotFound) {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    if err != nil {
        log.Printf("get book: %v", err)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    writeJSON(w, book)
}

API, ASP.NET Core'daki bir controller'ın bağımlılıklarını tutan constructor'ın karşılığı; DI (dependency injection; bağımlılıkları nesneye dışarıdan veren mekanizma) container'ı yok, main onu elle kuruyor. cmd/bookstore-api/main.go artık sadece bağımlılıkları kurup server'ı başlatıyor. Bookstore'un C# tarafındaki Bookstore.Api'yi düşünürsen, Go'da internal/httpapi o projenin controller katmanına karşılık geliyor.

Pattern Routing: Method, Wildcard ve Precedence

Go 1.22'den önce ServeMux sadece path prefix'i biliyordu; method kontrolü ve path parametreleri için chi gibi bir router gerekiyordu. 1.22 ile pattern'ler method, wildcard ve birkaç özel biçim kazandı. Hepsini bir lab'da denedim:

// Lab 09: ServeMux pattern'leri, precedence, wildcard'lar ve redirect'ler.
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func reply(name string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "%s isbn=%q rest=%q\n", name, r.PathValue("isbn"), r.PathValue("rest"))
    }
}

func main() {
    mux := http.NewServeMux()
    mux.Handle("GET /api/books/{isbn}", reply("book"))
    mux.Handle("GET /api/books/bestsellers", reply("bestsellers"))    // daha spesifik: kazanır
    mux.Handle("GET /covers/{rest...}", reply("cover"))               // path'in geri kalanını yakalar
    mux.Handle("GET /docs/", reply("docs"))                           // sondaki slash: bir alt ağaç
    mux.Handle("GET /api/books/{isbn}/reviews/{$}", reply("reviews")) // {$}: sadece tam eşleşme

    if len(os.Args) > 1 && os.Args[1] == "conflict" {
        // İki pattern'in hiçbiri diğerinden daha spesifik değil: Handle panic olur.
        mux.Handle("GET /api/{kind}/9780441172719", reply("conflict"))
    }

    addr := "127.0.0.1:52711"
    log.Printf("routing lab on http://%s", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}
curl http://127.0.0.1:52711/api/books/9780441172719
curl http://127.0.0.1:52711/api/books/bestsellers
curl http://127.0.0.1:52711/covers/2026/dune.jpg
curl http://127.0.0.1:52711/api/books/9780441172719/reviews/
curl http://127.0.0.1:52711/api/books/9780441172719/reviews/latest
curl -i http://127.0.0.1:52711/docs
book isbn="9780441172719" rest=""
bestsellers isbn="" rest=""
cover isbn="" rest="2026/dune.jpg"
reviews isbn="9780441172719" rest=""
404 page not found
HTTP/1.1 307 Temporary Redirect
Content-Type: text/html; charset=utf-8
Location: /docs/

/api/books/bestsellers iki pattern'e de uyuyor ve kayıt sırası önemli değil: daha spesifik olan, yani daha az isteğe uyan pattern kazanıyor. {rest...} path'in geri kalanını tek bir değer olarak alıyor, {$} ise sondaki slash'tan sonra hiçbir şey olmamasını şart koşuyor. Slash ile biten /docs/ bir alt ağaç pattern'i; /docs isteyen istemci /docs/'a yönlendiriliyor. Bu redirect Go 1.26'dan beri 301 değil 307 (Temporary Redirect). 301 tarayıcılarda kalıcı olarak cache'lenebildiği için sonradan /docs'a ayrı bir handler eklemek sorun çıkarabiliyordu.

Hiçbiri diğerinden daha spesifik olmayan iki pattern ise kayıt anında panic'e yol açıyor:

go run ./labs/09-routing conflict
panic: pattern "GET /api/{kind}/9780441172719" (registered at /home/user/bookstore/labs/09-routing/main.go:27) conflicts with pattern "GET /api/books/{isbn}" (registered at /home/user/bookstore/labs/09-routing/main.go:19):
    GET /api/{kind}/9780441172719 and GET /api/books/{isbn} both match some paths, like "/api/books/9780441172719".
    But neither is more specific than the other.
    GET /api/{kind}/9780441172719 matches "/api/kind/9780441172719", but GET /api/books/{isbn} doesn't.
    GET /api/books/{isbn} matches "/api/books/isbn", but GET /api/{kind}/9780441172719 doesn't.

Hatanın çalışma anında değil, server başlarken çıkması iyi bir tasarım: belirsiz bir route production'a hiç ulaşamıyor. ASP.NET Core Web API serisinin "Routing" yazısındaki route template'leri de benzer bir öncelik mantığıyla çalışıyordu; Go'da kural daha basit ve tek: daha spesifik olan kazanır, eşitlik varsa panic.

Bookstore API'sinde yanlış method ile gelen istek 405 alıyor ve Allow header'ı izin verilen method'ları listeliyor. GET pattern'i HEAD isteklerini de karşılıyor:

curl -i -X POST http://127.0.0.1:52710/api/books/9780141439587
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: 2MGGJ6LL4XRP
Date: Sun, 15 Nov 2026 01:48:08 GMT
Content-Length: 19

Method Not Allowed

Middleware: Handler'ı Saran Fonksiyon

Go'da middleware özel bir kavram değil: bir http.Handler alıp başka bir http.Handler döndüren fonksiyon. 05'teki Recover da böyleydi. Birden çok middleware'i okunur bir sırayla bağlamak için küçük bir Chain fonksiyonu yazdım ve iki yeni middleware ekledim:

// Middleware bir handler'ı ek davranışla sarar.
type Middleware func(http.Handler) http.Handler

// Chain, h'yi listedeki ilk middleware ilk çalışacak şekilde sarar.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

type requestIDKey struct{}

// RequestID her isteğe bir ID verir: istemci kısa bir X-Request-ID gönderdiyse
// onu, göndermediyse rastgele bir ID. ID context'e ve
// response header'ına konur.
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" || len(id) > 64 {
            id = rand.Text()[:12]
        }
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), requestIDKey{}, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// RequestIDFrom, RequestID'nin ctx'e koyduğu ID'yi döndürür.
func RequestIDFrom(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey{}).(string)
    return id
}
// statusRecorder, handler'ın yazdığı status code'u ve body boyutunu hatırlar.
type statusRecorder struct {
    http.ResponseWriter
    status int
    bytes  int
}

func (s *statusRecorder) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
    if s.status == 0 {
        s.status = http.StatusOK
    }
    n, err := s.ResponseWriter.Write(b)
    s.bytes += n
    return n, err
}

// Unwrap, http.ResponseController'ın asıl writer'a ulaşmasını sağlar.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// Logging, handler döndükten sonra her istek için tek satır yazar.
func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w}
        next.ServeHTTP(rec, r)
        log.Printf("%s %s %d %dB %v rid=%s", r.Method, r.URL.RequestURI(), rec.status, rec.bytes,
            time.Since(start).Round(time.Microsecond), RequestIDFrom(r.Context()))
    })
}

Logging, handler'ın hangi status code'u yazdığını bilmek için ResponseWriter'ı bir statusRecorder ile sarıyor. Unwrap method'u küçük ama önemli bir ayrıntı: Go 1.20'deki http.ResponseController, flush ya da tek bir istek için yazma deadline'ı ayarlamak gibi işlerde sarılmış writer'ın altındakine bu method ile ulaşıyor. RequestID, 08'de anlattığımız context değerlerinin tipik kullanımı: ID'yi export edilmeyen bir key ile context'e koyuyor ve RequestIDFrom ile okutuyor. Recover'ın log satırına da bu ID'yi ekledim.

09-middleware-chain-tr.png

Sıra önemli. RequestID en dışta, çünkü Logging ve Recover ID'yi kullanıyor. Logging, Recover'ın dışında, çünkü panic'ten sonra yazılan 500'ü de log'lamak istiyoruz. ASP.NET Core Web API serisinin "ASP.NET Core Middleware Pipeline" yazısında app.Use sırasının neden önemli olduğunu konuşmuştuk; Go'da aynı sıra Chain'in argüman sırası. Mekanizma da aynı: ASP.NET Core'da her middleware next()'i çağırıp çağırmamaya karar veriyor, Go'da da aynısı next.ServeHTTP(w, r)'yi çağırıp çağırmamakla oluyor.

CORS ve CrossOriginProtection

Bookstore'un Angular istemcisi geliştirme sırasında http://localhost:4200'de çalışıyor, API ise başka bir port'ta. Tarayıcı bu yüzden API'ye her istekte CORS (Cross-Origin Resource Sharing; tarayıcının başka origin'e yapılan istekleri hangi koşulla kabul edeceğini belirleyen kurallar) kontrolü yapıyor ve POST öncesi bir OPTIONS preflight isteği gönderiyor. CORS middleware'i sadece izin verilen origin'e cevap veriyor ve preflight'ı mux'a göndermeden kendisi bitiriyor:

// CORS, origin'deki Angular uygulamasının API'yi tarayıcıdan çağırmasına izin verir.
func CORS(origin string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("Origin") != origin {
                next.ServeHTTP(w, r)
                return
            }
            h := w.Header()
            h.Set("Access-Control-Allow-Origin", origin)
            h.Add("Vary", "Origin")
            if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
                h.Set("Access-Control-Allow-Methods", "GET, POST")
                h.Set("Access-Control-Allow-Headers", "Content-Type, X-Request-ID")
                h.Set("Access-Control-Max-Age", "600")
                w.WriteHeader(http.StatusNoContent) // preflight: cevabı burada ver, mux'a gitme
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Go 1.25'teki http.CrossOriginProtection ise CSRF (Cross-Site Request Forgery; kullanıcının tarayıcısı üzerinden başka bir siteden onun adına istek gönderilmesi) saldırılarına karşı token'sız bir savunma; main'de Angular'ın origin'ini güvenilir olarak ekledim:

csrf := http.NewCrossOriginProtection()
if err := csrf.AddTrustedOrigin(angularDevOrigin); err != nil {
    log.Fatal(err)
}

GET, HEAD ve OPTIONS dışındaki isteklerde tarayıcının gönderdiği Sec-Fetch-Site header'ına bakıyor; header yoksa Origin'i Host ile karşılaştırıyor. İkisi de yoksa isteğin tarayıcıdan gelmediğini varsayıp geçiriyor; curl ve diğer servisler etkilenmiyor. Henüz POST endpoint'imiz yok, ama 10'da gelecek /api/orders için kontrol şimdiden devrede:

curl -i -X OPTIONS -H 'Origin: http://localhost:4200' -H 'Access-Control-Request-Method: POST' http://127.0.0.1:52710/api/orders
curl -i -X POST -H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://evil.example.com' http://127.0.0.1:52710/api/orders
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type, X-Request-ID
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Max-Age: 600
Vary: Origin
X-Request-Id: 5HL2X5DJIFGP
Date: Sun, 15 Nov 2026 01:48:08 GMT

HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: AYT54Q7ZF43N
Date: Sun, 15 Nov 2026 01:48:08 GMT
Content-Length: 57

cross-origin request detected from Sec-Fetch-Site header

Server'ın log'u her isteği tek satırda gösteriyor; ikinci satırdaki ID, istemcinin kendi gönderdiği X-Request-ID:

2026/11/15 04:48:08 GET /api/books/9780141439587 200 87B 232µs rid=OKZYOUVJJVND
2026/11/15 04:48:08 GET /api/books/9780000000000 404 35B 20µs rid=angular-7f3a
2026/11/15 04:48:08 POST /api/books/9780141439587 405 19B 25µs rid=2MGGJ6LL4XRP
2026/11/15 04:48:08 OPTIONS /api/orders 204 0B 11µs rid=5HL2X5DJIFGP
2026/11/15 04:48:08 POST /api/orders 403 57B 8µs rid=AYT54Q7ZF43N

http.Server ve Timeout'lar

http.ListenAndServe(addr, h), bütün timeout'ları sıfır olan bir http.Server açıyor; sıfır, "sınır yok" demek. Cloudflare'in 2016 tarihli "The complete guide to Go net/http timeouts" yazısından beri bilinen bu durumu lab'ın slowloris modu gösteriyor: istemci request satırını ve bir header gönderiyor, header'ları bitiren boş satırı hiç göndermiyor.

case "slowloris":
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    start := time.Now()
    fmt.Fprint(conn, "GET /slow HTTP/1.1\r\nHost: bookstore\r\n") // boş satır yok: header'lar hiç bitmiyor
    if err := conn.SetReadDeadline(time.Now().Add(8 * time.Second)); err != nil {
        log.Fatal(err)
    }
    reply, err := io.ReadAll(conn)
    fmt.Printf("after %v: %d bytes, err=%v\n", time.Since(start).Round(100*time.Millisecond), len(reply), err)
    if len(reply) > 0 {
        fmt.Printf("%q\n", reply)
    }

Server'ı bir terminalde önce plain, sonra server moduyla başlattım ve her seferinde diğer terminalde istemciyi çalıştırdım:

go run ./labs/09-timeouts plain
go run ./labs/09-timeouts slowloris
go run ./labs/09-timeouts server
go run ./labs/09-timeouts slowloris
after 8s: 0 bytes, err=read tcp 127.0.0.1:63786->127.0.0.1:52711: i/o timeout
after 2s: 0 bytes, err=<nil>

ListenAndServe ile açılan server bağlantıyı 8 saniye boyunca açık tuttu ve vazgeçen taraf istemci oldu. Binlerce böyle bağlantı açan biri, her biri bir goroutine ve bir file descriptor tutarak server'ı kilitleyebilir; bu saldırının adı slowloris. ReadHeaderTimeout'u 2 saniye olan server ise bağlantıyı 2 saniyede kapattı.

Bookstore API'sinin main'i artık zinciri ve timeout'ları ayarlanmış bir http.Server'ı kuruyor:

func main() {
    api := &httpapi.API{Books: catalog.Store{}}

    csrf := http.NewCrossOriginProtection()
    if err := csrf.AddTrustedOrigin(angularDevOrigin); err != nil {
        log.Fatal(err)
    }

    handler := httpapi.Chain(api.Routes(),
        httpapi.RequestID, // en dışta: ilk çalışır, her isteği görür
        httpapi.Logging,
        httpapi.Recover,
        httpapi.CORS(angularDevOrigin),
        csrf.Handler, // en içte: mux'tan hemen önce
    )

    srv := &http.Server{
        Addr:              "127.0.0.1:52710",
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,  // request satırı ve header'lar
        ReadTimeout:       10 * time.Second, // header'lar ve body
        WriteTimeout:      15 * time.Second, // header'ların bitişinden cevabın sonuna kadar
        IdleTimeout:       60 * time.Second, // bir sonraki isteği bekleyen keep-alive bağlantısı
        MaxHeaderBytes:    64 << 10,
    }
    log.Printf("bookstore-api listening on http://%s", srv.Addr)
    log.Fatal(srv.ListenAndServe())
}

Dört timeout'un kapsamı farklı. ReadHeaderTimeout request satırı ve header'ların okunmasını, ReadTimeout body dahil bütün isteğin okunmasını sınırlıyor. WriteTimeout, header'ların okunması bittiği andan cevabın yazılması bitene kadar geçen süreyi sınırlıyor; Önce Tahmin Et'te gördüğümüz gibi handler'ı durdurmuyor. IdleTimeout ise keep-alive bağlantının bir sonraki isteği ne kadar bekleyeceği; sıfırsa ReadTimeout kullanılıyor. Uzun süren bir handler'ı gerçekten sınırlamak istiyorsan http.TimeoutHandler ya da handler içinde context.WithTimeout kullanırsın; dosya indirme gibi uzun cevaplar için http.ResponseController ile o isteğin yazma deadline'ını ayrıca uzatabilirsin. Go 1.27, server'a bir istekte kabul edilen header değeri sayısını sınırlayan MaxHeaderValueCount'u da ekledi; varsayılanı 500.

chi, Gin, Echo: Ne Zaman?

1.22'deki pattern routing'den sonra küçük ve orta boy bir API için standart kütüphane yetiyor; bu seride bu yüzden framework kullanmıyoruz. chi, standart http.Handler imzasını koruyan bir router: route grupları, alt router'lar ve hazır middleware'leri var, ve yazdığın her middleware net/http ile de çalışıyor. Çok sayıda route grubun ya da hazır middleware ihtiyacın varsa ilk bakacağın yer orası. Gin ve Echo ise kendi Context tiplerini getiriyor; handler'ların ve middleware'lerin o framework'e bağlanıyor ve standart kütüphane ekosistemiyle aranda bir dönüşüm katmanı oluyor. Ekipte zaten kullanılıyorsa sorun değil; yeni bir projede tek gerekçesi routing ise artık gerekli değil.

Üretimde Dikkat

Timeout'ları uygulamanın gerçek ihtiyacına göre seç. Dosya yükleme alan bir endpoint 10 saniyelik ReadTimeout ile çalışmaz; böyle endpoint'leri ayrı bir server'a ya da ResponseController ile istek bazlı deadline'a taşı. Server seviyesindeki değerler, en yavaş meşru isteği değil, saldırıyı sınırlamak için.

CORS bir güvenlik mekanizması değil, tarayıcının kendi kullanıcısını koruma mekanizması. curl ya da başka bir servis CORS'a hiç bakmaz. Yetkilendirmeyi her zaman server tarafında yap ve izin verilen origin listesini configuration'dan oku; localhost:4200'ün sabit yazılması sadece geliştirme için. 13'te adresleri configuration'a taşıyacağız.

Log'daki request ID'yi istemciye de döndür. Bir kullanıcı hata bildirdiğinde Angular uygulaması X-Request-Id'yi gösterebilirse, log'da o isteği saniyeler içinde bulursun. 13'te log/slog'a geçtiğimizde bu ID structured log'un bir alanı olacak.

Sık Düşülen Tuzaklar

Production'da http.ListenAndServe. Lab'da gördüğümüz gibi hiçbir timeout yok. Her zaman kendi http.Server'ını kur ve en azından ReadHeaderTimeout'u ayarla.

WriteTimeout'u handler timeout'u sanmak. Handler çalışmaya devam eder, istemci boş bir cevap alır. Handler'ın süresini sınırlamak için TimeoutHandler ya da context kullan.

Middleware sırasını ters kurmak. Recover'ı Logging'in dışına koyarsan panic olan isteğin log satırı hiç yazılmaz, çünkü panic Logging'in log satırına ulaşmadan yukarı çıkar.

WriteHeader'dan sonra header değiştirmek. Status code yazıldıktan sonra w.Header().Set(...) etkisizdir. RequestID'nin header'ı next'i çağırmadan önce koymasının nedeni bu.

Global ServeMux'ı kullanmak. http.HandleFunc paket seviyesindeki http.DefaultServeMux'a kayıt yapar; import ettiğin herhangi bir package de oraya route ekleyebilir. Bookstore'da olduğu gibi her zaman http.NewServeMux() ile kendi mux'ını kur.

Küçük Bir Deneme

★☆☆ labs/09-routing'e GET /api/books/{isbn}/reviews/{id} pattern'ini ekle. /api/books/9780441172719/reviews/ isteğini hangi handler karşılıyor? Neden?

★★☆ Logging'i, 400 ve üzeri cevaplarda log satırının başına WARN yazacak şekilde değiştir. Sonra Chain'deki sırayı değiştirip Recover'ı en dışa al ve panic olan bir isteğin log'una ne olduğuna bak.

★★★ /slow handler'ını, r.Context()'i dinleyip TimeoutHandler'ın süresi dolunca hemen duracak şekilde yaz. İpucu: time.Sleep yerine select ile time.After ve r.Context().Done()'ı birlikte bekle; TimeoutHandler'ın isteğin context'ini iptal ettiğini doğrula.

API'nin iskeleti hazır. Bir sonraki yazıda JSON'a ve request validation'a geçiyoruz: struct tag'leri, DisallowUnknownFields ve MaxBytesReader ile body sınırları, Go 1.27'de standart kütüphaneye giren encoding/json/v2, hataları Problem Details (RFC 9457; HTTP API'lerinde hata cevapları için standart JSON biçimi) olarak döndürmek ve sonunda POST /api/orders ile Bookstore'da ilk siparişi HTTP üzerinden almak.