Custom Errors in ASP.NET Core and MVC 6
Introduction:
One of the famous feature of classic ASP.NET is custom errors which provides information about custom error messages which may be useful for diagnosing issues. ASP.NET Core also provides some middlewares to show diagnostic information and generate error page responses. You can read more about Diagnotics in ASP.NET Core at here. I am not going to repeat the things that has been discussed in that post here. Rather, my intention will be to show you how to do custom errors like stuffs that we are used to do in classic ASP.NET.
Description:
Let's create an ASP.NET Core web application (note that I am using beta4 at the time of writing). The default template include these diagnostics lines,
if (env.IsEnvironment("Development")) { // omitted for brevity app.UseErrorPage(ErrorPageOptions.ShowAll); // omitted for brevity } else { // Add Error handling middleware which catches all application specific errors and // sends the request to the following path or controller action. app.UseErrorHandler("/Home/Error"); }
In classic ASP.NET, we use customErrors.mode in web.config to change the behaviour of response error pages so that our application will not leak security information. In ASP.NET Core, we can easily and explicitly opt-in or opt-out the error pages and its details about exception, source code, cookies, query-string, headers, etc., using UseErrorPage extension method. The default ASP.NET Core template that comes with Visual Studio 2015 RC only opt-in this feature if there is an environment variable called ASPNET_ENV and its value set to Development(during development Visual Studio automatically create this variable for us) which makes it harder for developers to accidently opt-in this feature in production. There is another extension method called UseErrorHandler. We can use this method in production to show generic error page or execute middleware if there is an unhandled exception thrown by application. This method is looks akin to classic ASP.NET's customErrors.defaultRedirect(but no redirect happen in ASP.NET Core ). We can pass a path(as shown above) or generic middlware(s) pipeline. For example(taken from),
//Configure the error handler to show an error page. app.UseErrorHandler(errorApp => { // Normally you'd use MVC or similar to render a nice page. errorApp.Run(async context => { context.Response.StatusCode = 500; context.Response.ContentType = "text/html"; await context.Response.WriteAsync("<html><body>\r\n"); await context.Response.WriteAsync("We're sorry, we encountered an un-expected issue with your application.<br>\r\n"); var error = context.GetFeature<IErrorHandlerFeature>(); if (error != null) { // This error would not normally be exposed to the client await context.Response.WriteAsync("<br>Error1: " + HtmlEncoder.Default.HtmlEncode(error.Error.Message) + "<br>\r\n"); } await context.Response.WriteAsync("<br><a href=\"/\">Home</a><br>\r\n"); await context.Response.WriteAsync("</body></html>\r\n"); await context.Response.WriteAsync(new string(' ', 512)); // Padding for IE }); });
There is another middleware called StatusCodePagesMiddleware, which we can use to return a custom error-page or execute a middleware to return response or redirect if the response status code is between 400 and 600. Generally, these status codes are used to indicate client(for 4xx) and/or server(for 5xx) error. The StatusCodePagesMiddleware can be very useful if we want to return a generic error response(or redirect) if some other middleware(e.g. mvc. web-api or authentication) mark the request as invalid using response staus-code(4xx or 5xx). Here is an example(from here),
app.UseStatusCodePages(); // There is a default response but any of the following can be used to change the behavior. // app.UseStatusCodePages(context => context.HttpContext.Response.SendAsync("Handler, status code: " + context.HttpContext.Response.StatusCode, "text/plain")); // app.UseStatusCodePages("text/plain", "Response, status code: {0}"); // app.UseStatusCodePagesWithRedirects("~/errors/{0}"); // PathBase relative // app.UseStatusCodePagesWithRedirects("/base/errors/{0}"); // Absolute // app.UseStatusCodePages(builder => builder.UseWelcomePage()); // app.UseStatusCodePagesWithReExecute("/errors/{0}");
In the above code, we also have extension methods for re-executing or redirecting the request(in classic ASP.NET world, we have customErrors.redirectMode=ResponseRedirect | ResponseRewrite). The above code will return a default response if the request is marked as invalid(means response status-code is set between 400 and 600) by some middleware. In some cases, some middleware may be willing to set both the status code between 400 and 600 as well as response at runtime. They can disable the status code page middleware by setting IStatusCodePagesFeature.Enabled to false. Here is an example,
var statusCodePagesFeature = context.GetFeature<IStatusCodePagesFeature>(); if (statusCodePagesFeature != null) { statusCodePagesFeature.Enabled = false; }
I think there is something missing in this feature. In classic ASP.NET world we have customErrors > error.statusCode and customErrors > error.redirect, which only redirect the user if a specific status code matches. We can very easily add/enable this feature using,
public static class MyStatusCodePagesExtensions { /// <summary> /// Adds a StatusCodePages middleware with the given options that checks for responses with status codes /// between 400 and 599 that do not have a body. /// </summary> /// <param name="app"></param> /// <param name="options"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, StatusCodePagesOptions options, int statusCode) { return app.UseMiddleware<MyStatusCodePagesMiddleware>(options, statusCode); } /// <summary> /// Adds a StatusCodePages middleware with a default response handler that checks for responses with status codes /// between 400 and 599 that do not have a body. /// </summary> /// <param name="app"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, int statusCode) { return UseStatusCodePages(app, new StatusCodePagesOptions(), statusCode); } /// <summary> /// Adds a StatusCodePages middleware with the specified handler that checks for responses with status codes /// between 400 and 599 that do not have a body. /// </summary> /// <param name="app"></param> /// <param name="handler"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, Func<StatusCodeContext, Task> handler, int statusCode) { return UseStatusCodePages(app, new StatusCodePagesOptions() { HandleAsync = handler }, statusCode); } /// <summary> /// Adds a StatusCodePages middleware with the specified response body to send. This may include a '{0}' placeholder for the status code. /// The middleware checks for responses with status codes between 400 and 599 that do not have a body. /// </summary> /// <param name="app"></param> /// <param name="contentType"></param> /// <param name="bodyFormat"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, string contentType, string bodyFormat, int statusCode) { return UseStatusCodePages(app, context => { var body = string.Format(CultureInfo.InvariantCulture, bodyFormat, context.HttpContext.Response.StatusCode); return context.HttpContext.Response.SendAsync(body, contentType); }, statusCode); } /// <summary> /// Adds a StatusCodePages middleware to the pipeine. Specifies that responses should be handled by redirecting /// with the given location URL template. This may include a '{0}' placeholder for the status code. URLs starting /// with '~' will have PathBase prepended, where any other URL will be used as is. /// </summary> /// <param name="app"></param> /// <param name="locationFormat"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePagesWithRedirects(this IApplicationBuilder app, string locationFormat, int statusCode) { if (locationFormat.StartsWith("~")) { locationFormat = locationFormat.Substring(1); return UseStatusCodePages(app, context => { var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode); context.HttpContext.Response.Redirect(context.HttpContext.Request.PathBase + location); return Task.FromResult(0); }, statusCode); } else { return UseStatusCodePages(app, context => { var location = string.Format(CultureInfo.InvariantCulture, locationFormat, context.HttpContext.Response.StatusCode); context.HttpContext.Response.Redirect(location); return Task.FromResult(0); }, statusCode); } } /// <summary> /// Adds a StatusCodePages middleware to the pipeline with the specified alternate middleware pipeline to execute /// to generate the response body. /// </summary> /// <param name="app"></param> /// <param name="configuration"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePages(this IApplicationBuilder app, Action<IApplicationBuilder> configuration, int statusCode) { var builder = app.New(); configuration(builder); var tangent = builder.Build(); return UseStatusCodePages(app, context => tangent(context.HttpContext), statusCode); } /// <summary> /// Adds a StatusCodePages middleware to the pipeline. Specifies that the response body should be generated by /// re-executing the request pipeline using an alternate path. This path may contain a '{0}' placeholder of the status code. /// </summary> /// <param name="app"></param> /// <param name="pathFormat"></param> /// <returns></returns> public static IApplicationBuilder UseStatusCodePagesWithReExecute(this IApplicationBuilder app, string pathFormat, int statusCode) { return UseStatusCodePages(app, async context => { var newPath = new PathString(string.Format(CultureInfo.InvariantCulture, pathFormat, context.HttpContext.Response.StatusCode)); var originalPath = context.HttpContext.Request.Path; // Store the original paths so the app can check it. context.HttpContext.SetFeature<IStatusCodeReExecuteFeature>(new StatusCodeReExecuteFeature() { OriginalPathBase = context.HttpContext.Request.PathBase.Value, OriginalPath = originalPath.Value, }); context.HttpContext.Request.Path = newPath; try { await context.Next(context.HttpContext); } finally { context.HttpContext.Request.Path = originalPath; context.HttpContext.SetFeature<IStatusCodeReExecuteFeature>(null); } }, statusCode); } } public class MyStatusCodePagesMiddleware { private readonly RequestDelegate _next; private readonly StatusCodePagesOptions _options; private readonly int _statusCode; public MyStatusCodePagesMiddleware(RequestDelegate next, StatusCodePagesOptions options, int statusCode) { _next = next; _options = options; if (_options.HandleAsync == null) { throw new ArgumentException("Missing options.HandleAsync implementation."); } _statusCode = statusCode; } public async Task Invoke(HttpContext context) { var statusCodeFeature = new StatusCodePagesFeature(); context.SetFeature<IStatusCodePagesFeature>(statusCodeFeature); await _next(context); if (!statusCodeFeature.Enabled) { // Check if the feature is still available because other middleware (such as a web API written in MVC) could // have disabled the feature to prevent HTML status code responses from showing up to an API client. return; } // Do nothing if a response body has already been provided. if (context.Response.HeadersSent || context.Response.StatusCode < 400 || context.Response.StatusCode >= 600 || context.Response.StatusCode != _statusCode // additional check || context.Response.ContentLength.HasValue || !string.IsNullOrEmpty(context.Response.ContentType)) { return; } var statusCodeContext = new StatusCodeContext(context, _options, _next); await _options.HandleAsync(statusCodeContext); } }
In the above code, we have just added additional overloads of UseStatusCodePagesXXX which adds an additional parameter called statusCode. We can now easily do what we have used to do in classic ASP.NET. You can test this by adding these lines on Startup class,
app.UseStatusCodePagesWithRedirects("~/400.html", 400); app.UseStatusCodePagesWithRedirects("~/500.html", 500);
Summary:
Diagnostics feature in ASP.NET Core is much more improved. In this article, I showed you how we can do custom errors like stuffs in ASP.NET Core that we are doing with classic ASP.NET.