EthicsComputerCourses
Join training +91 90532 10052

Error Handling & ProblemDetails

ASP.NET Core · Lesson 14 of 29 Intermediate 7 min read Updated

Things go wrong in every real app: the database times out, a patient picks a slot that someone else took a second earlier, a client sends nonsense. Good error handling means three things: the user sees a helpful message, developers get enough detail to fix the problem, and attackers learn nothing about your internals.

In this lesson you will see how ASP.NET Core behaves in Development and Production, add a global exception handler, map your own exceptions to the right status codes with IExceptionHandler, and return errors in the standard ProblemDetails format that API clients expect.

Development versus Production

When code throws an unhandled exception:

  • In the Development environment, WebApplication automatically adds the developer exception page. Browsers see a detailed page with the exception, the stack trace, the request headers and the route. API clients that ask for JSON get the same details as JSON. This is only for your machine.
  • In Production, without any handler, the client just receives an empty 500 Internal Server Error. The details go to the logs only.

You must never show the developer page or stack traces in production. They reveal file paths, library versions and sometimes connection details.

The exception handler middleware

UseExceptionHandler catches exceptions from everything after it in the pipeline and produces a safe response. For a Razor Pages or MVC site, point it at an error page:

C#
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

When a page throws, the middleware logs the exception, then re-runs the request against /Error, which shows a friendly "Sorry, something went wrong" page with a request ID the user can quote to support. Put it early in the pipeline, so it can catch errors from everything that follows.

ProblemDetails: a standard error format for APIs

APIs should not return HTML error pages. ProblemDetails (defined in RFC 9457) is a standard JSON shape for HTTP errors, so every client can read errors the same way:

FieldMeaning
typeA URI identifying the kind of problem
titleA short, human-readable summary
statusThe HTTP status code
detailAn explanation of this occurrence
instanceA URI for this specific occurrence (optional)

Register the ProblemDetails service and the API pipeline becomes consistent:

C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();      // unhandled exceptions -> 500 ProblemDetails
app.UseStatusCodePages();       // empty 4xx/5xx responses -> ProblemDetails

app.MapGet("/api/doctors/{id:int}", (int id) =>
    id == 1 ? Results.Ok(new { id, name = "Dr. Meera Kulkarni" }) : Results.NotFound());

app.MapGet("/api/fail", () =>
{
    throw new InvalidOperationException("Database connection string is wrong");
});

app.Run();

AddProblemDetails registers the service that writes ProblemDetails. The exception handler uses it for unhandled exceptions, and UseStatusCodePages uses it to fill in a body for bare status codes such as NotFound(). Without arguments, UseExceptionHandler() relies on that service, so AddProblemDetails is required. A request for /api/doctors/5 now returns status 404 with:

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-8f3c2b1a0d9e4f5a6b7c8d9e0f1a2b3c-4d5e6f7a8b9c0d1e-00"
}

The type links to the matching section of the HTTP standard. traceId identifies the request in your logs; it is different every time. /api/fail returns status 500 with the title "An error occurred while processing your request." and no exception message, so the connection string text stays secret; the full exception goes to the log. Because this example calls UseExceptionHandler() in every environment, it handles exceptions before the developer exception page can see them. Wrap it in if (!app.Environment.IsDevelopment()) if you prefer the detailed page on your own machine.

Your own exceptions with IExceptionHandler

Not every exception means "server bug". If a slot is taken, that is a 409 Conflict that the client should understand. Since .NET 8 you can register classes that implement IExceptionHandler; the exception handler middleware asks each one in turn whether it can handle the exception:

C#
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public class SlotTakenException(string message) : Exception(message) { }

public class SlotTakenHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        if (exception is not SlotTakenException)
        {
            return false; // let the next handler or the default 500 response deal with it
        }

        context.Response.StatusCode = StatusCodes.Status409Conflict;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Slot already booked",
                Detail = exception.Message
            }
        });
    }
}

Register it before building the app, together with ProblemDetails:

C#
builder.Services.AddExceptionHandler<SlotTakenHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler();

