Mert Özen Her Satırda Daha İleri
Backend ASP.NET Core MVC ve Razor Pages — #14

MVC ve Razor Pages'te Hata Sayfaları: UseExceptionHandler, Status Code Page'leri, Özel 404/500 View'ları ve Hata Kimliği

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
MVC ve Razor Pages'te Hata Sayfaları: UseExceptionHandler, Status Code Page'leri, Özel 404/500 View'ları ve Hata Kimliği

Sayfalar için UseExceptionHandler ve UseStatusCodePagesWithReExecute kullan: status code korunur, adres değişmez; WithRedirects 302 ve 200 ile soft 404 üretir. Bookstore.Web'de öneri gösteren 404, trace id'li 503 sayfası, /api için ProblemDetails, domain exception'ları için IExceptionHandler ve hata sayfasının kendisinin çöktüğü tuzakları gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, runtime 10.0.15) ile derlenip denenmiştir. Aynı uygulamayı .NET 11 (SDK 11.0.100) ile net11.0 hedefinde de çalıştırdım; bu yazıdaki senaryoların hepsi aynı status code'ları döndü.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) MVC tarafı Bookstore.Web, production'da çalışıyor. Katalog veritabanı bir anda cevap vermemeye başladı. Template'ten gelen UseExceptionHandler yerinde, hata view'ı da her sayfa gibi _Layout.cshtml'i kullanıyor. Layout'taki kategori menüsü bir view component ve kategorileri veritabanından çekiyor. Kullanıcı /books/1/dune adresini açınca ne görür: özenle hazırladığın "Bir şeyler ters gitti" sayfasını mı, başka bir şey mi?

İkinci soru: olmayan sayfalar için UseStatusCodePagesWithRedirects("/error/{0}") kullanıyorsun ve 404 sayfan güzel görünüyor. Googlebot /kitaplar/the-hobbit adresini istediğinde hangi status code'u görür?

Development'ta Bir Ekran, Production'da Başka Bir Ekran

WebApplication.CreateBuilder ile kurulan bir uygulamada developer exception page, Development ortamında kendiliğinden açık. Bir controller exception fırlattığında tarayıcıda stack trace, request header'ları ve route değerleri olan bir sayfa çıkar. Katalog kesintisini simüle eden bir ayarla (Catalog__SimulateOutage=true) Development'ta istek attım:

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

<title>Internal Server Error</title>
<h1>An unhandled exception occurred while processing the request.</h1>
...
TimeoutException: Catalog store did not answer within 5 seconds.

Kod Bookstore reposunun src/Bookstore.Web projesinde. Development'ta dotnet run --project src/Bookstore.Web yeterli; production davranışını görmek için ortamı ve kesinti ayarını command line'dan veriyorum:

dotnet run --project src/Bookstore.Web -- --environment Production --Catalog:SimulateOutage=true

Developer exception page geliştirici için. Production'da exception mesajı, dosya yolları ve kaynak kod satırları kullanıcıya gitmemeli. Orada iş UseExceptionHandler'ın: exception'ı loglar, yanıtı temizler ve isteği verdiğin path üzerinden pipeline'da yeniden çalıştırır. Template'teki if (!app.Environment.IsDevelopment()) kontrolü bu yüzden var; Bookstore.Web'de de aynısını koruyorum. Sonucu: Development'ta özel 500 sayfasını göremezsin; denemek için yukarıdaki komutu kullan.

500 Sayfası: UseExceptionHandler, StatusCodeSelector ve Hata Kodu

Pipeline'ın sayfalar için olan kolu şöyle:

static int StatusFor(Exception exception) => exception switch
{
    DomainException domain => domain.StatusCode,
    TimeoutException => StatusCodes.Status503ServiceUnavailable,
    _ => StatusCodes.Status500InternalServerError
};

