CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/aspnet-error-handling

Error handling for ASP.NET Core APIs — exception middleware, ProblemDetails,

94

1.13x
Quality

90%

Does it follow best practices?

Impact

100%

1.13x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
aspnet-error-handling
description:
Error handling for ASP.NET Core APIs — global exception handling, ProblemDetails (RFC 7807), IExceptionHandler, structured logging, and safe error responses. Use when building or reviewing ASP.NET Core web APIs, when you see raw 500 responses, when error formats are inconsistent, or when stack traces leak to clients.
keywords:
aspnet error handling, asp.net core exceptions, problem details, exception middleware, global error handler, aspnet api errors, dotnet error handling, IExceptionHandler, structured errors, RFC 7807, ProblemDetails, cancellation token, model validation
license:
MIT

ASP.NET Core Error Handling

Structured, secure error handling for ASP.NET Core APIs. Every API must return consistent machine-readable error responses, never leak internal details, and log enough context for debugging.


Rules

  1. Use ProblemDetails (RFC 7807) for all error responses. This is the standard format for HTTP API errors and is natively supported by ASP.NET Core.
  2. Register a global exception handler so unhandled exceptions never produce raw 500s with stack traces. Use IExceptionHandler (ASP.NET 8+) or custom middleware.
  3. Never leak stack traces or internal details in production error responses. Log them server-side with structured logging instead.
  4. Define custom exception types with HTTP status codes so the handler can map them automatically.
  5. Return validation errors as 400 Bad Request with field-level detail in the ProblemDetails errors dictionary. Use InvalidModelStateResponseFactory or FluentValidation.
  6. Log exceptions with structured data using ILogger — include request path, correlation ID, and exception details. Never use Console.WriteLine for error logging.
  7. Handle OperationCanceledException from cancelled requests gracefully — return 499 or suppress the log, do not treat as 500.
  8. Register error handling middleware first in the pipeline (before routing, auth, etc.) so it catches errors from all downstream middleware.
  9. Use app.UseStatusCodePages to catch non-exception error status codes (404 from missing routes, 405 method not allowed) and return ProblemDetails for those too.
  10. Set Content-Type to application/problem+json for all error responses.

1. Custom Exception Types

RIGHT — Typed exceptions with status codes and error codes

// Exceptions/ApiException.cs
public abstract class ApiException : Exception
{
    public int StatusCode { get; }
    public string ErrorCode { get; }

    protected ApiException(int statusCode, string errorCode, string message)
        : base(message)
    {
        StatusCode = statusCode;
        ErrorCode = errorCode;
    }

    protected ApiException(int statusCode, string errorCode, string message, Exception inner)
        : base(message, inner)
    {
        StatusCode = statusCode;
        ErrorCode = errorCode;
    }
}

public class NotFoundException : ApiException
{
    public NotFoundException(string resource, object id)
        : base(404, "RESOURCE_NOT_FOUND", $"{resource} with ID '{id}' was not found") { }
}

public class ConflictException : ApiException
{
    public ConflictException(string message)
        : base(409, "CONFLICT", message) { }
}

public class BusinessRuleException : ApiException
{
    public BusinessRuleException(string message)
        : base(422, "BUSINESS_RULE_VIOLATION", message) { }
}

public class ForbiddenException : ApiException
{
    public ForbiddenException(string message = "You do not have permission to perform this action")
        : base(403, "FORBIDDEN", message) { }
}

WRONG — Throwing raw exceptions or using generic Exception

// WRONG: No status code mapping, leaks details, inconsistent format
throw new Exception("Order not found");
throw new InvalidOperationException("Cannot cancel shipped order");

// WRONG: Returning error responses manually in every action
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var item = _db.Items.Find(id);
    if (item == null)
        return NotFound(new { error = "not found" }); // inconsistent format
    return Ok(item);
}

2. Global Exception Handler (ASP.NET 8+ with IExceptionHandler)

RIGHT — IExceptionHandler returning ProblemDetails

// ExceptionHandling/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // Let cancellation bubble up without logging as error
        if (exception is OperationCanceledException)
        {
            _logger.LogInformation("Request was cancelled: {Path}", httpContext.Request.Path);
            httpContext.Response.StatusCode = 499; // Client Closed Request
            return true;
        }

        var problemDetails = exception switch
        {
            ApiException apiEx => new ProblemDetails
            {
                Status = apiEx.StatusCode,
                Title = apiEx.ErrorCode,
                Detail = apiEx.Message,
                Instance = httpContext.Request.Path,
                Type = $"https://api.example.com/errors/{apiEx.ErrorCode.ToLowerInvariant()}"
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "INTERNAL_SERVER_ERROR",
                Detail = "An unexpected error occurred. Please try again later.",
                Instance = httpContext.Request.Path
            }
        };

        // Structured logging — full exception for unexpected errors
        if (exception is ApiException)
        {
            _logger.LogWarning(exception,
                "API exception {ErrorCode} on {Method} {Path}",
                ((ApiException)exception).ErrorCode,
                httpContext.Request.Method,
                httpContext.Request.Path);
        }
        else
        {
            _logger.LogError(exception,
                "Unhandled exception on {Method} {Path}",
                httpContext.Request.Method,
                httpContext.Request.Path);
        }

        httpContext.Response.StatusCode = problemDetails.Status ?? 500;
        httpContext.Response.ContentType = "application/problem+json";
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
        return true;
    }
}