Now any code that throws new SlotTakenException("Dr. Farhan Shaikh is already booked at 11:00 on 2026-11-20.") produces:

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Slot already booked",
  "status": 409,
  "detail": "Dr. Farhan Shaikh is already booked at 11:00 on 2026-11-20.",
  "traceId": "00-2b4d6f8a0c1e3a5b7d9f1b3d5f7a9c1e-0a1b2c3d4e5f6a7b-00"
}

Handlers run in the order you register them. Returning false passes the exception to the next one, and if none handles it, the client gets the standard 500.

Returning problems on purpose

Exceptions are for unexpected situations. When your code knows a request is wrong, return a problem result directly; it is clearer and cheaper than throwing:

C#
app.MapPost("/api/appointments", (NewAppointment input) =>
{
    if (input.Date.DayOfWeek == DayOfWeek.Sunday)
    {
        return Results.Problem(
            detail: "The clinic is closed on Sundays.",
            statusCode: StatusCodes.Status400BadRequest,
            title: "Clinic closed");
    }
    return Results.Ok(input);
});

For field-by-field errors use Results.ValidationProblem(errors), which takes a dictionary of field names and messages and returns the same shape as automatic validation errors. In controllers the equivalents are Problem(...) and ValidationProblem(ModelState).

Common mistakes

  • Showing exception messages to users in production. They can contain SQL, file paths or secrets. Log them; show a generic message and the trace ID.
  • Catching every exception and returning 200. Clients then think a failed booking succeeded. Use proper status codes.
  • Using exceptions for normal flow. "Slot not free" checked in advance should return a 409 result, not throw.
  • Placing UseExceptionHandler late in the pipeline. It cannot catch errors from middleware added before it.
  • Calling UseExceptionHandler() without AddProblemDetails(). The app fails at start-up because the middleware has nothing to produce a response with.

Key points

  • Development shows the developer exception page automatically; never enable it in production.
  • UseExceptionHandler("/Error") gives sites a friendly error page; UseExceptionHandler() plus AddProblemDetails() gives APIs ProblemDetails JSON.
  • ProblemDetails (RFC 9457) has type, title, status, detail and instance; ASP.NET Core adds a traceId.
  • IExceptionHandler maps your own exceptions to status codes such as 409; handlers run in registration order.
  • Return Results.Problem or ValidationProblem for expected errors instead of throwing.

Exercise

A train booking API throws QuotaExhaustedException when the Tatkal quota for a train is full and PaymentGatewayDownException when the payment provider is unreachable. Write two IExceptionHandler classes so that the first produces a 409 ProblemDetails with the title "Quota full", and the second produces a 503 with the title "Payments unavailable" and the detail "Please try again in a few minutes." (never the exception message). Show the registrations.

Show solution
C#
public class QuotaHandler(IProblemDetailsService pd) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not QuotaExhaustedException) return false;
        ctx.Response.StatusCode = StatusCodes.Status409Conflict;
        return await pd.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            Exception = ex,
            ProblemDetails = new ProblemDetails { Status = 409, Title = "Quota full", Detail = ex.Message }
        });
    }
}

public class PaymentDownHandler(IProblemDetailsService pd, ILogger<PaymentDownHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not PaymentGatewayDownException) return false;
        logger.LogError(ex, "Payment gateway unreachable");
        ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
        return await pd.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            Exception = ex,
            ProblemDetails = new ProblemDetails
            {
                Status = 503,
                Title = "Payments unavailable",
                Detail = "Please try again in a few minutes."
            }
        });
    }
}
C#
builder.Services.AddExceptionHandler<QuotaHandler>();
builder.Services.AddExceptionHandler<PaymentDownHandler>();
builder.Services.AddProblemDetails();
// after Build():
app.UseExceptionHandler();

The quota message is safe to show, so it becomes the detail. The gateway handler logs the real exception itself (since .NET 10 handled exceptions are not logged automatically) and sends only a fixed, friendly message.

Quick Quiz

1. What does an unhandled exception produce in Production when no exception handler is configured?

2. What does builder.Services.AddProblemDetails() enable?

3. An IExceptionHandler returns false. What happens?

4. Which status code fits "this appointment slot was just booked by someone else"?

Learn ASP.NET Core with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call