app.UseWhen(context => !IsApi(context), web =>
{
    // Development'ta bunun yerine developer exception page çalışır
    if (!app.Environment.IsDevelopment())
    {
        web.UseExceptionHandler(new ExceptionHandlerOptions
        {
            ExceptionHandlingPath = "/error",
            StatusCodeSelector = StatusFor
        });
    }
    web.UseStatusCodePagesWithReExecute("/error/{0}");
});

StatusCodeSelector .NET 9 ile geldi. Ondan önce exception handler her zaman 500 dönerdi; status'u değiştirmek için hata action'ında Response.StatusCode'u elle ayarlaman gerekirdi. Burada veritabanı zaman aşımı 503 oluyor. Bu ayrım önemli: 503, "geçici olarak hizmet veremiyorum" demek. Load balancer'lar ve arama motorları bunu kalıcı bir hatadan farklı yorumlar.

Hata action'ı:

[AllowAnonymous]
[Route("error")]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public sealed class ErrorController(IBookCatalog catalog, ILogger<ErrorController> logger) : Controller
{
    // [HttpGet] yok: POST sırasında fırlayan exception da POST olarak yeniden çalıştırılır
    [Route("")]
    public IActionResult ServerError()
    {
        IExceptionHandlerFeature? feature = HttpContext.Features.Get<IExceptionHandlerFeature>();
        if (feature is null)
        {
            return NotFound(); // birisi /error adresini elle yazdı
        }

        string errorCode = Activity.Current?.TraceId.ToString() ?? HttpContext.TraceIdentifier;
        string? userMessage = (feature.Error as DomainException)?.UserMessage;
        return View(new ServerErrorViewModel(Response.StatusCode, errorCode, userMessage));
    }
}

Kesinti sırasında production'da gelen gerçek yanıt:

HTTP/1.1 503 Service Unavailable
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache

<main><h1>We&#x27;ll be right back</h1>
<p>Our team has been notified. Please try again in a few minutes.</p>
<p>Error code: <code>bfae8147ccfd821a59dc1659636dd98d</code></p>
</main>

Sayfadaki hata kodu, isteğin trace id'si: trace context standardının taşıdığı 32 karakterlik hex değer. Console logger'da IncludeScopes açık olduğu için aynı değer log satırında da var:

fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1] => SpanId:ac25ad9704472b12, TraceId:bfae8147ccfd821a59dc1659636dd98d, ParentId:0000000000000000 => ConnectionId:0HNQ2TLIVCI62 => RequestPath:/books/1/dune RequestId:0HNQ2TLIVCI62:00000001 An unhandled exception has occurred while executing the request. System.TimeoutException: Catalog store did not answer within 5 seconds.    at Bookstore.Web.Catalog.InMemoryBookCatalog.ThrowIfOutage() in .../Catalog/InMemoryBookCatalog.cs:line 51 ...

Müşteri hizmetlerine "hata kodu bfae81..." diye yazan kullanıcının isteğini log'da tek aramayla bulursun. RequestPath de orijinal adres; hata action'ı /error'da çalışsa da log scope'u isteğin başında açıldığı için doğru yolu gösteriyor. HttpContext.TraceIdentifier (yukarıdaki RequestId) de iş görür, ama trace id dağıtık izlemede servisler arasında taşınan değer. Logging serisinin Correlation ID ve Trace Context yazısında bu id'nin queue'ya kadar nasıl taşındığını anlattık; hata sayfasında da aynı değeri göstermek zinciri tamamlıyor.

404: ReExecute mi, Redirects mi?

Status code page middleware, gövdesi boş bir 400–599 yanıtı yakalar ve onun yerine bir içerik üretir. Exception yakalamaz; o iş exception handler'ın. Üç seçenek var: UseStatusCodePages() düz metin yazar, WithRedirects tarayıcıyı hata sayfasına redirect eder, WithReExecute ise hata sayfasını aynı istek içinde çalıştırır. Son ikisinin farkını aynı istekle ölçtüm:

14-status-code-pages-tr.png

##### WithReExecute("/error/{0}")
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

##### WithRedirects("/error/{0}")
HTTP/1.1 302 Found
Location: /error/404
followed: 200 http://127.0.0.1:52216/error/404 redirects=1