RIGHT — Registration in Program.cs

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

// Exception handler FIRST in pipeline
app.UseExceptionHandler();
app.UseStatusCodePages();  // Catches 404s from missing routes, etc.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

WRONG — No global handler, or handler that leaks details

// WRONG: No exception handler — unhandled exceptions produce raw 500
var app = builder.Build();
app.MapControllers();
app.Run();

// WRONG: Developer exception page in production leaks stack traces
if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();
else
    app.UseDeveloperExceptionPage(); // Copy-paste bug — never use in production!

// WRONG: Catching exceptions but returning stack trace
catch (Exception ex)
{
    await context.Response.WriteAsJsonAsync(new
    {
        error = ex.Message,
        stackTrace = ex.StackTrace  // NEVER send to client
    });
}

3. Alternative: Custom Middleware (ASP.NET 6/7 or when IExceptionHandler is unavailable)

// Middleware/ExceptionMiddleware.cs
public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionMiddleware> _logger;
    private readonly IHostEnvironment _env;

    public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
    {
        _next = next;
        _logger = logger;
        _env = env;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            _logger.LogInformation("Request cancelled by client: {Path}", context.Request.Path);
            context.Response.StatusCode = 499;
        }
        catch (ApiException ex)
        {
            _logger.LogWarning(ex, "API error {ErrorCode} on {Path}", ex.ErrorCode, context.Request.Path);
            context.Response.StatusCode = ex.StatusCode;
            context.Response.ContentType = "application/problem+json";
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = ex.StatusCode,
                Title = ex.ErrorCode,
                Detail = ex.Message,
                Instance = context.Request.Path
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception on {Method} {Path}", context.Request.Method, context.Request.Path);
            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/problem+json";
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = 500,
                Title = "INTERNAL_SERVER_ERROR",
                Detail = "An unexpected error occurred. Please try again later."
            });
        }
    }
}

// Program.cs — register FIRST in the pipeline
app.UseMiddleware<ExceptionMiddleware>();

4. Model Validation Errors

RIGHT — Customise InvalidModelStateResponseFactory for consistent ProblemDetails

// Program.cs
builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var problemDetails = new ValidationProblemDetails(context.ModelState)
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "VALIDATION_ERROR",
                Detail = "One or more validation errors occurred.",
                Instance = context.HttpContext.Request.Path,
                Type = "https://api.example.com/errors/validation-error"
            };
            return new BadRequestObjectResult(problemDetails)
            {
                ContentTypes = { "application/problem+json" }
            };
        };
    });

WRONG — Letting default model validation return non-ProblemDetails format

// WRONG: Default returns a different shape than your exception handler
// Client gets inconsistent error formats depending on error source
builder.Services.AddControllers(); // No customization — format mismatch

5. Controller Usage

RIGHT — Throw typed exceptions, keep actions clean

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService) => _orderService = orderService;

    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
    {
        var order = await _orderService.GetByIdAsync(id, cancellationToken)
            ?? throw new NotFoundException("Order", id);
        return Ok(order);
    }

    [HttpPost("{id}/cancel")]
    public async Task<IActionResult> CancelOrder(int id, CancellationToken cancellationToken)
    {
        var order = await _orderService.GetByIdAsync(id, cancellationToken)
            ?? throw new NotFoundException("Order", id);

        if (order.Status == OrderStatus.Shipped)
            throw new BusinessRuleException("Cannot cancel an order that has already been shipped");

        await _orderService.CancelAsync(id, cancellationToken);
        return NoContent();
    }
}

WRONG — Try-catch in every action, inconsistent responses

// WRONG: Duplicated error handling in every action
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)  // Missing CancellationToken
{
    try
    {
        var order = await _orderService.GetByIdAsync(id);
        if (order == null)
            return NotFound(new { message = "not found" }); // inconsistent format
        return Ok(order);
    }
    catch (Exception ex)
    {
        return StatusCode(500, new { error = ex.Message }); // leaks details
    }
}

6. Structured Logging

RIGHT — Use ILogger with structured parameters

_logger.LogError(exception,
    "Failed to process order {OrderId} for customer {CustomerId} on {Method} {Path}",
    orderId, customerId, context.Request.Method, context.Request.Path);

WRONG — String interpolation or Console.WriteLine

// WRONG: String interpolation defeats structured logging
_logger.LogError($"Failed to process order {orderId}: {exception.Message}");

// WRONG: Console output instead of ILogger
Console.WriteLine($"Error: {exception}");

Checklist

  • Global exception handler registered (IExceptionHandler on ASP.NET 8+ or custom middleware)
  • Exception handler is first in the middleware pipeline (before routing/auth)
  • All error responses use ProblemDetails format (RFC 7807)
  • Content-Type set to application/problem+json for error responses
  • Custom exception types defined with HTTP status codes (NotFoundException, ConflictException, etc.)
  • No stack traces or internal details leaked in production responses
  • Unhandled exceptions logged with ILogger.LogError and structured parameters
  • OperationCanceledException handled gracefully (not logged as error, not returned as 500)
  • Model validation errors return 400 with ValidationProblemDetails
  • UseStatusCodePages configured for non-exception error status codes
  • Controller actions accept CancellationToken parameter
  • Controller actions throw typed exceptions instead of returning error results manually

Verifiers

  • aspnet-exception-middleware — Global exception handling with ProblemDetails
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/aspnet-error-handling badge