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

ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails

Mert Özen 4 Kas 2026 9 dk 1 görüntülenme
ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails

.NET 6'da yakalanmayan exception'ın gerçek yanıtından başlayıp UseExceptionHandler ve error controller'a, oradan domain exception'larını 404/409/500 ProblemDetails yanıtlarına çeviren kendi middleware'imize geçiyoruz.

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

Projeye, stok servisine ulaşamadığında exception fırlatan bir endpoint ekleyelim:

[ApiController]
[Route("api/[controller]")]
public class DiagnosticsController : ControllerBase
{
    [HttpGet("boom")]
    public IActionResult Boom()
    {
        throw new InvalidOperationException("Stock service is unreachable.");
    }
}

Program.cs'te hata yönetimiyle ilgili tek satır yok. Uygulama Production ortamında çalışıyor ve bir istemci GET /api/diagnostics/boom çağırıyor. İstemci ne görür: exception mesajını mı, bir JSON hata nesnesini mi, stack trace'i mi, yoksa hiçbirini mi? Cevap bir sonraki bölümde.

Exception Yakalanmazsa Ne Olur?

.NET 6'da Production ortamında gerçek yanıt şu:

HTTP/1.1 500 Internal Server Error
Content-Length: 0
Server: Kestrel

Hiçbiri. Status code 500, body boş. Exception'ı Kestrel yakaladı, logladı ve istemciye çıplak bir 500 döndü. İstemci tarafındaki geliştirici sadece "bir şey patladı" bilgisine sahip; ne olduğunu, hatta bunun bir bug mı yoksa geçici bir sorun mu olduğunu bilmiyor.

Aynı isteği Development ortamında atınca tablo değişiyor:

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8

System.InvalidOperationException: Stock service is unreachable.
   at ProductCatalog.Controllers.DiagnosticsController.Boom() in .../Controllers/DiagnosticsController.cs:line 12

Bu developer exception page. .NET 6'da WebApplication onu Development ortamında pipeline'ın en başına kendiliğinden ekler. Tarayıcıdan bakarsan HTML sayfası, curl gibi bir istemciden bakarsan düz metin döner. Geliştirme sırasında harika bir araç; Production'da ise asla açık olmamalı, çünkü kaynak kod yollarını, method isimlerini ve iç yapını dışarıya anlatır.

İki uç arasında bizim istediğimiz şey belli: her ortamda tutarlı, makinenin okuyabileceği, hassas bilgi sızdırmayan bir hata yanıtı. API dünyasında bunun standardı RFC 7807 ProblemDetails formatı.

Her Action'a try/catch Yazmamak

İlk refleks genellikle her action'ı try/catch ile sarmak olur. Elli action'lık bir API'de bu elli kopya demek, ve kopyalardan biri mutlaka farklı bir format dönmeye başlar. Ayrıca exception'ın yalnızca action'da değil, model binding'de, bir filter'da ya da bir middleware'de de fırlayabileceğini unutma. Hata yönetimi pipeline'ın kenarına ait bir iş; tıpkı binanın her odasına ayrı yangın tüpü koymak yerine merkezi bir yangın alarm sistemi kurmak gibi.

ASP.NET Core'un .NET 6'daki yerleşik cevabı UseExceptionHandler middleware'i.

UseExceptionHandler ve Error Controller

UseExceptionHandler, pipeline'da kendinden sonra fırlayan exception'ı yakalar, isteği verdiğin path'e yeniden yönlendirip pipeline'ı bir kez daha çalıştırır. O path'te hata yanıtını üreten küçük bir controller dururuz:

