Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Networking ve HTTP Nasıl Çalışır — #06

HTTP/1.1'i Kablodan Okumak: Request/Response Formatı, Method Semantiği ve Keep-Alive

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
HTTP/1.1'i Kablodan Okumak: Request/Response Formatı, Method Semantiği ve Keep-Alive

HTTP/1.1 bir metin protokolü: start line, header'lar, boş satır ve Content-Length ya da chunked ile çerçevelenen body. nc ve openssl s_client ile elle yazılan istekler, Host'suz isteğe 400, chunked'daki 3d'nin anlamı, safe/idempotent method'lar ve yalnız idempotent istekleri tekrar deneyen bir handler, keep-alive ve request smuggling'e karşı Kestrel.

Sürüm notu: Bu yazıdaki istekler macOS'taki nc, OpenSSL 4.0.3 ve .NET 10 (ASP.NET Core 10 Kestrel) ile denenmiştir; testler xUnit v3 4.0.2 ile koşulmuştur. HTTP (Hypertext Transfer Protocol) anlatımı RFC (Request for Comments) 9110 ve RFC 9112'yi esas alır.

Önce Tahmin Et

Bookstore API'sine (Application Programming Interface) hiçbir HTTP library'si kullanmadan, düz bir TCP (Transmission Control Protocol) bağlantısı üzerinden elle bir istek yazıyorsun ve cevabın içinde şunu görüyorsun:

Transfer-Encoding: chunked

3d
{"id":1,"isbn":"978-0000000001","title":"Title 1","price":11}
0

JSON'un (JavaScript Object Notation) üstündeki 3d ne? Altındaki 0 neden orada? Bir de şu: aynı isteği Host header'ı olmadan gönderseydin, sunucu yine aynı JSON'u mu dönerdi? Bu yazıda HTTP/1.1'i kablodaki hâliyle, byte byte okuyoruz.

Semantics ve Syntax: RFC 9110 ile RFC 9112

2022'de HTTP'nin spec'leri yeniden düzenlendi ve iki katmana ayrıldı. RFC 9110 (HTTP Semantics), bütün sürümlerin ortak anlamını tanımlar: method'lar, status code'lar, header alanlarının anlamı, content negotiation. RFC 9112 ise yalnızca HTTP/1.1'in kablodaki biçimini: bir mesajın satırlara nasıl bölündüğünü ve body'nin uzunluğunun nasıl belirlendiğini. HTTP/2 (RFC 9113) ve HTTP/3 (RFC 9114) aynı semantiği bambaşka bir kablo biçimiyle taşır. Bu ayrım pratikte şu demek: bir GET'in idempotent olması veya 304'ün anlamı sürümden bağımsız; ama "body nerede bitiyor?" sorusunun cevabı sürüme göre değişir.

Elle İstek Yazmak: nc ve openssl s_client

HTTP/1.1 bir metin protokolü; elle yazılabilir. nc (netcat) ile bir TCP bağlantısı açıp isteği printf ile gönderiyorum. Satır sonları CRLF (Carriage Return + Line Feed, \r\n) olmak zorunda; cat -v bunları ^M olarak gösteriyor:

$ (printf 'GET /api/books/1 HTTP/1.1\r\nHost: localhost\r\n\r\n'; sleep 1) | nc 127.0.0.1 5491 | cat -v
HTTP/1.1 200 OK^M
Content-Type: application/json; charset=utf-8^M
Date: Sun, 08 Nov 2026 10:28:17 GMT^M
Server: Kestrel^M
Transfer-Encoding: chunked^M
^M
3d^M
{"id":1,"isbn":"978-0000000001","title":"Title 1","price":11}^M
0^M
^M

HTTPS'te (HTTP Secure) aynı şeyi TLS (Transport Layer Security) bağlantısını açan openssl s_client ile yaparsın; -quiet handshake ayrıntılarını gizler, -alpn http/1.1 HTTP/2'ye geçilmesini engeller:

$ (printf 'GET /whoami HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'; sleep 1) | openssl s_client -connect 127.0.0.1:5592 -servername localhost -CAfile certs/ca.crt -alpn http/1.1 -quiet
depth=1 CN=Bookstore Local Root CA
verify return:1
depth=0 CN=bookstore.localhost
verify return:1
HTTP/1.1 200 OK
Content-Length: 6
Connection: close
Content-Type: text/plain; charset=utf-8
Date: Sun, 08 Nov 2026 10:29:03 GMT
Server: Kestrel

green
80FFF8F501000000:error:0A000126:SSL routines::unexpected eof while reading:ssl/record/rec_layer_s3.c:708:

Son satırdaki hata zararsız: sunucu Connection: close isteğine uyup TCP bağlantısını kapattı ama TLS'in kapanış mesajını (close_notify) göndermedi; OpenSSL 3 ve sonrası bunu bir hata olarak raporluyor. Body uzunluğu Content-Length ile bildirildiği için cevabın eksiksiz geldiğini biliyoruz.

Mesajın Anatomisi: Start Line, Header'lar, Body

Her HTTP/1.1 mesajı üç bölümden oluşur. İlk satır start line: istekte method, request target ve sürüm (GET /api/books/1 HTTP/1.1), cevapta sürüm, status code ve reason phrase (HTTP/1.1 200 OK). Ardından her biri Name: value biçiminde header satırları gelir. Boş bir satır (yalnızca CRLF) header'ların bittiğini söyler ve varsa body başlar.

HTTP/1.1'de Host header'ı zorunludur. Sebebi virtual hosting: aynı IP (Internet Protocol) adresi ve port'ta onlarca site barındıran bir sunucu, isteğin hangi siteye ait olduğunu yalnızca Host'tan öğrenir. RFC 9112 bu header'ı içermeyen bir HTTP/1.1 isteğine 400 dönülmesini ister. Kestrel de öyle yapıyor:

$ (printf 'GET /api/books/1 HTTP/1.1\r\n\r\n'; sleep 1) | nc 127.0.0.1 5491 | cat -v
HTTP/1.1 400 Bad Request^M
Content-Length: 0^M
Connection: close^M
Date: Sun, 08 Nov 2026 10:28:19 GMT^M
Server: Kestrel^M
^M

Tahmin bölümündeki ikinci sorunun cevabı bu: JSON yerine 400 ve kapanan bir bağlantı. ASP.NET Core'da Host header'ının değeri ayrıca Host Filtering middleware'i ile kısıtlanabilir; production'da AllowedHosts ayarının * bırakılmaması, sahte Host değerleriyle üretilen link'lere (örneğin şifre sıfırlama e-postalarında) karşı bir önlemdir.

Body Framing: Content-Length ve Chunked

TCP bir byte akışıdır; mesajların nerede bittiğini bilmez. HTTP/1.1'de body'nin sonunu bulmanın iki standart yolu var. Birincisi Content-Length: gönderici body'nin kaç byte olduğunu önceden söyler. Kestrel, boyutu belli olan bir string döndüğünde bunu kullanıyor:

$ (printf 'GET /whoami HTTP/1.1\r\nHost: localhost\r\n\r\n'; sleep 1) | nc 127.0.0.1 5491 | cat -v
HTTP/1.1 200 OK^M
Content-Length: 5^M
Content-Type: text/plain; charset=utf-8^M
Date: Sun, 08 Nov 2026 10:28:18 GMT^M
Server: Kestrel^M
^M
blue

blue ve bir satır sonu: 5 byte. İkinci yol Transfer-Encoding: chunked: gönderici body'yi parçalar hâlinde, her parçanın önüne onaltılık uzunluğunu yazarak gönderir ve sıfır uzunluklu bir parçayla bitirir. Tahmin bölümündeki 3d, onaltılık 61: JSON tam olarak 61 byte. Sondaki 0 "body bitti" işareti. Minimal API JSON'u doğrudan response stream'ine serialize ettiği için toplam boyutu yazmaya başlamadan bilmiyor ve chunked'ı seçiyor. Parçaları ayrı ayrı flush eden bir endpoint'te bu daha görünür:

// Content-Length vermeden yazınca Kestrel HTTP/1.1'de chunked encoding kullanır
app.MapGet("/stream", async (HttpContext ctx) =>
{
    ctx.Response.ContentType = "text/plain";
    for (var i = 1; i <= 3; i++)
    {
        await ctx.Response.WriteAsync($"part {i}\n");
        await ctx.Response.Body.FlushAsync();
    }
});
Transfer-Encoding: chunked^M
^M
7^M
part 1
^M
7^M
part 2
^M
7^M
part 3
^M
0^M
^M

Her parça yedi byte (part 1 ve \n). Chunked encoding sayesinde sunucu, sonunu bilmediği bir içeriği (bir rapor, bir log akışı, bir AI (Artificial Intelligence) cevabı) üretildikçe gönderebilir. İstekte body varsa aynı kurallar geçerli; bir sipariş oluşturmak için:

$ body='{"bookId":1,"quantity":2}'
$ (printf "POST /api/orders HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ${#body}\r\n\r\n$body"; sleep 1) | nc 127.0.0.1 5491
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 10:28:30 GMT
Server: Kestrel
Location: /api/orders/1214
Transfer-Encoding: chunked

19
{"bookId":1,"quantity":2}
0

201 ve Location header'ı, yeni kaynağın adresi. Status code'ların seçimi ASP.NET Core Web API serisinin "ASP.NET Core'da Status Code ve Response: Ok, CreatedAtAction ve ProblemDetails" yazısında.

