ASP.NET Web API (.NET Framework) · Cross-Cutting

Global Exception Handling

Exceptions will happen. Global exception handling makes sure every one of them is logged, mapped to the right status code, and never leaks a stack trace to the client.

Intermediate 7 min Lesson 20 of 36Covers ASP.NET Web API 2.2 on .NET Framework 4.8.
Before you start:Service Layer
0/36 in ASP.NET Web API
On this page · 7 min · Intermediate

Learning objectives#

  • Map known exception types to status codes with an exception filter
  • Return one safe 500 response with IExceptionHandler
  • Log every exception with IExceptionLogger
  • Correlate a client-visible error ID with the log

Explanation#

Without global handling, try/catch blocks repeat in every action, and unexpected errors return Web API’s default 500 — which can include exception details.

An ExceptionFilterAttribute catches exceptions thrown by actions. Use it to translate your own exception types: NotFoundException → 404, BusinessRuleException → 409.

IExceptionHandler (derive from ExceptionHandler) decides the response for anything still unhandled — including errors outside actions, such as in message handlers or serialisation. There is only one handler; you replace the default.

IExceptionLogger (derive from ExceptionLogger) is called for every exception, before filters and the handler. There can be several loggers.

Give each failure a short error ID, write it to the log and return it to the client. Support staff can find the exact log entry from a customer’s screenshot.

Set config.IncludeErrorDetailPolicy = LocalOnly (or Never) so details are hidden from remote clients.

Syntax#

C# Illustrative
config.Filters.Add(new DomainExceptionFilterAttribute());                     // known errors
config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler()); // everything else
config.Services.Add(typeof(IExceptionLogger), new TraceExceptionLogger());       // log all

Code example: Filter, handler and logger working together#

C#ErrorHandling.cs Illustrative
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using System.Web.Http.ExceptionHandling;
using System.Web.Http.Filters;
using System.Web.Http.Results;


public class NotFoundException : Exception
{
    public NotFoundException(string message) : base(message) { }
}


public class BusinessRuleException : Exception
{
    public BusinessRuleException(string message) : base(message) { }
}


// 1. Log every exception and give it an id
public class TraceExceptionLogger : ExceptionLogger
{
    public override void Log(ExceptionLoggerContext context)
    {
        var errorId = Guid.NewGuid().ToString("N").Substring(0, 8);
        context.Request.Properties["ErrorId"] = errorId;
        Trace.TraceError("[{0}] {1} {2}: {3}", errorId, context.Request.Method,
            context.Request.RequestUri, context.Exception);
    }
}


// 2. Known exceptions → specific status codes
public class DomainExceptionFilterAttribute : ExceptionFilterAttribute
{
    public override void OnException(HttpActionExecutedContext context)
    {
        var ex = context.Exception;
        if (ex is NotFoundException)
            context.Response = context.Request.CreateResponse(HttpStatusCode.NotFound, new { message = ex.Message });
        else if (ex is BusinessRuleException)
            context.Response = context.Request.CreateResponse(HttpStatusCode.Conflict, new { message = ex.Message });
    }
}


// 3. Everything else → one safe 500
public class GlobalExceptionHandler : ExceptionHandler
{
    public override void Handle(ExceptionHandlerContext context)
    {
        object errorId;
        context.Request.Properties.TryGetValue("ErrorId", out errorId);
        var response = context.Request.CreateResponse(HttpStatusCode.InternalServerError, new
        {
            message = "Something went wrong. Please try again.",
            errorId,
        });
        context.Result = new ResponseMessageResult(response);
    }
}


public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.MapHttpAttributeRoutes();
        config.Filters.Add(new DomainExceptionFilterAttribute());
        config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
        config.Services.Add(typeof(IExceptionLogger), new TraceExceptionLogger());
        config.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.LocalOnly;
    }
}


public class ProductsController : ApiController
{
    [HttpGet, Route("api/products/{id:int}")]
    public IHttpActionResult Get(int id)
    {
        if (id == 0) throw new InvalidOperationException("Connection to SQL Server failed");
        if (id > 100) throw new NotFoundException("Product " + id + " was not found.");
        return Ok(new { Id = id, Name = "Pen" });
    }
}
Expected output
GET /api/products/5     → 200 {"Id":5,"Name":"Pen"}
GET /api/products/500   → 404 {"message":"Product 500 was not found."}
GET /api/products/0     → 500 {"message":"Something went wrong. Please try again.","errorId":"3f9c2a1b"}

Log:  [3f9c2a1b] GET http://shop/api/products/0: System.InvalidOperationException:
      Connection to SQL Server failed  at ProductsController.Get(Int32 id) ...

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The logger runs first for every exception, creates an error ID and stores it on the request.
  2. The exception filter turns NotFoundException into 404 and BusinessRuleException into 409.
  3. Anything the filter did not handle reaches GlobalExceptionHandler, which returns a generic 500 with the error ID.
  4. The client never sees the real message (“Connection to SQL Server failed”) — only the log does.
  5. Actions stay clean: they just throw meaningful exceptions.

Real-world use case#

A customer reports “error 3f9c2a1b” from the mobile app. Support searches the logs for that ID and finds the exact request, user and stack trace in seconds.

Common mistakes#

Returning ex.Message for unexpected errors

Database and file-system messages reveal server names, table names and paths. Return a generic message.

Only using exception filters

Filters do not catch errors from message handlers, routing or serialisation. Add an IExceptionHandler for those.

Empty catch blocks

catch { } in an action hides failures from both the client and the logs. Let exceptions reach the global handler.

Best practices#

  • Throw specific exception types from services
  • Map them centrally with one filter
  • One handler for a safe 500 with an error ID
  • Never send stack traces to remote clients

Remember this#

Logger: log everything. Filter: known errors → 4xx. Handler: the rest → safe 500.

Error ID in the response = error ID in the log.

IncludeErrorDetailPolicy.LocalOnly in production.

Practice exercise#

Your task: Extend the exception filter so that UnauthorizedAccessException returns 403 with {"message":"You do not have access to this resource."}.

Need a hint?
Add another else-if branch with HttpStatusCode.Forbidden.
Show solution
C# Illustrative
using System;
using System.Net;
using System.Net.Http;
using System.Web.Http.Filters;


public class AccessExceptionFilterAttribute : ExceptionFilterAttribute
{
    public override void OnException(HttpActionExecutedContext context)
    {
        if (context.Exception is UnauthorizedAccessException)
        {
            context.Response = context.Request.CreateResponse(HttpStatusCode.Forbidden,
                new { message = "You do not have access to this resource." });
        }
    }
}

Mini quiz#

Global Exception Handling quizMultiple choice Question 1 of 3

Which component can catch exceptions thrown outside controller actions, e.g. in a message handler?

Score so far: 0/0

Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.