[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class ErrorController : ControllerBase
{
    [Route("/error")]
    public IActionResult HandleError() => Problem();

    [Route("/error-development")]
    public IActionResult HandleErrorDevelopment([FromServices] IHostEnvironment hostEnvironment)
    {
        if (!hostEnvironment.IsDevelopment())
        {
            return NotFound();
        }

        IExceptionHandlerFeature exceptionHandlerFeature =
            HttpContext.Features.Get<IExceptionHandlerFeature>()!;

        return Problem(
            detail: exceptionHandlerFeature.Error.StackTrace,
            title: exceptionHandlerFeature.Error.Message);
    }
}
if (app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error-development");
}
else
{
    app.UseExceptionHandler("/error");
}

Action'larda bilerek [HttpGet] yok: hata GET'te de POST'ta da olabilir, yeniden çalıştırılan istek orijinal HTTP method'unu korur. [ApiExplorerSettings(IgnoreApi = true)] ise bu controller'ı Swagger belgesinin dışında tutar. Orijinal exception'a IExceptionHandlerFeature üzerinden ulaşıyoruz. Production'da yanıt artık şöyle:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-0c05739c205588c9d1100a611d64182a-f0155360ce0f59cc-00"}

Problem() method'u ProblemDetails'i bizim için üretti; traceId de geldi. Bu değeri loglarda arayıp tam o isteğin kaydına ulaşabilirsin. Yeni sürümlerde type alanı RFC 9110'u gösteren bir link oluyor; yapı aynı.

Ama bir sorun var. Önceki yazılarda service'ten "bulunamadı" bilgisini null ile taşıyorduk. Service bu bilgiyi exception ile vermeye başlarsa, UseExceptionHandler onu da aynı 500'e çevirir. Bulunamayan ürün için 404 yerine "An error occurred" yazan bir 500 görürüz. Exception'ın türüne göre karar veren bir yapıya ihtiyacımız var.

Domain Exception'ları

İş kurallarının ihlalini anlatan kendi exception'larımızı tanımlıyoruz:

public abstract class DomainException : Exception
{
    protected DomainException(string message) : base(message)
    {
    }
}

public class NotFoundException : DomainException
{
    public NotFoundException(string resource, object key)
        : base($"{resource} with id {key} was not found.")
    {
    }
}

public class ConflictException : DomainException
{
    public ConflictException(string message) : base(message)
    {
    }
}

Service artık null dönmek yerine ne olduğunu açıkça söylüyor:

public async Task<ProductDto> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
    ProductDto? product = await _db.Products
        .AsNoTracking()
        .Where(p => p.Id == id)
        .Select(ToDto)
        .FirstOrDefaultAsync(cancellationToken);

    return product ?? throw new NotFoundException("Product", id);
}

public async Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken = default)
{
    bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name, cancellationToken);
    if (nameTaken)
    {
        throw new ConflictException($"A product named '{request.Name}' already exists.");
    }

    Product product = new()
    {
        Name = request.Name,
        Price = request.Price,
        CategoryId = request.CategoryId
    };

    _db.Products.Add(product);
    await _db.SaveChangesAsync(cancellationToken);

    return await GetByIdAsync(product.Id, cancellationToken);
}

Dönüş tipindeki soru işareti kayboldu: Task<ProductDto?> yerine Task<ProductDto>. Controller da sadeleşti; artık null kontrolü yok:

[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
    return await _productService.GetByIdAsync(id, cancellationToken);
}

[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken cancellationToken)
{
    ProductDto created = await _productService.CreateAsync(request, cancellationToken);
    return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}

Kendi Exception Middleware'imiz

Şimdi exception'ı türüne göre doğru status code'a çeviren middleware'i yazıyoruz:

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Exceptions;

