Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #42

Built-in Middleware Kataloğu: Response Compression, Request Decompression, Request Timeouts, Status Code Pages, URL Rewrite ve Header Propagation

Mert Özen 15 Kas 2026 10 dk 13 görüntülenme
Built-in Middleware Kataloğu: Response Compression, Request Decompression, Request Timeouts, Status Code Pages, URL Rewrite ve Header Propagation

ASP.NET Core'un built-in middleware'leri (response compression, request decompression, request timeouts, status code pages, URL rewrite, host filtering, header propagation) doğru sırayla eklenince API'nin her yanıtı tutarlı olur. Her birini Bookstore API'sine ekleyip etkisini curl ile gösteriyor, CORS'tan önce cevap veren bir middleware'in preflight'ı nasıl bozduğunu görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ve Microsoft.AspNetCore.HeaderPropagation 10.0.15 ile derlenip denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi http://localhost:4200'de çalışıyor. API'ye bir CORS policy'si ekledik ve pipeline'ı şöyle kurduk:

builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
    policy.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));

// ...

app.UseMiddleware<TenantMiddleware>();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

Kurumsal portal POST /api/institution/orders isteğini X-Tenant-Id ve Content-Type: application/json header'larıyla gönderiyor. Tarayıcı önce bir preflight OPTIONS isteği atıyor. Bu preflight'a ne döner ve sipariş oluşur mu?

04 numaralı middleware yazısında pipeline'ı ve önerilen sırayı tanımıştık; orada built-in middleware'lere tek bir bölüm ayırabilmiştik. Bu yazıda o listeyi genişletiyoruz: response compression, request decompression, request timeouts, status code pages, URL rewrite, host filtering ve header propagation. Her biri için ne yaptığına, ne zaman eklendiğine, pipeline'daki yerine ve varsayılanlarına bakıp etkisini curl ile göstereceğim. Rate limiting ve output cache yalnızca sıralamada geçiyor; ayrıntıları ASP.NET Core Güvenliği serisinin rate limiting yazısında ve Caching serisinin output cache yazısında.

Response Compression

Yanıtları Brotli ya da gzip ile sıkıştırır. Hangi algoritmayı istemcinin kabul ettiği Accept-Encoding header'ından okur:

builder.Services.AddResponseCompression(options =>
{
    // BREACH riskini değerlendirdik: yanıtlarda secret ile kullanıcı girdisi bir arada yok
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = System.IO.Compression.CompressionLevel.Fastest);

// ...

app.UseResponseCompression();
$ curl -s -D - -o /dev/null localhost:5238/api/books/export -H "Accept-Encoding: br"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Encoding: br
Vary: Accept-Encoding

Vary: Accept-Encoding önemli: aradaki cache'lere aynı URL'nin sıkıştırılmış ve sıkıştırılmamış hallerini ayrı tutmalarını söylüyor. .NET 10'da bu header yalnızca yanıt sıkıştırıldığında ekleniyor; .NET 11 yazısında gördüğümüz gibi .NET 11'de her response'a ekleniyor ve Zstandard da listeye girdi. Header'ın HTTP caching'deki rolü Caching serisinin HTTP caching yazısında.

Middleware, response'un body'sini yazarken sıkıştırdığı için body yazan her şeyden önce gelmeli. Varsayılan MIME listesinde application/json ve text/* tipleri var. Boyutları 30.760 byte'lık OpenAPI belgesi üzerinde ölçtüm: varsayılan Fastest seviyesinde Brotli 5.977, gzip 4.611 byte döndü. Evet, bu seviyede Brotli gzip'ten büyük çıktı. Brotli'yi Optimal yapınca 2.929, SmallestSize yapınca 2.186 byte oldu. Süre tarafında, ilk istekten sonra curl'ün ölçtüğü toplam süre Fastest ve Optimal'de 3–4 ms, SmallestSize'da 24 ms oldu. Bu belge küçük; büyük ve sık istenen yanıtlarda seviyeyi kendi verinle ölç. İstemci gzip, br gönderdiğinde Brotli seçildi, çünkü providers listesine önce o eklenmiş.

İki karar senin. Birincisi EnableForHttps. Varsayılanı false, çünkü HTTPS üzerinde sıkıştırma CRIME ve BREACH saldırılarına (sıkıştırılmış boyuttan gizli bilgiyi tahmin etmeye dayanan saldırılar) kapı açabilir. Risk, aynı yanıtta bir secret (ör. CSRF token) ile saldırganın etkileyebildiği bir girdinin birlikte bulunmasıyla ortaya çıkıyor; açarken bunu bilerek aç. İkincisi, sıkıştırmayı hangi katmanda yapacağın. Önünde Nginx, YARP ya da bir CDN varsa sıkıştırmayı orada yapmak genelde daha verimli; Learn de server tarafındaki modüllerin bu middleware'den daha hızlı olacağını belirtiyor. O durumda uygulamada kapat.

Request Decompression

.NET 7 ile gelen bu middleware ters yönde çalışıyor: Content-Encoding header'ı taşıyan bir request body'sini açıyor. Depo ekibi stok güncellemelerini toplu ve sıkıştırılmış gönderiyor:

builder.Services.AddRequestDecompression();

// ...

app.UseRequestDecompression();

// Depodan gelen toplu stok dosyası; istemci gzip'le sıkıştırıp gönderiyor
routes.MapPost("/api/inventory/stock-import", (List<StockLine> lines) =>
    TypedResults.Ok(new { Imported = lines.Count, TotalCopies = lines.Sum(l => l.Quantity) }))
    .RequireAuthorization(policy => policy.RequireRole("StoreStaff"));
$ gzip -k stock.json
$ ls -l stock.json stock.json.gz
17201 stock.json
147 stock.json.gz

$ curl -i -X POST localhost:5238/api/inventory/stock-import -H "Authorization: Bearer $STAFF_TOKEN" -H "Content-Type: application/json" -H "Content-Encoding: gzip" --data-binary @stock.json.gz
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"imported":400,"totalCopies":1200}

Middleware'i kaldırdığımda aynı istek 400 döndü; JSON reader ilk byte'ta gzip'in imzasını gördü: '0x1F' is an invalid start of a value. Desteklenen kodlamalar varsayılan olarak Brotli, Deflate ve gzip. Body'yi okuyan her şeyden, yani bir önceki yazılardaki body loglayan middleware'den ve endpoint'lerden önce gelmeli. Bir uyarı: 147 byte'lık bir body açılınca 17 KB oldu. Sıkıştırılmış body'ler küçük bir isteği büyük bir bellek yüküne çevirebilir; Kestrel'in body limiti açılmış body'ye de uygulanıyor, onu Web Server'lar ve Hosting serisinin Kestrel yazısındaki limitlerle birlikte düşün.

Request Timeouts

.NET 8 ile gelen bu middleware endpoint bazında süre sınırı koyuyor. Kestrel'in bağlantı seviyesindeki timeout'larından farklı olarak isteğin işlenme süresine bakıyor:

builder.Services.AddRequestTimeouts(options =>
{
    options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) };
    options.AddPolicy("reports", TimeSpan.FromSeconds(2));
});

// ...

app.UseRequestTimeouts();

routes.MapGet("/api/reports/sales", async (CancellationToken cancellationToken) =>
{
    // Yavaş bir rapor sorgusunu taklit ediyoruz
    await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
    return TypedResults.Ok(new { Total = 125_000m });
}).WithRequestTimeout("reports");
$ curl -i -w "time=%{time_total}\n" localhost:5238/api/reports/sales
HTTP/1.1 504 Gateway Timeout
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.5","title":"Gateway Timeout","status":504,"traceId":"00-a3a2e7307749d25a543786938f7c0333-af955a22ce6dd4e2-00"}time=2.013107
warn: Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutsMiddleware[1]
      Timeout exception handled.

Middleware isteği zorla kesmiyor; süre dolunca HttpContext.RequestAborted'ı iptal ediyor. Handler'daki CancellationToken ona bağlı olduğu için Task.Delay iptal oldu, middleware de exception'ı yakalayıp 504 yazdı. Token'ı async ve CancellationToken yazısında anlattığımız gibi veritabanına kadar taşımadıysan iş arka planda devam eder ve süre sınırı yalnızca kağıt üzerinde kalır. Status code'u policy'deki TimeoutStatusCode ile, yanıtı da WriteTimeoutResponse ile değiştirebilirsin. Controller'larda aynı iş [RequestTimeout("reports")] attribute'uyla, hiç timeout istemeyen bir endpoint'te de DisableRequestTimeout() ile yapılıyor. Middleware routing'den sonra gelmeli ki endpoint'in metadata'sını görebilsin.

Status Code Pages ve Exception Handler

39'uncu yazıda Production'daki binding hatalarının body'siz 400 döndüğünü görmüştük. 04'te anlattığımız exception handler'la birlikte status code pages middleware'i bu boşluğu kapatıyor:

builder.Services.AddProblemDetails();
builder.Services.Configure<ExceptionHandlerOptions>(options =>
    // Development'ta binding hataları exception olarak gelir; 400'ü 500'e çevirme
    options.StatusCodeSelector = exception =>
        exception is BadHttpRequestException badRequest ? badRequest.StatusCode : StatusCodes.Status500InternalServerError);

// ...

app.UseExceptionHandler();
app.UseStatusCodePages();
$ curl -i localhost:5238/api/nope
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-9cb4bd56f64ef644d95e12d2692d0dc0-276f186703e377f0-00"}

$ curl -i -X DELETE localhost:5238/api/books
HTTP/1.1 405 Method Not Allowed
Content-Type: application/problem+json
Allow: GET

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.6","title":"Method Not Allowed","status":405,"traceId":"00-9591b0780679a1ecf57e3c863991f82a-17bc9a1e30b235e8-00"}

$ curl -i "localhost:5238/api/books?sort=price"
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"00-ad33dded4c448d1ebf4c795dae5355c2-38437f875617a987-00"}

UseStatusCodePages() yalnızca body'si olmayan ve 400–599 aralığında biten yanıtlara dokunuyor; AddProblemDetails() kayıtlıysa body'yi ProblemDetails olarak yazıyor. Endpoint kendi body'sini yazdıysa hiçbir şey yapmıyor. Exception handler'a StatusCodeSelector eklememin sebebi gerçek bir sürprizdi. Development'ta ThrowOnBadRequest true olduğu için binding hataları BadHttpRequestException olarak fırlıyor. Pipeline'a UseExceptionHandler() koyunca bu exception'ı developer exception page'den önce o yakaladı ve gzip'li body'yi middleware olmadan gönderdiğim istek Development'ta 500 döndü. .NET 9 yazısında gördüğümüz StatusCodeSelector exception'ın kendi status code'unu koruyor. Bunun bir bedeli de var: exception handler'ı koşulsuz eklediğin için Development'ta da ayrıntılı exception sayfası yerine ProblemDetails görüyorsun. Template'ler bu yüzden onu if (!app.Environment.IsDevelopment()) içine alıyor; API'nin her ortamda aynı hata gövdesini dönmesini tercih ettiğimiz için biz koşulsuz bıraktık.

URL Rewrite ve Host Filtering

Rewrite middleware'i iki ayrı iş yapıyor. Redirect istemciye yeni adresi söyler ve istemci ikinci bir istek atar; rewrite ise URL'yi sunucu içinde değiştirir, istemci farkı görmez. 25'inci versioning yazısından kalan /api/v1/books/... adreslerini kalıcı olarak taşımak ve eski bir katalog entegrasyonunun kullandığı /catalog/... adresini sessizce yeni endpoint'e bağlamak için:

app.UseRewriter(new RewriteOptions()
    .AddRedirect("^api/v1/books/(.*)$", "api/books/$1", StatusCodes.Status301MovedPermanently)
    .AddRewrite("^catalog/(.*)$", "api/books/$1", skipRemainingRules: true));
$ curl -i localhost:5238/api/v1/books/9780441013593
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: /api/books/9780441013593

$ curl localhost:5238/catalog/9780441013593
{"id":1,"isbn":"978-0441013593","title":"Dune","price":450,"category":"science-fiction","series":"Dune Chronicles"}

Kurallar regex ve baştaki / olmadan yazılıyor. Rewriter URL'yi değiştirdiği için routing'den önce gelmeli. 301'i tarayıcılar ve proxy'ler cache'leyebilir; geri almanın zor olduğunu bil, emin değilsen önce 302 ile başla.

Host filtering'i eklemen gerekmiyor: WebApplication onu varsayılan olarak kuruyor ve AllowedHosts ayarını okuyor. Şablondaki "AllowedHosts": "*" her host'u kabul eder. Bookstore'da listeyi daralttık:

"AllowedHosts": "localhost;bookstore.example.com"
$ curl -i localhost:5238/api/books -H "Host: evil.example.com"
HTTP/1.1 400 Bad Request
Content-Type: text/html

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
...
<BODY><h2>Bad Request - Invalid Hostname</h2>

Host: bookstore.example.com ile gelen aynı istek 200 döndü; port listede olmadığı halde localhost:5238 de kabul edildi. Bu ayar, Host header'ından link üreten kodun (şifre sıfırlama e-postası, CreatedAtRoute'un Location'ı) saldırganın verdiği bir alan adını kullanmasını engelliyor. Kestrel doğrudan internete açıksa önemli; önündeki proxy host'u zaten kontrol ediyorsa ikinci bir savunma hattı.

Header Propagation

Sipariş akışında Angular istemcisi her isteğe bir X-Correlation-Id koyuyor. API stok bilgisini ayrı bir inventory service'inden alıyor ve aynı id'nin oraya da gitmesini istiyoruz ki iki service'in log'larını birleştirebilelim. Her HttpClient çağrısında header'ı elle kopyalamak yerine:

builder.Services.AddHeaderPropagation(options => options.Headers.Add("X-Correlation-Id"));
builder.Services.AddHttpClient("inventory", client =>
        client.BaseAddress = new Uri(builder.Configuration["Inventory:BaseUrl"]!))
    .AddHeaderPropagation();

// ...

app.UseHeaderPropagation();

routes.MapGet("/api/books/{isbn}/stock", async (Isbn isbn, IHttpClientFactory clients, CancellationToken cancellationToken) =>
{
    HttpClient inventory = clients.CreateClient("inventory");
    StockLine? stock = await inventory.GetFromJsonAsync<StockLine>($"/internal/inventory/{isbn}", cancellationToken);
    return TypedResults.Ok(stock);
});

Inventory service'ini denemek için aynı uygulamada, gelen header'ı loglayan bir /internal/inventory/{isbn} endpoint'iyle taklit ettim:

$ curl localhost:5238/api/books/978-0441013593/stock -H "X-Correlation-Id: ord-7f3a2c"
{"isbn":"9780441013593","quantity":12}
info: Bookstore.Api.Inventory.StockLine[0]
      Inventory lookup for 9780441013593, X-Correlation-Id: ord-7f3a2c

Header'sız istekte inventory tarafındaki değer boş kaldı. Bu paket ayrı bir NuGet paketi, shared framework'te değil. Middleware gelen header'ları request başında bir yere kaydediyor ve AddHeaderPropagation() eklenmiş HttpClient'lar bunları giden isteğe koyuyor; bu yüzden middleware çağrının yapıldığı endpoint'ten önce gelmeli. Correlation id'yi kendin taşımak yerine W3C trace context'i ve OpenTelemetry'yi kullanıyorsan traceparent zaten taşınıyor; ikisinin farkı Logging serisinin "Correlation ID ve Trace Context" yazısında anlatılıyor. Header propagation, trace dışındaki iş header'ları (tenant, dil, kanal) için daha anlamlı.

Sıralama ve Tahminin Cevabı

42-middleware-order-tr.png

Tahminin cevabı: preflight 400 döndü ve CORS header'ı yoktu.

$ curl -i -X OPTIONS localhost:5238/api/institution/orders -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type,x-tenant-id"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"Missing or unknown X-Tenant-Id header."}

Tarayıcı preflight'ta özel header'ların değerini göndermez, yalnızca adlarını sorar. TenantMiddleware, UseCors'tan önce durduğu için preflight'ı sıradan bir istek sandı, header'ı bulamadı ve 400 döndü. CORS middleware'i hiç çalışmadığı için yanıtta Access-Control-Allow-Origin da yoktu; tarayıcı konsolunda gerçek hatayı değil bir CORS hatası görürsün ve sipariş hiç gönderilmez. Kendi kısa devre yapan middleware'ini UseCors, UseAuthentication ve UseAuthorization'dan sonraya taşıyınca preflight 204 aldı, tenant'sız sıradan istek de 400'ünü CORS header'ıyla birlikte döndü. Exception handler'ın ürettiği 500'lerde ise CORS header'ı korundu; UseCors'u exception handler'dan önce de sonra da koysam yanıt Access-Control-Allow-Origin taşıdı. Yani CORS header'ını kaybettiren şey genelde exception handler değil, CORS'tan önce cevap veren bir middleware. Bookstore'un son pipeline'ı:

app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRewriter(new RewriteOptions()
    .AddRedirect("^api/v1/books/(.*)$", "api/books/$1", StatusCodes.Status301MovedPermanently)
    .AddRewrite("^catalog/(.*)$", "api/books/$1", skipRemainingRules: true));
app.UseResponseCompression();
app.UseRequestDecompression();
app.UseMiddleware<ResponseTimeMiddleware>();
app.UseMiddleware<RequestBodyLoggingMiddleware>();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<TenantMiddleware>();
app.UseAntiforgery();
app.UseRequestTimeouts();
app.UseHeaderPropagation();

Learn'ün tablosundaki kurallar bu sırayı açıklıyor: forwarded headers, düzelttiği alanları kullanan her şeyden önce; exception handler, hata üretebilecek her şeyden önce; rewrite, URL'yi kullanan her şeyden önce; decompression, body'yi okuyan her şeyden önce; UseCors, UseAuthentication ve UseAuthorization bu sırayla; antiforgery authentication ve authorization'dan sonra; request timeouts exception handler'dan ve routing'den sonra. HTTPS kullanıyorsan UseHsts ve UseHttpsRedirection, Learn'ün tablosuna göre isteği değiştiren forwarded headers ve rewrite'tan sonra, URL'yi kullanan middleware'lerden önce gelir; rate limiter ve output cache de routing ve CORS'tan sonra. HTTP logging gibi her şeyi görmesi gereken middleware'ler ise en başa yakın durur.

Sık Düşülen Tuzaklar

Birincisi, compression'ı hem reverse proxy'de hem uygulamada açmak. Aynı yanıt iki kez sıkıştırılmaz ama hangi katmanın hangi tipleri hangi seviyede sıkıştırdığı belirsizleşir ve CPU iki yerde harcanabilir. Tek bir katman seç; proxy yapıyorsa uygulamada AddResponseCompression'ı hiç ekleme.

İkincisi, timeout'un debugger'da çalışmadığını bilmemek. Learn açıkça söylüyor: debugger bağlıyken timeout middleware'i tetiklenmiyor, Kestrel'in timeout'ları gibi. Visual Studio'da F5 ile "timeout çalışmıyor" diye saatler harcama; Ctrl+F5 ya da dotnet run ile dene.

Üçüncüsü, kendi kısa devre yapan middleware'ini CORS'tan önce koymak. Tahmin bölümünde gördük: preflight bozulur ve hata yanıtları CORS header'ı taşımaz. Kural basit: istemciye cevap verebilen her middleware UseCors'tan sonra gelmeli.

Dördüncüsü, UseStatusCodePages()'ın kendi body'ni ezeceğini sanmak. Body'si olan bir 404'e dokunmaz; tersine, body'si olmasını beklediğin bir yanıt ProblemDetails'e dönüşüyorsa handler bir şey yazmıyor demektir.

Küçük Bir Deneme

★☆☆ GzipCompressionProviderOptions'ın seviyesini SmallestSize yap ve /openapi/v1.json'ın gzip boyutunu Brotli'nin üç seviyesiyle karşılaştır. Sonuçları kendi uygulamanın en büyük üç yanıtında tekrarla.

★★☆ reports policy'sine WriteTimeoutResponse ekleyip 504 yerine Retry-After: 30 header'ı olan bir 503 dön. Handler'da CancellationToken'ı Task.Delay'e vermeyi unutursan ne olduğunu gör.

★★★ TenantMiddleware'i UseCors'tan önceye geri al, Angular istemcisinden ya da tarayıcı konsolunda fetch ile bir kurumsal sipariş gönder. Konsolda gördüğün hata mesajını curl çıktısıyla karşılaştır ve sonra middleware'e HttpMethods.IsOptions kontrolü eklemenin neden yeterli bir çözüm olmadığını düşün.

Built-in middleware'lerle API'nin her yanıtı tutarlı bir biçime kavuştu ama hepsi İngilizce. Bir sonraki yazıda localization'a geçiyoruz: Accept-Language, request culture, yerelleştirilmiş validation ve ProblemDetails mesajları ve hangi alanların asla yerelleştirilmemesi gerektiği.