Robust Error Handling in ASP.NET Core
Wed, 22 Jul 2020 23:00 UTC by garethbrown
Error handling in .NET Core is great, but there are some pitfalls if not properly set up that can result in unexpected behaviour. For example - HTTP status codes that appear incorrect, or some categories of errors not being trapped.
I revisited my error handling code because of a few issues I was experiencing:
- Errors that occurred before the request had made it inside controller code were returning HTTP 405 (Method not allowed) to the caller / browser.
- Errors in API controllers were returning HTML in the response to the caller, when a simple text or JSON response would be more appropriate.
The main reference documentation is here:
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-3.1
The core issues
Changing [HttpGet] to [Route]
I was using the [HttpGet("error/{statusCode:int}")]
HTTP verb attribute on my error controller action. This meant that where an error was thrown and the original action was an HTTP POST, UseStatusCodePagesWithReExecute
re-executes the pipeline but is not allowed to call the Error
action because [HttpGet("error/{statusCode:int}")]
requires that the action is only available in the context of an HTTP POST. To resolve this, I needed to use [Route("error/{statusCode:int}")]
instead.
Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IConfiguration configuration)
{
// ....
// Ensures graceful handling of non exception errors (404's etc)
app.UseStatusCodePagesWithReExecute("/error/{0}");
// Ensures custom error pages for unhandled exceptions
app.UseExceptionHandler("/error/500");
Checking paths in HttpContext.Features to determine if the original request was for an API controller
Using properties on the variables below ...
var statusCodeFeature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();
var exceptionDataFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
We can later check the path for an /api
prefix and return the appropriate response.
bool isApiPath = (statusCodeFeature?.OriginalPath != null && statusCodeFeature.OriginalPath.StartsWith("/api", StringComparison.InvariantCultureIgnoreCase)) ||
(exceptionDataFeature?.Path != null && exceptionDataFeature.Path.StartsWith("/api", StringComparison.InvariantCultureIgnoreCase));
if (isApiPath)
{
actionResult = Content($"The request could not be processed ({statusCode.ToString(CultureInfo.InvariantCulture)})");
}
else
{
ViewBag.StatusCode = statusCode;
actionResult = View();
}
Full Source Code
ErrorController.cs
using System;
using System.Globalization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ExampleApp.Controllers
{
public class ErrorController : Controller
{
private readonly IConfiguration _configuration;
private readonly ILogger _logger;
public ErrorController(IConfiguration configuration, ILogger logger) : base(configuration)
{
_configuration = configuration;
_logger = logger;
}
// Must be [Route] attribute and not HttpGet (for example) as if the original erroring action does
// not match then this results in an 40t status code which is confusing for the recipient
[Route("error/{statusCode:int}")]
public IActionResult Error(int statusCode)
{
var statusCodeFeature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();
var exceptionDataFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
string logString = null;
if (statusCodeFeature != null)
{
logString += $"Status Code: {statusCode} Status Code Path Base: [{statusCodeFeature.OriginalPathBase}], Status Code Path: [{statusCodeFeature.OriginalPath}], Status Code Query String: [{statusCodeFeature.OriginalQueryString}]";
}
if (exceptionDataFeature != null)
{
if (logString != null)
{
logString += Environment.NewLine;
}
logString += $"Status Code: {statusCode} Exception Path: [{exceptionDataFeature.Path}], Exception: [{exceptionDataFeature.Error}], Exception Message: [{exceptionDataFeature.Error.Message}], Exception Stack Trace: [{exceptionDataFeature.Error.StackTrace}]";
}
if (statusCodeFeature == null && exceptionDataFeature == null)
{
// Unsure if it's possible for both to be null but log something in case
logString += $"Status Code: {statusCode}. Both {nameof(statusCodeFeature)} and {nameof(exceptionDataFeature)} were null in {nameof(ErrorController)} {nameof(Error)}";
}
if (statusCode >= 400 && statusCode <= 499)
{
// Log warning to ensure captured in production logging. Although possibly not our
// error, something is not communicating with our app as expected and so this warrants
// the warn flag.
_logger.LogWarning(logString);
}
else
{
_logger.LogError(logString);
}
IActionResult actionResult;
// Check both statusCodeFeature and exceptionDataFeature for path
bool isApiPath = (statusCodeFeature?.OriginalPath != null && statusCodeFeature.OriginalPath.StartsWith("/api", StringComparison.InvariantCultureIgnoreCase)) ||
(exceptionDataFeature?.Path != null && exceptionDataFeature.Path.StartsWith("/api", StringComparison.InvariantCultureIgnoreCase));
if (isApiPath)
{
actionResult = Content($"The request could not be processed ({statusCode.ToString(CultureInfo.InvariantCulture)})");
}
else
{
ViewBag.StatusCode = statusCode;
actionResult = View();
}
return actionResult;
}
// Test actions to trigger basic use case scenarios
[HttpGet("error/test-throw-404")]
public IActionResult Throw404() => StatusCode(404);
[HttpGet("error/test-throw-422")]
public IActionResult Throw422() => StatusCode(422);
[HttpGet("error/test-throw-500")]
public IActionResult Throw500() => StatusCode(500);
[HttpGet("error/test-throw-exception")]
public IActionResult ThrowException()
{
throw new InvalidOperationException("Test exception");
}
}
}
-
Web Analytics
-
.NET
-
API Versioning and Basic UI Authentication with OpenAPI (Swagger / Swashbuckle) in .NET Core 6
-
Converting Enum Types By Value in C#
-
Implementing Microsoft.Extensions.Logging.ILogger with NLog
-
ASP.NET File Uploader with SignalR Progress Bar and Extended Input Stream Processing
-
How to inject Google Adsense In-Article script into your HTML (ASP.NET Core Razor)
-
Robust Error Handling in ASP.NET Core
-
A Utility Class for Finding Database Deadlocks in .NET Applications
-
Sanitizing HTML in .NET Core
-
Uploading Directly to S3 from Client Using Pre-Signed URLs (JavaScript, .NET)
-
Including Automated Swagger Documentation for API Dependencies
-
API Versioning and Basic UI Authentication with OpenAPI (Swagger / Swashbuckle) in .NET Core 6
-
Principles
-
JavaScript & TypeScript
-
AI
-
Software Architecture
-
General
-
Docker