Bu ölçümde hata action'ı status'u kendisi ayarlamıyordu; template'lerdeki ve çoğu projedeki hâli bu.

WithRedirects iki şeyi bozuyor. Birincisi, istemcinin aldığı ilk yanıt 302; ikinci istekte hata sayfası 200 dönüyor. Sayfada "Page not found" yazıyor ama status 200. Google, içeriği hata söyleyen bir 2xx yanıtı soft 404 olarak işaretliyor; Teknik SEO serisinin HTTP Status Code'ları ve Soft 404 yazısında bunun index'e etkisini görmüştük. İkincisi, adres çubuğunda artık /error/404 var. Kullanıcı yazım hatasını göremez, linki paylaşınca da hata sayfasının adresini paylaşır. WithReExecute'te adres değişmiyor, status 404 kalıyor. Sayfalar için tercih bu.

Re-execute edilen action'da IStatusCodeReExecuteFeature orijinal path'i, path base'i ve query string'i taşıyor. Bookstore'un 404 sayfası bunu öneri için kullanıyor:

[Route("{statusCode:int}")]
public async Task<IActionResult> Status(int statusCode, CancellationToken cancellationToken)
{
    // /error/404'e doğrudan gelen istek 200 almamalı
    Response.StatusCode = statusCode;
    IStatusCodeReExecuteFeature? reExecute = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();

    if (statusCode == StatusCodes.Status404NotFound)
    {
        IReadOnlyList<Book> suggestions = [];
        try
        {
            suggestions = await catalog.SuggestAsync(reExecute?.OriginalPath, 3, cancellationToken);
        }
        catch (Exception ex)
        {
            // Katalog çalışmasa da 404 sayfası açılmalı
            logger.LogWarning(ex, "No suggestions for the 404 page");
        }

        return View("NotFound", new NotFoundViewModel(reExecute?.OriginalPath, suggestions));
    }

    return statusCode == StatusCodes.Status403Forbidden ? View("Forbidden") : View("Status", statusCode);
}

/kitaplar/the-hobbit için gelen sayfa (yanıt 404):

<main><h1>Page not found</h1>
<p>There is no page at <code>/kitaplar/the-hobbit</code>.</p>
    <p>You might be looking for:</p>
    <ul class="books">
            <li><a href="/books/2/the-hobbit">The Hobbit</a></li>
    </ul>
<p><a href="/books">Browse all books</a></p>
</main>

Response.StatusCode = statusCode satırı küçük ama gerekli. Re-execute sırasında status zaten 404, ama biri /error/404 adresini doğrudan açarsa action 200 dönerdi. Satırla birlikte üç adresin gerçek sonuçları: /error → 404, /error/404 → 404, /error/500 → 500.

Bulunamayan Kitap: Controller'ın Kendi 404'ü

Katalogdan kaldırılmış bir kitabın eski linki (/books/21/dune-messiah) route'la eşleşiyor, yani 404'ü controller veriyor. Burada daha fazla bağlam var: slug'daki kelimelerle benzer kitapları önerebiliriz.

[HttpGet("{id:int}/{slug?}")]
public async Task<IActionResult> Details(int id, string? slug, CancellationToken cancellationToken)
{
    Book? book = await catalog.FindAsync(id, cancellationToken);
    if (book is not null)
    {
        return View(book);
    }

    // Kitap kaldırılmış ya da link yanlış: slug'dan öneri çıkaran gerçek bir 404
    IReadOnlyList<Book> suggestions = await catalog.SuggestAsync(slug, 3, cancellationToken);
    ViewResult notFound = View("BookNotFound", suggestions);
    notFound.StatusCode = StatusCodes.Status404NotFound;
    return notFound;
}
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<main><h1>We couldn't find that book</h1>
<p>It may have been removed from the catalog. Maybe one of these?</p>
<ul class="books">
        <li><a href="/books/1/dune">Dune</a> · Frank Herbert</li>
</ul>
</main>

