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

ASP.NET Core'da Status Code ve Response: Ok, CreatedAtAction ve ProblemDetails

Mert Özen 4 Kas 2026 8 dk 2 görüntülenme
ASP.NET Core'da Status Code ve Response: Ok, CreatedAtAction ve ProblemDetails

ControllerBase yardımcılarıyla doğru status code'u dönmeyi, null dönünce gelen sürpriz 204'ü, CreatedAtAction ve Location header'ını, 400/404/409 ayrımını, ProblemDetails'i ve content negotiation'ı gerçek yanıtlarla anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Aynı ProductsController içinde üç action var. Üçü de olmayan bir ürün için, yani id 99 iken çağrılıyor:

[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return Ok(product);
}

[HttpGet("{id:int}/text-404")]
public ActionResult<Product> GetWithMessage(int id)
{
    return NotFound($"Product {id} was not found.");
}

[HttpGet("{id:int}/null")]
public ActionResult<Product> GetNull(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    return product!;
}

Her biri için iki şeyi tahmin et: status code ne olur ve response'un Content-Type'ı ne olur? Özellikle üçüncüsü çoğu kişiyi şaşırtır.

Status Code'u Koddan Okumak

Bir önceki yazıda controller'ları ControllerBase'den türettik. Bu class'ın en çok kullandığın parçası, her biri belirli bir status code'u temsil eden yardımcı method'lar. Ok() 200, CreatedAtAction() 201, NoContent() 204, BadRequest() 400, NotFound() 404, Conflict() 409 üretir. Listede adı olmayan bir kod lazımsa StatusCode(503) gibi genel olanı kullanırsın.

Bu yardımcıların bir güzel tarafı, kodu okuyanın HTTP'yi düşünmesini sağlaması. return NotFound(); satırı hem derleyiciye hem de altı ay sonra bu dosyayı açacak kişiye aynı şeyi söyler. StatusCode(404) da aynı işi görür ama niyeti o kadar net anlatmaz.

Yardımcıların çoğunun iki hâli var. NotFound() body'siz bir sonuç döner, NotFound(value) ise verdiğin değeri body'ye yazar. İlk iki tahminin cevabı tam olarak bu farkta saklı:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404,"traceId":"00-92e83b6bd36040b7249171e380f03441-6681a330e27d0cdc-00"}

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8

Product 99 was not found.

Body'siz NotFound()'u [ApiController] yakalayıp ProblemDetails'e çevirdi. String verdiğinde ise framework senin body'ne dokunmadı ve string'i olduğu gibi text/plain olarak yazdı. İstemci açısından bu iki yanıt çok farklı: biri her hata için aynı şemayı bekleyen bir JSON, diğeri parse edilecek bir cümle.

200 mü, 204 mü?

Üçüncü action'ın gerçek yanıtı şu:

HTTP/1.1 204 No Content
Content-Length: 0

404 beklerdin, değil mi? null değeri ActionResult<Product>'a çevrildiğinde framework bir 200 ve JSON body hazırlıyor. Ama response yazılırken devreye giren HttpNoContentOutputFormatter, değerin null olduğunu görünce status code'u 204'e çeviriyor ve body yazmıyor. İstemci "başarılı ama içerik yok" anlıyor, oysa sen "böyle bir ürün yok" demek istiyordun. Kural basit: bir kaynak bulunamadıysa bunu her zaman NotFound() ile açıkça söyle.

204'ün doğru yeri, işlemin başarılı olduğu ama geri gönderecek bir şeyin olmadığı durumlar. Bir önceki yazıdaki PUT ve DELETE action'larımız bu yüzden NoContent() dönüyordu. Güncellenmiş kaynağı geri dönmek istersen PUT için Ok(existing) da geçerli bir tercih; önemli olan API'nin her yerinde aynı kuralı izlemek.

201 Created ve Location Header'ı

Yeni bir kaynak oluşturduğunda 201 dönmek, istemciye iki bilgi verir: kayıt oluştu ve şu adreste duruyor. İkinci bilgi Location header'ında taşınır:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
    if (Products.Any(p => p.Name.Equals(product.Name, StringComparison.OrdinalIgnoreCase)))
    {
        return Conflict();
    }

    product.Id = Products.Max(p => p.Id) + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

