Understanding ASP.NET Core - handle errors

Keywords: ASP.NET .NET

Note: This article is part of the understanding ASP.NET Core series. Please check the top blog or Click here to view the full-text catalog

Error handling using middleware

Developer exception page

The developer exception page is used to display the details of unhandled request exceptions. When we create a project through the ASP.NET Core template, the following code will be automatically generated in the Startup.Configure method:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        // Add developer exception page Middleware
        app.UseDeveloperExceptionPage();
    }
}

It should be noted that the middleware related to "exception handling" must be added as soon as possible, so that it can catch the unhandled exceptions thrown by subsequent middleware to the greatest extent.

It can be seen that the developer exception page is enabled only when the program is running in the development environment, which is well understood, because in the production environment, we cannot expose the details of exceptions to users, otherwise, it will lead to a series of security problems.

Now we add the following code to throw an exception:

app.Use((context, next) =>
{
    throw new NotImplementedException();
});

When the developer exception page middleware catches the unhandled exception, the following related information will be displayed:

The exception page displays the following information:

  • Exception message
  • Exception Stack trace (Stack)
  • HTTP request Query parameters (Query)
  • Cookies
  • HTTP request Headers
  • Routing, including endpoint and routing information

IDeveloperPageExceptionFilter

When you look at the source code of developer exception pagemiddleware, you will find an input parameter in the constructor of type IEnumerable < ideveloperpageexceptionfilter >. Through this Filter set, an error processor pipeline is formed to handle errors in order according to the principle of registration first and execution first.

The following is the core source code of developer exception pagemiddleware:

public class DeveloperExceptionPageMiddleware
{
    public DeveloperExceptionPageMiddleware(
        RequestDelegate next,
        IOptions<DeveloperExceptionPageOptions> options,
        ILoggerFactory loggerFactory,
        IWebHostEnvironment hostingEnvironment,
        DiagnosticSource diagnosticSource,
        IEnumerable<IDeveloperPageExceptionFilter> filters)
    {
        // ...
        
        // Place DisplayException at the bottom of the pipe
        // DisplayException is used to write the exception page we saw above to the response
        _exceptionHandler = DisplayException;
    
        foreach (var filter in filters.Reverse())
        {
            var nextFilter = _exceptionHandler;
            _exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter);
        }
    }
    
    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            // If the response has been started, skip the processing and throw it up directly
            if (context.Response.HasStarted)
            {
                throw;
            }
    
            try
            {
                context.Response.Clear();
                context.Response.StatusCode = 500;
    
                // error handling
                await _exceptionHandler(new ErrorContext(context, ex));
    
                // ...
    
                // The error was successfully processed
                return;
            }
            catch (Exception ex2) { }
            
            // If a new exception ex2 is thrown during processing, the original exception ex is thrown again
            throw;
        }
    }
}

This means that if we want to customize the developer exception page, we can achieve our goal by implementing the IDeveloperPageExceptionFilter interface.

Let's take a look at the IDeveloperPageExceptionFilter interface definition:

public interface IDeveloperPageExceptionFilter
{
    Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next);
}

public class ErrorContext
{
    public ErrorContext(HttpContext httpContext, Exception exception)
    {
        HttpContext = httpContext ?? throw new ArgumentNullException(nameof(httpContext));
        Exception = exception ?? throw new ArgumentNullException(nameof(exception));
    }

    public HttpContext HttpContext { get; }

    public Exception Exception { get; }
}

In addition to the error context information, the HandleExceptionAsync method also contains a func < errorcontext, task > next. What is this for? In fact, as mentioned earlier, all implementations of IDeveloperPageExceptionFilter will form a pipeline. When the error needs to be further processed by the subsequent processor in the pipeline, the error is transmitted through this next. Therefore, when the error needs to be transmitted, remember to call next!

No more nonsense, let's hurry to achieve one and see the effect:

public class MyDeveloperPageExceptionFilter : IDeveloperPageExceptionFilter
{
    public Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorContext, Task> next)
    {
        errorContext.HttpContext.Response.WriteAsync($"MyDeveloperPageExceptionFilter: {errorContext.Exception}");

        // We do not call next, so DisplayException will not be executed

        return Task.CompletedTask;
    }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IDeveloperPageExceptionFilter, MyDeveloperPageExceptionFilter>();
}

When an exception is thrown, you will see a page similar to the following:

Exception handler