Method Semantiği: Safe ve Idempotent

RFC 9110 her method'a iki özellik atar. Safe bir method, istemcinin bakış açısından sunucuda bir değişiklik istemez; idempotent bir method ise aynı isteği bir kez veya on kez göndermenin sonucu değiştirmediği anlamına gelir.

MethodSafeIdempotentBookstore örneği
GET, HEAD, OPTIONSevetevetKitap listesini okumak
PUThayırevetKitabın fiyatını 120 yapmak (ikinci kez yapmak yine 120)
DELETEhayırevetSepetten bir kitabı çıkarmak
POSThayırhayırSipariş oluşturmak (iki kez göndermek iki sipariş)
PATCHhayırhayır (genelde)Stok sayısını bir artırmak

Bu ayrım en çok retry'da önemli. Bir istek gönderildikten sonra bağlantı koptuğunda istemci, isteğin sunucuya ulaşıp ulaşmadığını bilemez. Idempotent bir isteği tekrarlamak güvenlidir; bir POST'u tekrarlamak çift sipariş demektir. Bu kuralı bir DelegatingHandler olarak yazdım:

// Başarısız bir isteği bir kez tekrar dener, ama yalnızca tekrar etmek ikinci bir yan etki üretemiyorsa
public sealed class IdempotentRetryHandler(int maxRetries = 1) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);
        for (var attempt = 0;
             attempt < maxRetries && response.StatusCode == HttpStatusCode.ServiceUnavailable && HttpMethodSemantics.IsIdempotent(request.Method);
             attempt++)
        {
            response.Dispose();
            response = await base.SendAsync(request, cancellationToken);
        }
        return response;
    }
}

Sunucuda her ikinci çağrıda 503 dönen bir /flaky endpoint'i var. Aynı client ile önce GET, sonra POST:

$ dotnet Bookstore.Net.Lab.dll retry http://127.0.0.1:5491/flaky
GET  -> 200 OK ok after 2 calls
POST -> 503 ServiceUnavailable

GET ilk denemede 503 aldı, handler tekrar denedi ve ikinci çağrı başarılı oldu. POST 503 aldı ve tekrar denenmedi. HttpMethodSemantics'in RFC 9110'daki tabloya uyduğunu bir xUnit testi doğruluyor. Gerçek projede bu işi Polly tabanlı Microsoft.Extensions.Http.Resilience paketi yapar. Standart retry stratejisi method'a bakmadan dener; DisableForUnsafeHttpMethods() ise POST, PATCH, PUT, DELETE ve CONNECT için retry'ı kapatır, yani idempotent olan PUT ve DELETE'i de dışarıda bırakan daha temkinli bir kural uygular. Konunun ayrıntısı ilerideki External Service Integration ve Resilience serisinde. POST'u güvenle tekrar denenebilir yapmanın yolu bir idempotency key: istemci her siparişe bir kimlik verir, sunucu aynı kimliği ikinci kez görünce yeni sipariş oluşturmaz.

Persistent Connection, Head-of-Line Blocking ve 6 Bağlantı

HTTP/1.0'da her istek için yeni bir TCP bağlantısı açılırdı. HTTP/1.1'de bağlantılar varsayılan olarak açık kalır (persistent connection, halk arasında keep-alive); bir taraf Connection: close göndermedikçe aynı bağlantı sonraki istekler için kullanılır. İki isteği aynı nc bağlantısından arka arkaya gönderdim; ikincisinde bağlantının kapanmasını istedim:

$ (printf 'GET /conn HTTP/1.1\r\nHost: localhost\r\n\r\nGET /conn HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'; sleep 1) | nc 127.0.0.1 5491
HTTP/1.1 200 OK
...
{"id":"0HNPTG51MT3T4","remotePort":64506,"protocol":"HTTP/1.1"}
0

HTTP/1.1 200 OK
Connection: close
...
{"id":"0HNPTG51MT3T4","remotePort":64506,"protocol":"HTTP/1.1"}
0

İki cevapta da aynı bağlantı kimliği ve aynı istemci port'u. İkinci cevaptaki Connection: close, sunucunun isteğe uyup bağlantıyı kapatacağını söylüyor.

Ama HTTP/1.1'de bir bağlantı aynı anda yalnızca bir cevap taşıyabilir. İkinci isteği birincinin cevabını beklemeden göndermek (pipelining) spec'te var ama cevaplar sırayla gelmek zorunda: yavaş ilk cevap arkasındaki her şeyi bekletir. Buna head-of-line blocking denir ve hatalı proxy'ler yüzünden tarayıcılar pipelining'i hiç açmadı. Tarayıcıların çözümü paralellik: aynı host'a birden çok bağlantı açmak. Chrome ve Firefox host başına 6 bağlantıyla sınırlar; 30 küçük dosyalık bir sayfa HTTP/1.1'de beşer beşer sıraya girer. DevTools'taki "Queueing" ve "Stalled" sürelerinin önemli bir kısmı buradan gelir. HTTP/2'nin asıl çözdüğü sorun bu; sekizinci yazının konusu.

