Back to: ASP.NET Core Web API Tutorials
Exception Filters in ASP.NET Core Web API
In this article, I will discuss Exception Filters in ASP.NET Core Web API Applications with Examples. Please read our previous article discussing Result Filters in ASP.NET Core Web API Applications. In modern APIs, centralized and consistent error handling is crucial for reliability, maintainability, and providing user-friendly feedback. Exception Filters in ASP.NET Core Web API are designed specifically for this purpose; they catch and handle unhandled exceptions that occur during the execution of controller action methods or filters. They help us catch errors, log them, and return meaningful, consistent error responses to API clients without cluttering our action methods with try-catch blocks.
What is an Exception Filter in ASP.NET Core Web API?
An Exception Filter is a special type of filter that executes only when an unhandled exception occurs in the ASP.NET Core request pipeline, specifically during:
- Action method execution
- Action filters execution
- Result filters execution
- Resource filters execution
Exception Filters catch these exceptions and provide a centralized way to handle them by:
- Logging the error details for diagnostics
- Returning custom error messages and HTTP status codes
- Preventing sensitive exception details from leaking to clients
- Implementing fallback or recovery logic if necessary
Example: The Emergency Response Team
Imagine a factory where various processes are running (action methods, filters). Occasionally, unexpected accidents (exceptions) can happen. The Exception Filter acts like an emergency response team. It’s not involved in the day-to-day process but comes into action only if something goes wrong. The team assesses the situation, prevents further damage (e.g., logs the issue, sends alerts), and provides a controlled, consistent response to anyone affected.
Example: Fire Alarm System in a Building
Imagine an office building with a fire alarm system (Exception Filter). A sudden fire (exception) occurs in one of the rooms (API methods). Instead of everyone panicking, the fire alarm:
- Detects the fire (catches the exception),
- Sounds the alarm (logs the issue),
- Activates alerts firefighters (returns a controlled response).
When Do Exception Filters Run?
Exception filters are one of the last lines of defense in the filter pipeline. They are triggered when an unhandled exception occurs during:
- The execution of an action method.
- The execution of other filters like Action Filters, Result Filters, or Resource Filters.
They run after the action starts but before the response is sent back to the client, providing a chance to handle the exception, set a proper response, and short-circuit the pipeline. Exception filters do not catch exceptions thrown in middleware, only in the MVC action/filter pipeline.
Types of Exception Filters
You can create:
- Synchronous Exception Filters: Implement IExceptionFilter (for fast, non-async logic).
- Asynchronous Exception Filters: Implement IAsyncExceptionFilter (for logging to remote stores, etc.).
- Attribute-Based Filters: Inherit from ExceptionFilterAttribute for easy attribute usage.
ASP.NET Core Web API Project setup:
First, create a new ASP.NET Core Web API project named ExceptionFiltersDemo. Then, create a new folder named Filters at the project root directory. Then, please install the following package:
- Install-Package Microsoft.Data.SqlClient
- Install-Package Microsoft.EntityFrameworkCore
Creating Custom Exception Filter:
Create a class file named CustomExceptionFilter.cs within the Filters folder and then copy and paste the following code. The filter logs all unhandled exceptions. It uses HTTP status codes appropriate to the exception type. It provides generic messages for clients, but detailed messages are in development. It returns structured JSON with Status, Error, and Message.Â
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using System.Net; using System.Security.Authentication; namespace ExceptionFiltersDemo.Filters { // Custom Exception Filter implementing the IExceptionFilter interface public class CustomExceptionFilter : IExceptionFilter { private readonly ILogger<CustomExceptionFilter> _logger; // Logger to log exceptions private readonly IHostEnvironment _env; // Provides info about hosting environment // Constructor receives ILogger and IHostEnvironment via Dependency Injection public CustomExceptionFilter(ILogger<CustomExceptionFilter> logger, IHostEnvironment env) { _logger = logger; // Assign logger instance to private field _env = env; // Assign environment instance to private field } // This method is called when an unhandled exception occurs during the request pipeline public void OnException(ExceptionContext context) { // Log the exception details with stack trace and message at Error level _logger.LogError(context.Exception, "Unhandled exception occurred."); // Initialize default HTTP status code as 500 Internal Server Error var statusCode = (int)HttpStatusCode.InternalServerError; // Initialize default error title var error = "Internal Server Error"; // Initialize default client-friendly error message var message = "An unexpected error occurred. Please try again later."; // If the application is running in Development environment if (_env.IsDevelopment()) { // Provide detailed exception message for easier debugging message = context.Exception.Message; } // Use a switch statement to handle specific exception types differently switch (context.Exception) { // Handle argument validation errors (e.g., invalid method arguments) case ArgumentException argEx: statusCode = (int)HttpStatusCode.BadRequest; // 400 Bad Request error = "Bad Request"; message = argEx.Message; // Use exception's message break; // Handle resource not found errors case KeyNotFoundException _: statusCode = (int)HttpStatusCode.NotFound; // 404 Not Found error = "Not Found"; message = "Resource not found."; // Generic message for client break; // Handle authentication failures case AuthenticationException _: statusCode = (int)HttpStatusCode.Unauthorized; // 401 Unauthorized error = "Unauthorized"; message = "Authentication failed."; break; // Handle unauthorized access attempts case UnauthorizedAccessException _: statusCode = (int)HttpStatusCode.Unauthorized; // 401 Unauthorized error = "Unauthorized"; message = "Unauthorized access."; break; // Handle invalid operations (e.g., state conflicts) case InvalidOperationException _: statusCode = (int)HttpStatusCode.Conflict; // 409 Conflict error = "Conflict"; message = context.Exception.Message; // Use detailed message break; // Handle SQL Server exceptions (e.g., connection failures) case SqlException _: // Handle Entity Framework update exceptions (e.g., database update issues) case DbUpdateException _: statusCode = (int)HttpStatusCode.ServiceUnavailable; // 503 Service Unavailable error = "Database Error"; message = "A database error occurred. Please try again later."; break; // Handle unimplemented functionality exceptions case NotImplementedException _: statusCode = (int)HttpStatusCode.NotImplemented; // 501 Not Implemented error = "Not Implemented"; message = "This functionality is not implemented."; break; // Handle request timeouts case TimeoutException _: statusCode = (int)HttpStatusCode.RequestTimeout; // 408 Request Timeout error = "Request Timeout"; message = "The request timed out. Please try again."; break; // You can add more cases here to handle other exception types as needed } // Create an anonymous object to structure the JSON error response var errorResponse = new { Status = statusCode, // HTTP status code (e.g., 404, 500) Error = error, // Short error title Message = message // Detailed error message for client }; // Assign the constructed JSON response as the HTTP result context.Result = new JsonResult(errorResponse) { StatusCode = statusCode // Set appropriate HTTP status code on the response }; // Mark the exception as handled so ASP.NET Core doesn't rethrow it context.ExceptionHandled = true; } } }
Register the Exception Filter Globally
We need to register the Exception Filter globally in the Program.cs file. So, please modify the Program.cs class file as follows:
using ExceptionFiltersDemo.Filters; namespace ExceptionFiltersDemo { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(options => { // Register the Exception Filter Globally options.Filters.Add<CustomExceptionFilter>(); }) .AddJsonOptions(options => { // Disable camelCase in JSON output, preserve property names as defined in C# classes options.JsonSerializerOptions.PropertyNamingPolicy = null; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); } } }
API Controller to Demonstrate the Exception Filter:
Now, create an Empty API Controller named DemoController within the Controllers folder and then copy and paste the following code. The controller defines four endpoints (argument, not found, unexpected, and OK). Each of the first three endpoints throws a specific exception to simulate error conditions. The last endpoint returns a successful result with HTTP 200 OK. Exception Filters registered in your application will catch these exceptions and return corresponding HTTP status codes and messages based on your filter logic.
using Microsoft.AspNetCore.Mvc; namespace ExceptionFiltersDemo.Controllers { [ApiController] [Route("api/[controller]")] public class DemoController : ControllerBase { // HTTP GET method for the route: api/demo/argument [HttpGet("argument")] public IActionResult ThrowArgumentException() { // Intentionally throw an ArgumentException with a specific message // This simulates a scenario where the user provides invalid input // If you have an exception filter registered, it will catch this and return a 400 Bad Request response throw new ArgumentException("Invalid parameter value provided."); } // HTTP GET method for the route: api/demo/notfound [HttpGet("notfound")] public IActionResult ThrowNotFoundException() { // Intentionally throw a KeyNotFoundException with a custom message // This simulates a scenario where a requested resource does not exist // Exception filters can catch this and return a 404 Not Found response with a custom error message throw new KeyNotFoundException("The requested item was not found."); } // HTTP GET method for the route: api/demo/unexpected [HttpGet("unexpected")] public IActionResult ThrowUnexpectedException() { // Throw a generic Exception to simulate an unhandled server error // Without custom handling, this results in a 500 Internal Server Error response // Exception filters can log this and return a friendly error response throw new Exception("Simulated unhandled server error."); } // HTTP GET method for the route: api/demo/ok [HttpGet("ok")] public IActionResult GetOk() { // Returns an HTTP 200 OK response with a JSON payload // Indicates that the endpoint executed successfully without any exceptions return Ok(new { Message = "No exception, everything is OK!" }); } } }
Why Do We Need Exception Filters?
- Centralized Error Handling: Instead of adding try-catch blocks in every controller action, we handle exceptions globally in one place.
- Consistent API Responses: We can return a uniform error response format (e.g., JSON with error code, message, and status code) to all API clients.
- Security: Prevents stack traces or sensitive internal details from reaching external clients.
- Logging & Monitoring: Facilitates capturing detailed error logs for debugging or audit purposes.
- Cleaner Code: Keeps our action methods clean and focused on business logic without worrying about exception handling.
Exception Filters are essential for building robust, secure, and user-friendly ASP.NET Core Web APIs. They provide centralized, reusable logic for handling errors, logging, and returning consistent error responses, greatly improving the API’s reliability and developer experience.
What is a Custom Middleware in ASP.NET Core Web API?
A Custom Middleware in ASP.NET Core Web API sits in the HTTP request pipeline and processes incoming requests and outgoing responses. Middleware components can perform tasks like logging, authentication, error handling, modifying requests/responses, routing, etc. You can think of middleware as a chain of components through which every HTTP request and response flows. Each middleware can:
- Inspect or modify the incoming HTTP request.
- Perform actions before or after the next middleware/component runs.
- Short-circuit the pipeline by sending a response immediately.
Custom middleware is user-defined middleware that we create to implement functionality specific to our application needs, such as custom logging, header injection, or global exception handling.
When to Create a Custom Exception Middleware in ASP.NET Core Web API?
We need to create a Custom Exception Middleware when we want to:
- Handle all unhandled exceptions globally across our entire application, including exceptions from MVC controllers, other middleware, static files, or APIs. This ensures consistent error responses and centralized error logging.
- Ensure consistent error response formatting for clients, such as returning JSON errors with status codes and messages in a unified structure.
- Log exceptions centrally so that all errors are captured in one place with detailed diagnostic info.
- Control error messages based on the environment (development vs. production) to prevent the leak of sensitive server or exception details to end users.
- You should have more control over the HTTP response, including setting headers or modifying response bodies for error cases, which might be difficult with exception filters.
- Catch exceptions as early as possible in the request pipeline, including those thrown outside of MVC controllers (like in middleware or endpoint routing).
Creating Custom Middleware to Handle Exceptions Globally:
So, first, create a folder named Middlewares at the project root directory. Then, inside the Middlewares folder, create a class file named ExceptionHandlingMiddleware.cs and copy and paste the following code.
using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using System.Net; using System.Security.Authentication; using System.Text.Json; namespace ExceptionFiltersDemo.Middlewares { // Custom middleware to handle exceptions globally public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; // Next middleware in the pipeline private readonly ILogger<ExceptionHandlingMiddleware> _logger; // Logger for error logging private readonly IHostEnvironment _env; // To determine environment (Dev/Prod) // Constructor with DI for next delegate, logger, and environment public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env) { _next = next; _logger = logger; _env = env; } // This method is called for each HTTP request public async Task InvokeAsync(HttpContext context) { try { // Call the next middleware/component in the pipeline await _next(context); } catch (Exception ex) { // Catch any exception thrown downstream await HandleExceptionAsync(context, ex); } } // Centralized method to handle exceptions and write custom response private async Task HandleExceptionAsync(HttpContext context, Exception exception) { // Log exception details with stack trace _logger.LogError(exception, "Unhandled exception caught by middleware."); // Default to 500 Internal Server Error var statusCode = (int)HttpStatusCode.InternalServerError; var error = "Internal Server Error"; var message = "An unexpected error occurred. Please try again later."; // Customize message for development environment if (_env.IsDevelopment()) { message = exception.Message; } // Use a switch statement to handle specific exception types differently switch (exception) { // Handle argument validation errors (e.g., invalid method arguments) case ArgumentException argEx: statusCode = (int)HttpStatusCode.BadRequest; // 400 Bad Request error = "Bad Request"; message = argEx.Message; // Use exception's message break; // Handle resource not found errors case KeyNotFoundException _: statusCode = (int)HttpStatusCode.NotFound; // 404 Not Found error = "Not Found"; message = "Resource not found."; // Generic message for client break; // Handle authentication failures case AuthenticationException _: statusCode = (int)HttpStatusCode.Unauthorized; // 401 Unauthorized error = "Unauthorized"; message = "Authentication failed."; break; // Handle unauthorized access attempts case UnauthorizedAccessException _: statusCode = (int)HttpStatusCode.Unauthorized; // 401 Unauthorized error = "Unauthorized"; message = "Unauthorized access."; break; // Handle invalid operations (e.g., state conflicts) case InvalidOperationException _: statusCode = (int)HttpStatusCode.Conflict; // 409 Conflict error = "Conflict"; message = exception.Message; // Use detailed message break; // Handle SQL Server exceptions (e.g., connection failures) case SqlException _: // Handle Entity Framework update exceptions (e.g., database update issues) case DbUpdateException _: statusCode = (int)HttpStatusCode.ServiceUnavailable; // 503 Service Unavailable error = "Database Error"; message = "A database error occurred. Please try again later."; break; // Handle unimplemented functionality exceptions case NotImplementedException _: statusCode = (int)HttpStatusCode.NotImplemented; // 501 Not Implemented error = "Not Implemented"; message = "This functionality is not implemented."; break; // Handle request timeouts case TimeoutException _: statusCode = (int)HttpStatusCode.RequestTimeout; // 408 Request Timeout error = "Request Timeout"; message = "The request timed out. Please try again."; break; // You can add more cases here to handle other exception types as needed } // Prepare error response payload var errorResponse = new { Status = statusCode, Error = error, Message = message }; // Serialize to JSON var jsonResponse = JsonSerializer.Serialize(errorResponse); // Set response content type and status code context.Response.ContentType = "application/json"; context.Response.StatusCode = statusCode; // Write JSON error response to the client await context.Response.WriteAsync(jsonResponse); } } }
Registering the Middleware in the Program.cs
Add the middleware as early as possible in the HTTP pipeline, right after the app.UseHttpsRedirection() (and before authentication/authorization). So, please modify the Program class as follows:
using ExceptionFiltersDemo.Middlewares; namespace ExceptionFiltersDemo { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers() .AddJsonOptions(options => { // Disable camelCase in JSON output, preserve property names as defined in C# classes options.JsonSerializerOptions.PropertyNamingPolicy = null; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); // Register the Custom Exception Handling Middleware app.UseMiddleware<ExceptionHandlingMiddleware>(); app.UseAuthorization(); app.MapControllers(); app.Run(); } } }
Custom Exception Filter vs Custom Exception Middleware in ASP.NET Core:
Where do they run?
Exception Filters are part of the MVC pipeline. They only catch exceptions thrown inside controller actions and the related filters. Middleware, on the other hand, runs very early in the ASP.NET Core pipeline and can catch exceptions thrown anywhere downstream, including MVC actions, other middleware, static file requests, or any part of the pipeline.
Scope and flexibility:
Exception Filters are tightly coupled with MVC and give you access to MVC-specific context like action details, route data, and model state. This can be useful for API-specific error formatting. Middleware has a broader scope. It works with the full HTTP context and can handle errors globally, regardless of whether the request hits MVC or not.
Registration and usage:
Exception Filters are registered through MVC options (globally) or applied as attributes on controllers or actions. Middleware is registered in the request pipeline (typically in Program.cs) using app.UseMiddleware<YourMiddleware>(). Because middleware wraps the entire request pipeline, it’s the recommended place for global exception handling.
Error handling and response control:
Exception Filters handle errors at the MVC level and can replace or modify action results. Middleware can short-circuit the entire HTTP response, giving you total control over what the client receives, including setting headers, status codes, and response bodies for all request types.
Logging and diagnostics:
Middleware can log errors for all requests, including static files, APIs, and non-MVC endpoints. Exception Filters are limited to logging exceptions happening inside MVC controller actions or filters.
When to choose which:
- Use middleware when you want consistent, global exception handling across the entire app, including non-MVC routes or static files.
- Use exception filters if you want MVC-specific error handling with access to action context, or want to handle exceptions on a per-controller or per-action basis.
In most real-world ASP.NET Core apps, exception middleware is preferred for global error handling because it covers all cases consistently and runs early in the pipeline. Exception filters are useful when you need MVC-specific control or want to customize error handling.
In the next article, I will discuss ServiceFilter vs TypeFilter in ASP.NET Core Web API Applications. In this article, I explain Exception Filters in ASP.NET Core Web API Application with Examples. I hope you enjoy this article on Exception Filters in ASP.NET Core Web API.