The above describes the exception handling in the development environment. Now let's take a look at the exception handling in the production environment. Register the middleware ExceptionHandlerMiddleware by calling the UseExceptionHandler extension method.

The exception handler:

  • Exceptions not handled by subsequent middleware can be caught
  • If there is no exception or the HTTP response has been started (Response.HasStarted == true), no processing will be performed
  • The path in the URL will not be changed

By default, a template similar to the following is generated:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        // Add exception handler
        app.UseExceptionHandler("/Home/Error");
    }
}

Providing exception handlers through lambda

We can provide an exception handling logic to UseExceptionHandler through lambda:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseExceptionHandler(errorApp =>
    {
        var loggerFactory = errorApp.ApplicationServices.GetRequiredService<ILoggerFactory>();
        var logger = loggerFactory.CreateLogger("ExceptionHandlerWithLambda");
    
        errorApp.Run(async context =>
        {
            // Here, you can customize the http response content. The following is only an example
    
            var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
    
            logger.LogError($"Exception Handled: {exceptionHandlerPathFeature.Error}");
    
            var statusCode = StatusCodes.Status500InternalServerError;
            var message = exceptionHandlerPathFeature.Error.Message;
    
            if (exceptionHandlerPathFeature.Error is NotImplementedException)
            {
                message = "I didn't realize it";
                statusCode = StatusCodes.Status501NotImplemented;
            }
    
            context.Response.StatusCode = statusCode;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsJsonAsync(new
            {
                Message = message,
                Success = false,
            });
    
        });
    });
}

You can see that when an exception is caught, you can obtain the exception information through HttpContext.Features and specify the type IExceptionHandlerPathFeature or IExceptionHandlerFeature (the former inherits from the latter).

public interface IExceptionHandlerFeature
{
    // Abnormal information
    Exception Error { get; }
}

public interface IExceptionHandlerPathFeature : IExceptionHandlerFeature
{
    // http request resource path not escaped
    string Path { get; }
}

Again, don't expose sensitive error information to the client.

Exception handler page

In addition to using lambda, we can also specify a path to an alternate pipeline for exception handling. For MVC, this alternate pipeline is generally an Action in the Controller, such as the default / Home/Error of MVC template:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseExceptionHandler("/Home/Error");
}

public class HomeController : Controller
{
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult Error()
    {
        return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

When an exception is caught, you will see a page similar to the following:

You can customize the error handling logic in ActionError, just like lambda.

It should be noted that [HttpGet], [HttpPost] and other features defining the Http request method should not be added to the Error. Once you add [HttpGet], this method can only handle the exception of Get request.

However, if you intend to process Http requests of different methods separately, you can process them like the following:

public class HomeController : Controller
{
    // Exception handling Get request
    [HttpGet("[controller]/error")]
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult GetError()
    {
        _logger.LogInformation("Get Exception Handled");

        return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }

    // Exception handling Post request
    [HttpPost("[controller]/error")]
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult PostError()
    {
        _logger.LogInformation("Post Exception Handled");

        return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
    }
}

In addition, it should be reminded that if an Error is also reported when requesting the standby pipeline (such as the Error in the example), whether the middleware in the Http request pipeline reports an Error or the Error in the Error, the ExceptionHandlerMiddleware will re throw the original exception instead of throwing the exception of the standby pipeline.

Generally, the exception handler page is for all users, so please ensure that it can be accessed anonymously.

Let's take a look at ExceptionHandlerMiddleware:

public class ExceptionHandlerMiddleware
{
    public ExceptionHandlerMiddleware(
        RequestDelegate next,
        ILoggerFactory loggerFactory,
        IOptions<ExceptionHandlerOptions> options,
        DiagnosticListener diagnosticListener)
    {
        // Either manually specify an exception handler (such as through lambda)
        // Or provide a resource path and re send it to the subsequent middleware for exception handling
        if (_options.ExceptionHandler == null)
        {
            if (_options.ExceptionHandlingPath == null)
            {
                throw new InvalidOperationException(Resources.ExceptionHandlerOptions_NotConfiguredCorrectly);
            }
            else
            {
                _options.ExceptionHandler = _next;
            }
        }
    }

    public Task Invoke(HttpContext context)
    {
        ExceptionDispatchInfo edi;
        try
        {
            var task = _next(context);
            if (!task.IsCompletedSuccessfully)
            {
                return Awaited(this, context, task);
            }

            return Task.CompletedTask;
        }
        catch (Exception exception)
        {
            edi = ExceptionDispatchInfo.Capture(exception);
        }

        // Handle when synchronization is completed and an exception is thrown
        return HandleException(context, edi);

        static async Task Awaited(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)
        {
            ExceptionDispatchInfo edi = null;
            try
            {
                await task;
            }
            catch (Exception exception)
            {
                edi = ExceptionDispatchInfo.Capture(exception);
            }

            if (edi != null)
            {
                // Handle when asynchronously completing and throwing an exception
                await middleware.HandleException(context, edi);
            }
        }
    }

    private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi)
    {
        // If the response has been started, skip the processing and throw it up directly
        if (context.Response.HasStarted)
        {
            edi.Throw();
        }

        PathString originalPath = context.Request.Path;
        if (_options.ExceptionHandlingPath.HasValue)
        {
            context.Request.Path = _options.ExceptionHandlingPath;
        }
        try
        {
            ClearHttpContext(context);

            // Save the exceptionHandlerFeature into context.Features
            var exceptionHandlerFeature = new ExceptionHandlerFeature()
            {
                Error = edi.SourceException,
                Path = originalPath.Value,
            };
            context.Features.Set<IExceptionHandlerFeature>(exceptionHandlerFeature);
            context.Features.Set<IExceptionHandlerPathFeature>(exceptionHandlerFeature);
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response);

            // Handling exceptions
            await _options.ExceptionHandler(context);

            if (context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response)
            {
                return;
            }
        }
        catch (Exception ex2) { }
        finally
        {
            // Restore the request path to ensure that the Url in the browser remains unchanged
            context.Request.Path = originalPath;
        }

        // If the exception is not handled, the original exception is thrown again
        edi.Throw();
    }
}