CreatedAtAction üç parametre alır. Birincisi, yeni kaynağı okuyan action'ın adı. İkincisi, o action'ın route'unu doldurmak için gereken değerler; burada {id:int} segment'i için id. Üçüncüsü de body'ye yazılacak nesne. Framework routing bilgisini kullanarak GetById'nin URL'ini geriye doğru üretir ve Location'a koyar. Adresi elle string birleştirerek kurmana gerek kalmaz; route'u değiştirdiğinde header da kendiliğinden güncellenir.

Aynı işi yapan iki kardeşi daha var. CreatedAtRoute, action adı yerine [HttpGet("{id:int}", Name = "GetProductById")] gibi verdiğin route adını kullanır. Created(uri, value) ise adresi senin vermeni bekler; başka bir servisteki bir kaynağa işaret ettiğin nadir durumlar için.

4xx Ailesi: BadRequest, NotFound, Conflict

Yukarıdaki Create'te aynı adla ikinci bir ürün eklemeye çalışırsan şu döner:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.8","title":"Conflict","status":409,"traceId":"00-7202d8f82a1e4c2ea5ecd4cb22901cdb-8bab389715b77eef-00"}

4xx kodları arasında seçim yaparken kendine şu soruyu sor: sorun nerede? İsteğin kendisi bozuksa, yani JSON parse edilemiyor ya da zorunlu bir alan eksikse 400. İstenen kaynak yoksa 404. İstek düzgün ve kaynak da var ama sunucunun mevcut durumuyla çelişiyorsa 409. Aynı adlı ürün, zaten iptal edilmiş bir siparişi tekrar iptal etmek, eski bir versiyonun üzerine yazmaya çalışmak tipik 409 örnekleri.

Bir postane düşün. Zarfın üstünde adres yazmıyorsa paket geri döner: 400. Adres yazıyor ama öyle bir sokak yoksa: 404. Sokak da ev de var ama ev sahibi aynı paketi dün zaten teslim almışsa: 409.

ProblemDetails: Hataların Ortak Dili

Şimdiye kadar gördüğün hata body'lerinin hepsi aynı şemayı izliyor. Bu şema RFC 7807'de tanımlanmış ProblemDetails formatı. type hata türünü anlatan bir URI, title kısa bir başlık, status status code'un kendisi, detail bu olaya özel açıklama, instance da hatanın oluştuğu kaynak. traceId standarda ASP.NET Core'un eklediği bir alan; log'larda bu isteği bulmanı sağlar.

Varsayılan body'de detail yok. Eklemek istersen Problem() yardımcısı var:

[HttpGet("{id:int}/problem-404")]
public ActionResult<Product> GetWithProblem(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return Problem(
            title: "Product not found",
            detail: $"There is no product with id {id}.",
            statusCode: StatusCodes.Status404NotFound);
    }

    return product;
}
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Product not found","status":404,"detail":"There is no product with id 99.","traceId":"00-524a0585737aed691a3c16fabf7bc562-20d46596e41a980c-00"}

Hem istemciye okunabilir bir mesaj verdin hem de şemayı bozmadın. String body dönen NotFound("...") yerine tercih etmen gereken yol bu.

[ApiController]'ın body'siz sonuçları nasıl ProblemDetails'e çevirdiğini merak ediyorsan, cevap ApiBehaviorOptions.ClientErrorMapping sözlüğü. .NET 6'da bu sözlükte 400, 401, 403, 404, 406, 409, 415, 422 ve 500 için hazır title ve type değerleri var. Listede olmayan bir kod döndüğünde ne olduğuna bak:

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json; charset=utf-8

{"status":503,"traceId":"00-0c4670cff98695f81c7be03511b9582c-1d60162a5355e679-00"}

StatusCode(StatusCodes.Status503ServiceUnavailable) yine ProblemDetails üretti ama type ve title boş kaldı. Eksik kodu sözlüğe sen ekleyebilirsin:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.ClientErrorMapping[StatusCodes.Status503ServiceUnavailable] = new ClientErrorData
        {
            Title = "Service Unavailable",
            Link = "https://tools.ietf.org/html/rfc7231#section-6.6.4"
        };
    });

Bu ayardan sonra aynı istek "type":"https://tools.ietf.org/html/rfc7231#section-6.6.4","title":"Service Unavailable" alanlarıyla geliyor. Aynı kod .NET 10'da ayarsız da dolu bir title döndürüyor ve type adresleri RFC 9110'a taşınmış durumda; ProblemDetails'in sonraki sürümlerde nasıl geliştiğini ilerleyen sürüm yazılarında göreceğiz. Bu dönüşümü tamamen kapatmak istersen options.SuppressMapClientErrors = true var, ama ortak bir hata şemasından vazgeçmek için iyi bir sebebin olmalı.