Status code page middleware bu yanıta dokunmuyor, çünkü gövdesi dolu. Kural basit: action NotFound() dönerse gövde boş kalır ve genel 404 sayfası devreye girer; ViewResult ile kendi sayfanı dönersen o sayfa gider. Bir action'da genel sayfayı istemiyorsan [SkipStatusCodePages] attribute'u ya da IStatusCodePagesFeature.Enabled = false ile kapatabilirsin.

Aynı Uygulamada /api İçin ProblemDetails

Bookstore.Web'in içinde sepet ve kitap için küçük bir JSON API var. Onu çağıran JavaScript'e HTML hata sayfası dönmek, 13. yazıda gördüğümüz "login HTML'ini JSON diye parse etmek" sorununun aynısı. Pipeline bu yüzden UseWhen ile ikiye ayrılıyor:

builder.Services.AddProblemDetails();

static bool IsApi(HttpContext context) => context.Request.Path.StartsWithSegments("/api");

// /api: JSON ProblemDetails
app.UseWhen(IsApi, api =>
{
    api.UseExceptionHandler(new ExceptionHandlerOptions { StatusCodeSelector = StatusFor });
    api.UseStatusCodePages();
});

Path verilmemiş UseExceptionHandler() ve UseStatusCodePages(), AddProblemDetails() kayıtlıysa IProblemDetailsService üzerinden JSON yazıyor. Aynı kesintide /api/books/1 ve olmayan bir API adresi:

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

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Service Unavailable","status":503,"traceId":"00-7b79d7bc2321e84771e021b0a87ec027-6a615b117825d85f-00"}

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-19f9f865256e8cee37070c9dbb9cc53f-1e9d7c7a6e48fef7-00"}

traceId alanının ortasındaki 32 karakter, HTML sayfadaki hata koduyla aynı yapıda. ProblemDetails'in ayrıntıları ve API tarafında global hata yönetimi ASP.NET Core Web API serisinin Global Hata Yönetimi yazısında; burada yalnız iki dünyanın aynı uygulamada nasıl ayrıldığına bakıyoruz.

Domain Exception'larını Sayfa Mesajına Çevirmek: IExceptionHandler

Her exception bir hata değil. "War and Peace stokta yok" bir iş kuralı; kullanıcıya "Bir şeyler ters gitti" demek yanlış. Bookstore.Web'de kullanıcıya gösterilebilir kurallar ortak bir base class'tan türüyor:

// Kullanıcının anlayıp düzeltebileceği bir iş kuralı; UserMessage sayfada gösterilebilir
public abstract class DomainException(string userMessage, int statusCode) : Exception(userMessage)
{
    public string UserMessage { get; } = userMessage;
    public int StatusCode { get; } = statusCode;
}

public sealed class OutOfStockException(string title)
    : DomainException($"\"{title}\" is out of stock right now.", StatusCodes.Status409Conflict);

CartService.AddAsync stok yetmezse bu exception'ı fırlatıyor. Sepet formu POST edildiğinde kullanıcının hata sayfası yerine kitap sayfasına geri dönüp bir uyarı görmesini istiyoruz. .NET 8'le gelen IExceptionHandler bunun yeri:

public sealed class DomainExceptionHandler(
    IProblemDetailsService problemDetails,
    ITempDataDictionaryFactory tempDataFactory,
    ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not DomainException domain)
        {
            return false; // bizim değil: 500 sayfası devralır
        }

        // Request.Path burada artık hata path'i (/error); orijinal path feature'da
        string path = context.Features.Get<IExceptionHandlerPathFeature>()?.Path ?? context.Request.Path;
        logger.LogInformation("Rule {Rule} stopped {Method} {Path}: {Message}",
            domain.GetType().Name, context.Request.Method, path, domain.UserMessage);

        if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
        {
            context.Response.StatusCode = domain.StatusCode;
            return await problemDetails.TryWriteAsync(new ProblemDetailsContext
            {
                HttpContext = context,
                Exception = exception,
                ProblemDetails = { Status = domain.StatusCode, Title = domain.UserMessage }
            });
        }

        if (HttpMethods.IsPost(context.Request.Method))
        {
            // Form post: formun geldiği sayfaya flash mesajla dön (Post-Redirect-Get)
            ITempDataDictionary tempData = tempDataFactory.GetTempData(context);
            tempData["Flash"] = domain.UserMessage;
            tempData.Save();
            context.Response.Redirect(LocalReferer(context) ?? "/");
            return true;
        }

        return false; // GET: hata sayfası UserMessage'ı StatusCodeSelector'ın verdiği status'la gösterir
    }
}