Http error status code processing without response body

By default, when the ASP.NET Core encounters a 400-599Http error status code without a body, it will not provide a page, but return a status code and an empty response body. However, for a good user experience, we generally provide friendly pages for common error status codes (404), such as gitee404

Please note that the middleware involved in this section does not conflict with the error exception handling middleware explained in the previous two sections, and can be used at the same time. Specifically, this section is not dealing with exceptions, but only to improve the user experience.

UseStatusCodePages

We can implement this function through StatusCodePagesMiddleware middleware:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseDeveloperExceptionPage();
    
    // Add StatusCodePagesMiddleware Middleware
    app.UseStatusCodePages();
    
    // ... request processing middleware
    app.UseHttpsRedirection();
    app.UseStaticFiles();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");
    });
}

Note that you must call UseStatusCodePages before you process the middleware after the exception handling middleware.

Now, you can request a path that does not exist, such as Home/Index2. You will see the following output in the browser:

Status Code: 404; Not Found 

UseStatusCodePages also provides overloading, allowing us to customize the response content type and body content, such as:

// Use placeholder {0} to populate the Http status code
app.UseStatusCodePages("text/plain", "Status code is: {0}");

Browser output is:

Status code is: 404

Similarly, we can also pass in a lambda expression to UseStatusCodePages:

app.UseStatusCodePages(async context =>
{
    context.HttpContext.Response.ContentType = "text/plain";

    await context.HttpContext.Response.WriteAsync(
        $"Status code is: {context.HttpContext.Response.StatusCode}");
});

After introducing so much, you can see that in fact, the effect of UseStatusCodePages is not good, so we generally don't use it in the production environment. What do we use? Please follow me.

UseStatusCodePagesWithRedirects

This extension method is actually implemented internally by calling UseStatusCodePages and passing in lambda. This method:

  • Receive an Http resource location string. Similarly, there will be a placeholder {0} to fill in the Http status code
  • Send Http status code 302 to client - found
  • Then redirect the client to the specified endpoint, where different error status codes can be processed separately
app.UseStatusCodePagesWithRedirects("/Home/StatusCodeError?code={0}");