Content Negotiation Kısaca

İstemci hangi formatta yanıt istediğini Accept header'ıyla söyler. Bu pazarlığa content negotiation denir. Önce varsayılan davranışa bakalım: Accept: application/xml göndersen bile yanıt 200 OK ve application/json geliyor. Framework XML yazamadığı için sessizce elindeki ilk formatter'a, yani JSON'a düşüyor.

Bu davranışı iki ayarla değiştirebilirsin:

builder.Services.AddControllers(options =>
{
    // İstenen format desteklenmiyorsa JSON'a düşme, 406 dön
    options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters();

Sadece ReturnHttpNotAcceptable açıkken aynı istek 406 Not Acceptable döner. AddXmlSerializerFormatters()'ı da eklediğinde XML konuşmaya başlar:

HTTP/1.1 200 OK
Content-Length: 179
Content-Type: application/xml; charset=utf-8

<Product xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><Id>1</Id><Name>Mechanical Keyboard</Name><Price>1450</Price></Product>

Hata yanıtları da formatı takip eder; 404 bu kez application/problem+xml olarak gelir. Çoğu modern API yalnızca JSON konuşur ve bu gayet makul. Ama XML bekleyen eski bir entegrasyonla karşılaştığında controller kodunun tek satırına dokunmadan format ekleyebildiğini bilmek işe yarar.

Sık Düşülen Tuzaklar

En sinsi olanı async action'larda CreatedAtAction. Async'i ayrı bir yazıda konuşacağız ama tuzağı şimdiden bil:

[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetByIdAsync(int id)
{
    await Task.Delay(10);
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

[HttpPost]
public async Task<ActionResult<Product>> CreateAsync(Product product)
{
    await Task.Delay(10);
    product.Id = Products.Count + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetByIdAsync), new { id = product.Id }, product);
}

POST isteği 500 Internal Server Error döner ve log'da şu satır durur: System.InvalidOperationException: No route matches the supplied values. Sebebi, ASP.NET Core'un action adlarındaki Async son ekini varsayılan olarak silmesi. Action'ın adı framework için GetById, sen ise GetByIdAsync arıyorsun. Daha kötüsü, exception response yazılırken fırlıyor; ürün listeye çoktan eklenmiş durumda. Yani istemci hata aldığını sanıp tekrar deneyince elinde iki kayıt olur. Çözüm, GET action'ına [ActionName(nameof(GetByIdAsync))] eklemek ya da MvcOptions.SuppressAsyncSuffixInActionNames'i false yapmak.

İkinci tuzak, her şeye 200 dönüp hatayı body'nin içine gömmek: {"success":false,"error":"not found"}. Bu API'yi kullanan her istemci status code'u görmezden gelip body'yi parse etmek zorunda kalır, cache'ler ve monitoring araçları da her şeyi başarılı sanar. Status code'lar HTTP'nin kendi hata dili; onu kullan.

Üçüncüsü, hata body'lerinde karışık format kullanmak. Bir action NotFound("...") ile string, diğeri NotFound() ile ProblemDetails dönerse istemci her endpoint için ayrı bir hata ayrıştırıcı yazmak zorunda kalır. Mesaj vermek istiyorsan Problem() kullan.

Küçük Bir Deneme

★☆☆ GetById'yi return product!; şeklinde yazıp olmayan bir id için istek at ve 204'ü kendi gözünle gör. Sonra NotFound()'a geri dön.

★★☆ Create action'ındaki Conflict()'i, çakışan ürünün adını detail alanında anlatan bir Problem() çağrısına çevir. Status code 409 kalsın.

★★★ ClientErrorMapping üzerinden 404 için Link değerini kendi dokümantasyon adresinle değiştir, ardından AddXmlSerializerFormatters() ekleyip aynı 404'ü hem Accept: application/json hem Accept: application/xml ile iste. İki yanıtta da kendi adresinin göründüğünü doğrula.

Bu yazıda body'nin içine ne yazdığımızı konuştuk. Peki istemcinin gönderdiği veri action parametrelerine nasıl ulaşıyor? id'nin route'tan, product'ın body'den geldiğini [ApiController]'ın tahmin ettiğini söyledik; bir sonraki yazıda bu tahminin kurallarını, query string, header ve form'dan veri okumayı ve binding hatalarını inceleyeceğiz.