Kayıt builder.Services.AddExceptionHandler<DomainExceptionHandler>(). true dönen handler yanıtı kendisi yazmış sayılır; ne sıradaki handler'lar ne de /error path'i çalışır. false dönerse exception yoluna devam eder. LocalReferer, Referer header'ı yalnız aynı host'a aitse kullanıyor; başka bir siteden gelen Referer ile denediğimde redirect /'e gitti. Bu kontrol olmasa handler bir open redirect açığı olurdu.

Gerçek akış: War and Peace'in stoğu 0 ve kullanıcı formu gönderiyor.

HTTP/1.1 302 Found
Location: /books/15/war-and-peace
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8Fsw...; path=/; samesite=lax; httponly

<!-- ardından GET /books/15/war-and-peace -->
<p class="flash" role="status">&quot;War and Peace&quot; is out of stock right now.</p>
<h1>War and Peace</h1>

Aynı exception /api/cart/items'tan gelince 409 Conflict ve "title":"\"War and Peace\" is out of stock right now." içeren bir ProblemDetails dönüyor. Log'da ilginç bir ayrıntı yakaladım: ilk denememde handler context.Request.Path'i logluyordu ve satırda stopped POST /error yazıyordu. Exception handler, handler'ları çağırmadan önce path'i ExceptionHandlingPath'e çeviriyor; orijinal adres için IExceptionHandlerPathFeature.Path'e bakmak gerekiyor.

Bir not: .NET 10'dan itibaren true dönen handler'ın yakaladığı exception'lar middleware tarafından error seviyesinde loglanmıyor (13. yazıda gördük). Handler'daki Information log'u bu yüzden var: iş kuralı ihlali alarm değil, ama izi kalmalı.

Üretimde Dikkat

Hata sayfasının hiçbir veriye ihtiyacı olmamalı. Bookstore.Web'de bütün hata view'ları _ErrorLayout.cshtml kullanıyor: view component yok, service yok, TempData yok, <meta name="robots" content="noindex"> var. 404 sayfasındaki öneriler bile try/catch içinde; katalog kesintisinde de /nope adresi 404 sayfasını öneri listesi olmadan açtı.

Hata action'ına HTTP method kısıtı koyma. Exception handler isteği orijinal method'la yeniden çalıştırıyor. ServerError'a [HttpGet] ekleyip kesinti sırasında sepet formunu gönderdiğimde kullanıcı 503 sayfası yerine Error 405 gördü; GET-only endpoint POST'u kabul etmedi, 405'i de status code page yakaladı. .NET 10 dokümanı aynı durumun status code re-execute için de geçerli olduğunu yazıyor.

401 ve 403 için de aynı sayfalar çalışıyor. Bookstore.Web'in demo authentication handler'ı (Development'ta X-Demo-Role header'ını kullanıcıya çeviriyor) challenge'da 401, forbid'de 403 dönüyor; StoreStaff olmayan biri /reports/sales'i açınca 403 Forbidden ile "You don't have access to this page" sayfası geliyor. Cookie authentication'da ise 401 ve 403 yerine login ve AccessDeniedPath adreslerine redirect gelir; orada access denied sayfasını ayrıca tasarlarsın. Ayrıntısı Authentication ve Authorization serisinin Cookie Authentication yazısında.