public class HomeController : Controller
{
    [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult StatusCodeError(int code)
    {
        return code switch
        {
            // Jump to page 404
            StatusCodes.Status404NotFound => View("404"),
            // Jump to the unified display page
            _ => View(code),
        };
    }
}

Now you can try it yourself.

I wonder if you have noticed: when we request a non-existent path, it does jump to page 404, but the Url also changes to / Home/StatusCodeError?code=404, and the response status code also changes to 200Ok. You can see what's going on through the source code (I believe you can see 302):

public static IApplicationBuilder UseStatusCodePagesWithRedirects(this IApplicationBuilder app, string locationFormat)
{
    // The two conditional branches are similar. Let's look at the second one, which is easier to understand
    if (locationFormat.StartsWith("~"))
    {
        locationFormat = locationFormat.Substring(1);
        return app.UseStatusCodePages(context =>
        {
            var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
            context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location);
            return Task.CompletedTask;
        });
    }
    else
    {
        return app.UseStatusCodePages(context =>
        {
            // Format resource location with context.HttpContext.Response.StatusCode as placeholder
            var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode);
            // Redirect (302) to the set resource
            context.HttpContext.Response.Redirect(location);
            return Task.CompletedTask;
        });
    }
}

If you don't want to change the Url of the original request and keep the original status code, you should use the UseStatusCodePagesWithReExecute described next.

UseStatusCodePagesWithReExecute

Similarly, the extension method is implemented internally by calling UseStatusCodePages and passing in lambda, but the method:

  • Receive 1 path string and 1 query string. Similarly, there will be a placeholder {0} to fill in the Http status code
  • The Url remains unchanged and returns the original Http status code to the client
  • Execute an alternate pipeline to generate the response body
// Note that it should be written separately here
app.UseStatusCodePagesWithReExecute("/Home/StatusCodeError", "?code={0}");

No more specific examples, just use the above. Now let's look at the source code:

public static IApplicationBuilder UseStatusCodePagesWithReExecute(
    this IApplicationBuilder app,
    string pathFormat,
    string queryFormat = null)
{
    return app.UseStatusCodePages(async context =>
    {
        // Note that the Http response has not started at this time
    
        // Format the resource path with context.HttpContext.Response.StatusCode as placeholder
        var newPath = new PathString(
            string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode));
        // Format the query string with context.HttpContext.Response.StatusCode as placeholder
        var formatedQueryString = queryFormat == null ? null :
            string.Format(CultureInfo.InvariantCulture, queryFormat, context.HttpContext.Response.StatusCode);
        var newQueryString = queryFormat == null ? QueryString.Empty : new QueryString(formatedQueryString);

        var originalPath = context.HttpContext.Request.Path;
        var originalQueryString = context.HttpContext.Request.QueryString;
        // Save the original request information for subsequent restoration
        context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(new StatusCodeReExecuteFeature()
        {
            OriginalPathBase = context.HttpContext.Request.PathBase.Value,
            OriginalPath = originalPath.Value,
            OriginalQueryString = originalQueryString.HasValue ? originalQueryString.Value : null,
        });

        context.HttpContext.SetEndpoint(endpoint: null);
        var routeValuesFeature = context.HttpContext.Features.Get<IRouteValuesFeature>();
        routeValuesFeature?.RouteValues?.Clear();

        // Construct new request
        context.HttpContext.Request.Path = newPath;
        context.HttpContext.Request.QueryString = newQueryString;
        try
        {
            // Execute the standby pipeline to generate the response body
            await context.Next(context.HttpContext);
        }
        finally
        {
            // Restore original request information
            context.HttpContext.Request.QueryString = originalQueryString;
            context.HttpContext.Request.Path = originalPath;
            context.HttpContext.Features.Set<IStatusCodeReExecuteFeature>(null);
        }
    });
}

In MVC, you can skip StatusCodePagesMiddleware by adding the [SkipStatusCodePages] feature to the controller or its Action method.

Error handling using filters

In addition to error handling middleware, ASP.NET Core also provides exception filters for error handling.

Exception filter:

  • Customize the exception filter by implementing the interface IExceptionFilter or IAsyncExceptionFilter
  • You can catch unhandled exceptions thrown in Controller creation (that is, only exceptions thrown in constructor), model binding, Action Filter and Action
  • Exceptions thrown elsewhere will not be caught

This section only introduces the exception filter. The details of the filter will be introduced in subsequent articles

Let's take a look at the two interfaces:

// It only has the function of marking it as a filter of mvc request pipeline
public interface IFilterMetadata { }

public interface IExceptionFilter : IFilterMetadata
{
    // When an exception is thrown, the method catches
    void OnException(ExceptionContext context);
}

public interface IAsyncExceptionFilter : IFilterMetadata
{
    // When an exception is thrown, the method catches
    Task OnExceptionAsync(ExceptionContext context);
}

