Creating a Global Error Handler in your ASP.NET Application

Last week, we looked at a cross-cutting concern in web (API) applications – request validation – and how we can solve for that using MediatR’s pipeline behavior and FluentValidation. If you haven’t read that post, you can check that out, here. This week, in keeping with that same theme of cross-cutting concerns in web applications, let’s look at how we can handle errors within our web applications in a consistent manner. Handling errors is an integral part of any web application development process. While dealing with errors, it’s essential to provide clear, informative messages to users (without giving away too much to potential attackers that can then use that information to hack the site), and log the necessary details for developers to debug and fix the issues. In ASP.NET, a Global Error Handler allows you to manage all unhandled exceptions in one place, ensuring a consistent approach to error handling and reducing repetitive code. In this post, let’s explore some of the approaches that we can take to achieve this.

ASP.NET Filters

One tried and true approach for building a global error handler in ASP.NET is to build a filter. If you are unfamiliar with them, you can check out this post that I wrote on ASP.NET filters, a while back. Here, let’s employ that method to create a global error handler. When building a filter for the purposes of error handling, your custom filter can inherit from the built-in ExceptionFilterAttribute abstract class which in turn implements the built-in IExceptionFilter interface, among other things. Check out this sample implementation, below.

public class ApiExceptionFilterAttribute : ExceptionFilterAttribute
{
    private readonly IDictionary<Type, Action<ExceptionContext>> exceptionHandlers;
    private readonly ILogger<ApiExceptionFilterAttribute> logger;

    public ApiExceptionFilterAttribute(ILogger<ApiExceptionFilterAttribute> logger)
    {
        // Register known exception types and handlers.
        exceptionHandlers = new Dictionary<Type, Action<ExceptionContext>>
            {
                { typeof(ValidationException), HandleValidationException },
                { typeof(NotFoundException), HandleNotFoundException },
                { typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException },
                { typeof(ForbiddenAccessException), HandleForbiddenAccessException }
            };
        this.logger = logger;
    }

    public override void OnException(ExceptionContext context)
    {
        HandleException(context);

        base.OnException(context);
    }

    private void HandleException(ExceptionContext context)
    {
        var type = context.Exception.GetType();
        if (exceptionHandlers.ContainsKey(type))
        {
            exceptionHandlers[type].Invoke(context);
            return;
        }

        if (!context.ModelState.IsValid)
        {
            HandleInvalidModelStateException(context);
            return;
        }

        HandleUnknownException(context);
    }

    private void HandleValidationException(ExceptionContext context)
    {
        var exception = (ValidationException)context.Exception;

        var details = new ValidationProblemDetails(exception.Errors)
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };

        context.Result = new BadRequestObjectResult(details);

        context.ExceptionHandled = true;

        logger.LogError("Handled Validation Exception: {@Exception}", context.Exception);
    }

    private void HandleInvalidModelStateException(ExceptionContext context)
    {
        var details = new ValidationProblemDetails(context.ModelState)
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };

        context.Result = new BadRequestObjectResult(details);

        context.ExceptionHandled = true;

        logger.LogError("Handled InvalidModelState Exception: {@Exception}", context.Exception);
    }

    private void HandleNotFoundException(ExceptionContext context)
    {
        var exception = (NotFoundException)context.Exception;

        var details = new ProblemDetails()
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
            Title = "The specified resource was not found.",
            Detail = exception.Message
        };

        context.Result = new NotFoundObjectResult(details);

        context.ExceptionHandled = true;

        logger.LogError("Handled NotFound Exception: {@Exception}", context.Exception);
    }

    private void HandleUnauthorizedAccessException(ExceptionContext context)
    {
        var details = new ProblemDetails
        {
            Status = StatusCodes.Status401Unauthorized,
            Title = "Unauthorized",
            Type = "https://tools.ietf.org/html/rfc7235#section-3.1"
        };

        context.Result = new ObjectResult(details)
        {
            StatusCode = StatusCodes.Status401Unauthorized
        };

        context.ExceptionHandled = true;

        logger.LogError("Handled Unauthorized Exception: {@Exception}", context.Exception);
    }

    private void HandleForbiddenAccessException(ExceptionContext context)
    {
        var details = new ProblemDetails
        {
            Status = StatusCodes.Status403Forbidden,
            Title = "Forbidden",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3"
        };

        context.Result = new ObjectResult(details)
        {
            StatusCode = StatusCodes.Status403Forbidden
        };

        context.ExceptionHandled = true;

        logger.LogError("Handled Forbidden Exception: {@Exception}", context.Exception);
    }

    private void HandleUnknownException(ExceptionContext context)
    {
        var details = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request.",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
        };

        context.Result = new ObjectResult(details)
        {
            StatusCode = StatusCodes.Status500InternalServerError
        };

        context.ExceptionHandled = true;

        logger.LogError("Handled Unknown Exception: {@Exception}", context.Exception);
    }
}

The class above defines a custom exception filter attribute named ApiExceptionFilterAttribute, which inherits from ExceptionFilterAttribute. It contains a dictionary of exception handlers for specific exception types, such as ValidationException, NotFoundException, UnauthorizedAccessException, and ForbiddenAccessException. The constructor initializes the dictionary and takes an ILogger instance for logging purposes. The OnException method calls the HandleException method, which checks if the exception type is registered in the dictionary, and if so, invokes the corresponding handler. If the exception type is not registered and the ModelState is invalid, it calls HandleInvalidModelStateException. Otherwise, it calls HandleUnknownException. Each handler sets the appropriate HTTP response status code, result, and exception details, marks the exception as handled, and logs the handled exception.

You can wire up this filter at an application level by adding it to your pipeline, like so:

builder.Services.AddControllers((options) =>
{
    options.Filters.Add<ApiExceptionFilterAttribute>();
});

While, this is a perfectly cromulent solution, more recent advice from Microsoft and others is to solve this problem using middleware.

ASP.NET Middleware

Middleware is one of those foundational pillars of the ASP.NET framework. If you’re new to them, you can check out this introductory post that I did on the subject, a while back. Here, let’s look at how we can use middleware to solve the problem of global error handling. Consider the following code, below.

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

        // Customize your error handling logic here
        var result = new { message = "An error occurred while processing your request." };
        await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(result));
    });
});

In the code above, we’re using the built-in UseExceptionHandler middleware to handle exceptions in our application. The handler above is quite simplistic, simply setting the HTTP status code to 500 and returning a simple JSON payload with a string message indicating that an error has occurred. You can certainly get fancier than this and log your errors to various sources and provided varied responses based on the type of error that was caught and handled. Alternatively you can create an action method that can use as a response for errors and wire it up, like so:

app.UseExceptionHandler("/Error");

Closing Remarks

That’s it! We looked at a couple of ways to implement a Global Error Handler in our ASP.NET applications. Despite of what approach you ultimately go with, having a global error handler benefits you in a handful of ways:

  1. Centralized error handling: With a Global Error Handler, all unhandled exceptions are managed in one place, making it easier to maintain and update error handling logic.
  2. Consistent user experience: By handling all errors centrally, we can ensure that users receive consistent and informative error messages across the application.
  3. Improved debugging: A Global Error Handler logs necessary details about each unhandled exception, making it easier for developers to identify and resolve issues.
  4. Reduced repetitive code: By handling errors globally, developers no longer need to include error handling code in every action method, resulting in cleaner and more maintainable code.

Give one of these approaches a try within your applications and see how it improves your workflow in handling application errors.

Leave a Comment

Your email address will not be published. Required fields are marked *