Hata yanıtları cache'lenmemeli. Exception handler yanıtı Cache-Control: no-cache,no-store ile işaretliyor. ErrorController üzerindeki [ResponseCache(NoStore = true)] doğrudan açılan hata sayfalarını da kapsıyor.

Middleware sırası önemli. Status code re-execute, routing'i ve status code page middleware'inden sonra eklenen middleware'leri yeniden çalıştırıyor. UseWhen kolları bu yüzden UseRouting'den önce. Bu bölümde resmî belgelerden yararlanılmıştır.

Sık Düşülen Tuzaklar

Hata sayfasının kendisinin hata vermesi. Önce Tahmin Et'teki sorunun cevabı: hata view'ını _Layout ile çalıştırınca kategori menüsü de aynı exception'ı fırlattı. Middleware "An exception was thrown attempting to execute the error handler." diye logladı, orijinal exception'ı yeniden fırlattı ve kullanıcıya şu gitti:

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

Boş bir sayfa, 503 yerine 500 ve hata kodu yok.

404'ü ana sayfaya 302 ile yönlendirmek. İkinci sorunun cevabı da buna yakın: WithRedirects ile Googlebot önce 302, sonra hata metni olan bir 200 görür. Kaldırılan her sayfayı ana sayfaya göndermek de aynı soft 404 sorununu doğurur. Kaldırılan sayfa için 404 ya da 410 dön; içeriğin yeni bir adresi varsa 301 kullan.

Hata sayfasında stack trace göstermek. @Model.Exception.ToString() ya da ex.Message basan bir hata view'ı, production'da dosya yollarını, SQL hata metinlerini ve bazen bağlantı bilgilerini kullanıcıya verir. Kullanıcıya yalnız trace id ve senin yazdığın mesaj (UserMessage) gitsin. Production sunucusunda ASPNETCORE_ENVIRONMENT=Development bırakmak da aynı sızıntı.

Hata view'ının [Authorize] arkasında kalması. ErrorController bütün controller'ların türediği ve [Authorize] taşıyan bir base class'tan türerse, anonim kullanıcının 404'ü 404 sayfasına değil boş bir yanıta dönüşüyor. [AllowAnonymous]'ı kaldırıp [Authorize] koyduğum denemede /kitaplar/the-hobbit şunu döndü:

HTTP/1.1 401 Unauthorized
Content-Length: 0

Aynı istek oturum açmış bir kullanıcıyla 404 sayfasını açtı. Yani hatayı yalnız oturumu olmayanlar, büyük ihtimalle de arama motorları görüyor.

Küçük Bir Deneme

★☆☆ Bookstore.Web'i --environment Production ile çalıştır ve curl -i ile /error, /error/404, /books/999 ve /kitaplar/dune adreslerinin status code'larını kontrol et. ErrorController.Status'taki Response.StatusCode = statusCode satırını silip tekrar dene: hangisi değişti?

★★☆ 410 Gone için ayrı bir view yaz ve katalogdan kalıcı olarak çıkarılan kitap id'lerini bir listede tut. Details action'ı bu id'ler için 404 yerine 410 dönsün. Hangi durumda 404, hangi durumda 410 doğru?

★★★ Katalog kesintisinde 404 sayfası öneri gösteremiyor. Önerileri IMemoryCache'te tutan ve kesinti sırasında son bilinen listeyi gösteren bir SuggestAsync yaz. Hata sayfasının "veri çağırmamalı" kuralıyla bu çözümü nasıl dengelersin: cache'ten okumak hata sayfası için kabul edilebilir bir bağımlılık mı?

Hata sayfası yazarken sepetin nerede durduğunu bilerek açıkta bıraktık: CartService şimdilik yalnız stoğu kontrol ediyor. Serinin son yazısında request'ler arası state'i ele alacağız: TempData'nın cookie ve session provider'ları, Session ile Redis, doğrudan cookie ve query string. Bookstore.Web'e anonim kullanıcının sepetini ve üç adımlı bir kurumsal sipariş formu ekleyip hangi verinin nerede durması gerektiğine karar vereceğiz.