namespace ProductCatalog.Middleware;

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;
    private readonly IHostEnvironment _environment;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger,
        IHostEnvironment environment)
    {
        _next = next;
        _logger = logger;
        _environment = environment;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            // istemci vazgeçti; bu bir hata değil
            _logger.LogInformation("Request {Path} was cancelled by the client", context.Request.Path);
            if (!context.Response.HasStarted)
            {
                context.Response.StatusCode = 499;
            }
        }
        catch (Exception exception) when (!context.Response.HasStarted)
        {
            await WriteProblemAsync(context, exception);
        }
    }

    private async Task WriteProblemAsync(HttpContext context, Exception exception)
    {
        (int statusCode, string title) = exception switch
        {
            NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
            ConflictException => (StatusCodes.Status409Conflict, "Conflict"),
            _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
        };

        if (statusCode == StatusCodes.Status500InternalServerError)
        {
            _logger.LogError(exception, "Unhandled exception for {Method} {Path}",
                context.Request.Method, context.Request.Path);
        }
        else
        {
            _logger.LogWarning("{ExceptionType}: {Message}", exception.GetType().Name, exception.Message);
        }

        // domain mesajlarını biz yazdık, güvenli; beklenmeyen hatanın mesajı yalnızca Development'ta
        bool showMessage = exception is DomainException || _environment.IsDevelopment();

        ProblemDetails problem = new()
        {
            Status = statusCode,
            Title = title,
            Detail = showMessage ? exception.Message : null,
            Instance = context.Request.Path
        };
        problem.Extensions["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier;

        context.Response.StatusCode = statusCode;
        await context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json");
    }
}

Birkaç karar var, tek tek bakalım. switch expression tüm eşlemeyi tek yerde topluyor; yeni bir domain exception eklediğinde buraya bir satır eklersin. Alternatif, DomainException'a abstract bir StatusCode property'si koyup her exception'ın kendi kodunu taşıması. O yol eşlemeyi exception'ın yanına taşır ama domain katmanını HTTP status code'larından haberdar eder; bu yazıda eşlemeyi HTTP'nin kenarında tutmayı seçtik.

İkinci karar loglama seviyesi. Bulunamayan bir ürün sistemin arızası değil, istemcinin isteğinin sonucu; Warning yeterli. Beklenmeyen exception ise Error ve stack trace ile loglanıyor. Böylece alarm kuralların gerçekten bakılması gereken şeylerde çalar.

Üçüncü karar Detail. Domain exception mesajlarını biz yazdığımız için istemciye göstermek güvenli. Beklenmeyen bir exception'ın mesajında ise connection string'den dosya yoluna kadar her şey olabilir; o yüzden yalnızca Development'ta görünüyor.

when (!context.Response.HasStarted) filtresi de önemli. Yanıtın header'ları istemciye gitmeye başladıysa artık status code'u değiştiremezsin. Bu durumda exception'ı yakalamıyoruz; yukarı çıkıyor ve sunucu bağlantıyı kapatıyor. Yarım bir JSON'un sonuna ikinci bir JSON eklemekten iyidir.

Kaydı Program.cs'te pipeline'ın en başına yapıyoruz, çünkü middleware yalnızca kendinden sonra gelenlerin exception'larını yakalayabilir:

var app = builder.Build();

app.UseMiddleware<ExceptionHandlingMiddleware>();

app.MapControllers();

app.Run();

Sonuçlar ve İptal Edilen İstekler

Production'da aynı istekleri tekrar attık; POST isteği zaten var olan "Mechanical Keyboard" adını kullanıyor:

GET /api/products/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"title":"Resource not found","status":404,"detail":"Product with id 99 was not found.","instance":"/api/products/99","traceId":"00-2ee0d6bfcc5d2bf03e9225288a823f23-8e0c3b928482ece0-00"}

POST /api/products
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"title":"Conflict","status":409,"detail":"A product named 'Mechanical Keyboard' already exists.","instance":"/api/products","traceId":"00-410e0e54f0d33a7523dde8354667a3d2-8b0a778f835d488e-00"}

GET /api/diagnostics/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{"title":"An unexpected error occurred","status":500,"instance":"/api/diagnostics/boom","traceId":"00-1c200c6a27ea704c07adf66843fc92b0-19b6e41919015f08-00"}

500'de detail alanı yok; Development'ta aynı istek "detail":"Stock service is unreachable." içeriyor. Bu çıktıların .NET 6 ve .NET 10'da birebir aynı olduğunu da gördük, çünkü ProblemDetails'i kendimiz kuruyoruz.

Middleware'deki ilk catch bloğu ise bir önceki yazıdan kalan borcu kapatıyor. İstemci bağlantıyı kopardığında fırlayan OperationCanceledException artık fail değil, şu satırla görünüyor:

info: ProductCatalog.Middleware.ExceptionHandlingMiddleware[0]
      Request /api/diagnostics/slow was cancelled by the client

499, nginx'in "client closed request" için kullandığı resmi olmayan bir kod; istemci zaten gittiği için onu kimse görmez, ama log ve metriklerde iptalleri gerçek hatalardan ayırmanı sağlar. .NET 6'nın StatusCodes class'ında bunun için bir sabit olmadığından sayıyı doğrudan yazdık.

İleriki sürümlerde bu tablo zenginleşiyor: .NET 7 AddProblemDetails ve IProblemDetailsService ile ProblemDetails üretimini standartlaştırıyor, .NET 8 IExceptionHandler interface'i ile bu middleware'in yaptığını resmi bir genişleme noktasına taşıyor. Bunları sürüm yazılarında göreceğiz; .NET 6'da yukarıdaki middleware işi tam olarak görüyor.

Sık Düşülen Tuzaklar

Error action'ına [HttpGet] koymak. Denedik: UseExceptionHandler POST isteğindeki hatayı error endpoint'ine yeniden yönlendirdiğinde istek hâlâ POST olduğu için istemci 500 yerine 405 Method Not Allowed aldı. Error action'larında HTTP method attribute'u olmamalı.

Middleware'i yanlış sıraya koymak. UseMiddleware<ExceptionHandlingMiddleware>() satırı kendinden önce kaydedilen middleware'lerin exception'larını göremez. En başta olmalı.

Production'da stack trace dönmek. exception.ToString() ya da StackTrace'i body'ye yazmak, saldırgana uygulamanın haritasını vermek demek. Detay loglara, traceId istemciye.

[ApiController]'ın 400'lerinin de buradan geçeceğini sanmak. Model validation hatası bir exception değil; [ApiController] action çalışmadan önce kendi ValidationProblemDetails yanıtını üretir. Formatın bizimkiyle birebir aynı olmasını istiyorsan ApiBehaviorOptions.InvalidModelStateResponseFactory ile özelleştirmen gerekir.

Exception'ı yutmak. catch (Exception) { return Ok(); } gibi bir blok hatayı ortadan kaldırmaz, sadece görünmez kılar. Yakaladığın her şeyi ya logla ya da yukarı fırlat.

Her şeyi exception ile modellemek. Exception'lar gerçekten istisnai durumlar için tasarlandı ve göreceli olarak pahalıdır. "Ürün bulunamadı" gibi API'nin doğal akışının parçası olan durumlar için iki seçeneğin var: domain exception (çağıran kod temiz, eşleme tek yerde, ama akış görünmez bir yoldan ilerler) ya da açık bir result tipi (her dönüşü kontrol etmek zorunda kalırsın, ama akış imzada görünür). Bu seride okunabilirlik için domain exception'ları seçtik; yoğun trafikli bir endpoint'te sık fırlayan exception'ları profiler'da görürsen result tipine geçmeyi düşün.

Küçük Bir Deneme

★☆☆ Uygulamayı Production'da ExceptionHandlingMiddleware olmadan, sonra onunla çalıştır ve GET /api/diagnostics/boom yanıtlarını karşılaştır. Logdaki traceId'yi yanıttakiyle eşleştir.

★★☆ ProductService.CreateAsync'e "kategori yoksa ürün oluşturulamaz" kuralını ekle. Bunun için 400 dönen yeni bir domain exception tanımla ve middleware'deki eşlemeye ekle.

★★★ InvalidModelStateResponseFactory ile [ApiController]'ın validation yanıtına da instance alanı ekle ve traceId'nin middleware'deki ile aynı şekilde üretildiğinden emin ol.

Middleware, exception'ları pipeline'ın en dış katmanında yakaladı. Ama bazen bir davranışı yalnızca belirli controller'lara ya da action'lara uygulamak, üstelik action'ın parametrelerine ve sonucuna erişerek yapmak istersin. Bir sonraki yazıda MVC'nin kendi içindeki ikinci pipeline'ı, yani filter'ları ve middleware ile filter arasındaki seçimi konuşacağız.