Both OnException and OnExceptionAsync methods contain a parameter of type ExceptionContext. Obviously, it is the context related to exceptions, and our exception handling logic is inseparable from it. Then let's take a look at its structure:

public class ExceptionContext : FilterContext
{
    // Unhandled exception caught
    public virtual Exception Exception { get; set; }

    public virtual ExceptionDispatchInfo? ExceptionDispatchInfo { get; set; }

    // Indicates whether the exception has been handled
    // true: indicates that the exception has been handled and will not be thrown upward
    // false: indicates that the exception has not been handled, and the exception will continue to be thrown upward
    public virtual bool ExceptionHandled { get; set; }

    // Set the iationresult of the response
    // If the result is set, it also indicates that the exception has been handled and will not be thrown upward
    public virtual IActionResult? Result { get; set; }
}

In addition, ExceptionContext also inherits FilterContext, and FilterContext inherits ActionContext (which also shows that the filter serves Action from the side), that is, we can also get some information about filters and actions. Let's see what they have:

public class ActionContext
{
    // Action related information
    public ActionDescriptor ActionDescriptor { get; set; }

    // HTTP context
    public HttpContext HttpContext { get; set; }

    // Model binding and validation
    public ModelStateDictionary ModelState { get; }

    // Routing data
    public RouteData RouteData { get; set; }
}

public abstract class FilterContext : ActionContext
{
    public virtual IList<IFilterMetadata> Filters { get; }

    public bool IsEffectivePolicy<TMetadata>(TMetadata policy) where TMetadata : IFilterMetadata {}

    public TMetadata FindEffectivePolicy<TMetadata>() where TMetadata : IFilterMetadata {}
}

For more parameter details, I will introduce them in detail in the article devoted to filters.

Next, let's implement a custom exception handler:

public class MyExceptionFilterAttribute : ExceptionFilterAttribute
{
    private readonly IModelMetadataProvider _modelMetadataProvider;

    public MyExceptionFilterAttribute(IModelMetadataProvider modelMetadataProvider)
    {
        _modelMetadataProvider = modelMetadataProvider;
    }

    public override void OnException(ExceptionContext context)
    {
        if (!context.ExceptionHandled)
        {
            // This is a simple demonstration only
            var exception = context.Exception;
            var result = new ViewResult()
            {
                ViewName = "Error",
                ViewData = new ViewDataDictionary(_modelMetadataProvider, context.ModelState)
                {
                    // Remember to add the Message property to ErrorViewModel
                    Model = new ErrorViewModel
                    {
                        Message = exception.ToString()
                    }
                }
            };

            context.Result = result;

            // Mark exception handled
            context.ExceptionHandled = true;
        }
    }
}

Next, find / Views/Shared/Error.cshtml to display the error message:

@model ErrorViewModel
@{
    ViewData["Title"] = "Error";
}

<p>@Model.Message</p>

Finally, register the service MyExceptionFilterAttribute to the DI container:

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<MyExceptionFilterAttribute>();

    services.AddControllersWithViews();
}

Now, we add the exception handler to / Home/Index and throw an exception:

public class HomeController : Controller
{
    [ServiceFilter(typeof(MyExceptionFilterAttribute))]
    public IActionResult Index()
    {
        throw new Exception("Home Index Error");

        return View();
    }
}

When requesting / Home/Index, you will get the following page:

Error handling middleware VS exception filter

Now, we have introduced two methods of error handling -- error handling middleware and exception filter. Now let's compare their similarities and differences, and when and which treatment should we choose.

Error handling middleware:

  • All unhandled exceptions of subsequent middleware can be captured
  • With RequestDelegate, the operation is more flexible
  • The granularity is coarse and can only be configured for the global

Error handling middleware is suitable for handling global exceptions.

Exception filter:

  • Only unhandled exceptions thrown in Controller creation (i.e. exceptions thrown in constructor), model binding, Action Filter and Action can be caught. Exceptions thrown elsewhere cannot be caught
  • With smaller granularity, you can flexibly configure different exception filters for Controller or Action

Exception filters are ideal for capturing and handling exceptions in actions.

In our application, we can use error handling middleware and exception filter at the same time. Only by giving full play to their respective advantages can we deal with the errors in the program.

Posted by baffled_in_UK on Mon, 22 Nov 2021 04:10:38 -0800