Defensive Not: Request Smuggling

Body'nin sonunu belirlemenin iki yolu olması bir risk doğurur. Bir istek hem Content-Length hem Transfer-Encoding taşırsa, önde duran bir proxy ile arkadaki sunucu body'nin sonunu farklı yerde görebilir. O zaman proxy'nin tek istek sandığı byte'lar, sunucu tarafında iki istek olarak yorumlanır ve ikinci "gizli" istek başka bir kullanıcının bağlantısına karışabilir. Bu saldırı sınıfına request smuggling denir. RFC 9112, iki header birlikte geldiğinde Transfer-Encoding'in geçerli olmasını ve böyle bir isteğin şüpheli kabul edilip bağlantının kapatılmasını önerir. Kendi sunucumun bunu nasıl ele aldığını kontrol ettim:

$ (printf 'POST /api/orders HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n'; sleep 1) | nc 127.0.0.1 5491 | cat -v
HTTP/1.1 400 Bad Request^M
Content-Length: 0^M
Connection: close^M
Date: Sun, 08 Nov 2026 10:28:31 GMT^M
Server: Kestrel^M
^M

Kestrel isteği 400 ile reddedip bağlantıyı kapattı; belirsiz framing'e izin vermiyor. Savunmanın özü bu: zincirdeki her bileşen (CDN (Content Delivery Network), load balancer, reverse proxy, uygulama sunucusu) belirsiz framing'i reddetmeli ve tercihen proxy ile sunucu arasında HTTP/2 gibi uzunluğu frame'lerle belirleyen bir protokol kullanılmalı. Bileşenlerini güncel tutmak burada en etkili önlem.

Üretimde Dikkat

Retry'ı method'a göre ver. Idempotent olmayan çağrıları otomatik tekrar eden bir resilience ayarı, ağ kesintisinde çift sipariş ve çift ödeme üretir. POST için idempotency key kullan veya retry'ı kapat.

Chunked cevaplara hazırlıklı ol. Content-Length olmayan bir cevapta toplam boyutu baştan bilemezsin; ilerleme çubuğu, boyut limiti veya buffer hesabı yapan istemci kodu bunu dikkate almalı.

Header'lara güvenme, doğrula. Host, X-Forwarded-* ve Content-Length istemcinin kontrolünde. ASP.NET Core'da AllowedHosts'u doldur, Forwarded Headers middleware'ini yalnızca bilinen proxy'lere güvenecek şekilde yapılandır ve request body limitlerini (MaxRequestBodySize) bilinçli ayarla.

Sık Düşülen Tuzaklar

Satır sonlarını \n ile yazmak. HTTP/1.1 CRLF ister; bazı sunucular toleranslı davransa da elle yazdığın test isteklerinde \r\n kullan.

Host header'ını unutmak. HTTP/1.1'de zorunlu; yoksa 400 alırsın.

Chunked'ı bir hata sanmak. Body'deki onaltılık sayılar içeriğin parçası değil, framing; HTTP library'leri bunları senin için temizler.

PUT ile POST'u karıştırmak. "Güncelleme ama bir şey ekliyor" diye PUT kullanmak, idempotency beklentisi olan bir retry'ı tehlikeli hâle getirir.

Keep-alive'ı sonsuz sanmak. Sunucu, proxy veya istemci boşta kalan bağlantıyı kapatır; üçüncü yazıda gördüğümüz idle timeout'lar burada da geçerli.

Küçük Bir Deneme

★☆☆ nc ile kendi API'ne bir HEAD isteği gönder. Cevapta Content-Length var mı, body var mı? Neden?

★★☆ Aynı nc bağlantısı üzerinden, birincinin cevabını beklemeden /slow?ms=500 ve /whoami isteklerini arka arkaya gönder. Cevaplar hangi sırayla ve ne zaman geliyor?

★★★ IdempotentRetryHandler'ı, POST isteklerinde Idempotency-Key header'ı varsa onları da tekrar deneyecek şekilde genişlet ve sunucu tarafında aynı anahtarla gelen ikinci siparişi yok sayan bir endpoint yaz. xUnit testiyle iki tarafı birlikte doğrula.

Elle yazdığımız bu istekler her seferinde sunucuya kadar gidip geldi. Oysa çoğu cevap tekrar tekrar aynı. Bir sonraki yazıda HTTP caching'e geçiyoruz: Cache-Control, ETag, conditional request'ler ve 304 Not Modified.