One of the conundrums of exception handling in asp net, is the question on where to catch them and turn them into http responses. The service layer should not have any conception of http, as it is purely about the underlying business logic and has no idea who calls it and why. The controller is where this logic belongs, but considering how the controller code is usually one to three lines of code and the rest is exception handling, this results in an unreasonable amount of boiler plate code in relation to business code.
To tackle this conuncrum this package supply an exception mapper profile for handling of downstream exception. In that profile you map a service exception with an http response code and an internal error code. The exception handler will use the mapping profile to automatically catch any uncaught exception coming from the downstream services and transforms the exception to json response messages, and sets the status code of the response. As a result, the only exception handler boiler plate needed in the controllers are handling exceptions from the default mapping. Other than that, the controller should only contain the naĂŻve happy path of execution.
There are also support packages included in this repo that extends the exception handler, but is not needed for the core functionality.
- AspNetCore ExceptionHandler
Install the nuget package from nuget
Either add it with the PM-Console.
Install-Package Frogvall.AspNetCore.ExceptionHandling
Or add it to your csproj file.
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling" Version="7.1.0" />
...
</ItemGroup>
A few other packages are handled by this repo that builds upon the functionality of the main package:
- AwsXRay: Adds an extra middleware for decorating the status code of the AWS XRay trace record, needed if using the exception handler middleware in unison with AWS XRay. Also adds an exception listener for decorating the trace record with the caught exception.
- ModelValidation: Adds another filter for automatically validating the models in your controller and returning with a http content on the same format as the exception handler, to make the result unison no matter if the exception handler returns it or the model validation fails. Also supplies an attribute like
[Required]
, but for non-nullable types, like integers, guids, etc. - NewtonsoftJson: From version 5.0.0 this package relies on the
System.Text.Json
library for writing and parsing json. This includes the extension methods for parsing an ApiError. This package adds extra parsing options for those who are using Newtonsoft.Json instead. - Swagger: Adds a couple of OperationFilters for those that use Swashbuckle.Swagger and wants to automatically decorate their Open Api documentation with 400 and 500, which the exception handler can throw for any operation.
You could hook the exception handler into your asp.net core pipeline in two ways. Either as a middleware or a filter. When hooking it in as a middleware, the exception handler uses Microsoft's exception handler middleware under the hood, which clears headers upon handling the exception. This will result in a CORS error if called from a javascript application. If used as a filter, the headers will remain intact. It's certainly viable to have both the middleware and the filter hooked in at the same time. What will happen in such case is that the filter will catch any error within the controller method and handle it accordingly, while the middleware will function as a final safe guard, in case the application encounters a problem before the controller method has been reached, for example while invoking another middleware.
To add the exception handler filter, add the following to according MVCOptions
in your ConfigureServices()
method.
mvcOptions.Filters.Add(new ApiExceptionFilter());
Example:
services.AddControllers(mvcOptions =>
{
mvcOptions.Filters.Add(new ApiExceptionFilter());
});
You can also use the included extension method:
using Microsoft.AspNetCore.Mvc.Filters;
mvcOptions.Filters.AddApiExceptionFilter();
To hook it into the middleware pipeline, add this to the Configure()
method.
app.UseApiExceptionHandler();
Since middlewares are dependent on the order they are executed, make sure that any middleware that executes before the exception handler middleware can never throw an exception. If that happens you service will terminate.
This package comes with a predefined opiniated error object, which might not be what you need in order to fulfil certain demands on your API. In order to change how the resulting error output should look, you can pass a function to the exception handler or exception filter.
services.AddControllers(mvcOptions =>
{
mvcOptions.Filters.AddApiExceptionFilter((error, statusCode) => myErrorObject(error, statusCode));
});
app.UseApiExceptionHandler((error, statusCode) => myErrorObject(error, statusCode));
Sometimes you want to do some things when the exception handler catches an exception. One such example could be that you would want to add exception metadata to your tracing context, for example Amazon XRay. In order to do so, you can pass one or several actions to the exception handler middleware and filter. The actions will be executed when an exception is caught, before the http response is built.
services.AddControllers(mvcOptions =>
{
mvcOptions.Filters.AddApiExceptionFilter(MyExceptionListener.HandleException);
});
app.UseApiExceptionHandler(MyExceptionListener.HandleException);
Included in the exception handler package is an exception mapper. You don't have to utilize the mapper for the exception handler to work, but you still have to initialize it.
To initialize the mapper add this to the ConfigureServices()
method:
services.AddExceptionMapper();
This is all that is needed to use the basic functionality of this package. Exceptions will be handled and parsed into a http response with a status code of 500. The exception stack trace will be pushed with the response when run locally, and a generic message will take it's place when IsDevelopment()
is false.
If you want to handle 4xx and 5xx errors in your api's by casting exceptions, you can create a mapping profile. Anywhere in your assembly, put a class that implements the abstract ExceptionMappingProfile<>
class. The generic type should be an enum that describes the error. The int and string representation of the enum will both exist in the response, so keep that in mind when naming them. Exception mapping happens in the constructor and there you inform the mapper what exceptions should result in what http status code and what internal errorcode it should represent.
Example:
public class MyMappingProfile : ExceptionMappingProfile<MyErrorEnum>
{
public MyMappingProfile()
{
AddMapping<MyException>(HttpStatusCode.BadRequest, MyErrorEnum.MyErrorCode);
}
}
As an alternative, the AddMapping function can take a lambda instead of an enum.
The exception mapper only handles exceptions that implement the BaseApiException
included in this package, as a way for you to prove to it that you own the exception. The reasoning for that is that you don't necessary know where other exceptions come from in beforehand and therefore can't be sure it's mapped correctly.
After an exception is mapped, it can be thrown from anyewhere in order to abort the current api action and return with the corresponding http status code.
When initializing the exception mapper, there are some options you can pass in. Those are:
ServiceName
: Setting this option will override the default service name that will be added to the respond message. By default the entry assembly name will be chosen, but there are cases where this is not the correct name. When running in AWS Lambda for example, the default name would be "LambdaExecutor".RespondWithDeveloperContext
: A boolean descibing wether the developer context should be written in the response. This is handy for example in local development, but not recommended in production. This could for example be set toIHostEnvironment.IsDevelopment()
. The default isfalse
.
The exception handler uses an ApiError
record that is serialized into the response body. There are also a couple of extension methods that can be used to parse an ApiError received as the response body from a downstream service.
To parse an ApiError in an asynchronous context, use the ParseApiErrorAsync
extension method.
var response = await _client.PostAsync(...);
var error = await response.ParseApiErrorAsync();
if (error != null)
{
//Handle api error here
}
else
{
//Handle non-api error here
}
To parse an ApiError in a synchronous context you can call TryParseApiError(out ApiError error)
if (response.TryParseApiError(out var error))
{
//Handle api error here
}
else
{
//Handle non-api error here
}
As the context and developer context of the api error class can be any object, they are deserialized as System.Text.Json.JsonElement
.
As an extension package you can add automatic model validation to your controller actions. The model validation filter will check your incoming requests against your models and return with a 400 Bad Request
if the model validation fails. The return body will be formatted in the same way as the exception handler formats any other exception. In order to utilize the model validation functionalities you need to add the model validation package to your dependencies.
Install-Package Frogvall.AspNetCore.ExceptionHandling.ModelValidation
or
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.ModelValidation" Version="7.1.0" />
...
</ItemGroup>
In order for all your controller actions to have their models automatically validated you can add the ModelValidationFilter
to your MVCOptions
in your ConfigureServices()
method.
services.AddMvc(options =>
{
options.Filters.Add(new ValidateModelFilter { ErrorCode = 123 } );
});
Or you can use the included extension method:
using Microsoft.AspNetCore.Mvc.Filters;
services.AddMvc(options =>
{
options.Filters.AddValidateModelFilter(123);
});
Alternatively, you could add the model validation filter as an attribute to specific controllers or controller methods.
[ValidateModelFilter(ErrorCode = 123)]
If using the mvc filter to add model validation as default, and you for some reason want to exclude a single controller or controller action from the model validation, this can be done by appending the SkipModelValidation
attribute.
[SkipModelValidationFilter]
If you are utilizing the ApiController attribute, the above way of handling model validation errors will be overwritten by the ApiController flow. Then you can instead configure the ApiController flow to use the ApiError
class for reporting model validation errors.
services.ConfigureOptions<ConfigureApiErrorBehaviorOptions>();
If you want to also set the ErrorCode, do so by calling the following extension method on you your ExceptionMapperOptions
.
.SetModelValidationErrorCode(123);
Example:
services.AddExceptionMapper(new ExceptionMapperOptions().SetModelValidationErrorCode(911), typeof(Program));
The ApiController flow does not currently support the SkipModelValidation
attribute.
The exception handler uses System.Text.Json for serializing and deserializing json. If you are using Newtonsoft.Json
for serialization and deserialization in your service, the exception handler still works fine. The only caveat is that the extension methods that parses the ApiError
class is going to include System.Text.Json.JsonElement
objects. If you rather would have them as Newtonsoft.Json.JObjects
you can use the extension methods that are included in this package instead. In order to do so you need to add the newtonsoft json package to your dependencies.
Install-Package Frogvall.AspNetCore.ExceptionHandling.NewtonsoftJson
or
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.NewtonsoftJson" Version="7.0.0" />
...
</ItemGroup>
You can then call the newtonsoft json extension methods instead. To parse an ApiError in an asynchronous context, use the ParseApiErrorUsingNewtonsoftJsonAsync
extension method.
var response = await _client.PostAsync(...);
var error = await response.ParseApiErrorUsingNewtonsoftJsonAsync();
if (error != null)
{
//Handle api error here
}
else
{
//Handle non-api error here
}
To parse an ApiError in a synchronous context you can call TryParseApiErrorUsingNewtonsoftJson(out ApiError error)
if (response.TryParseApiErrorUsingNewtonsoftJson(out var error))
{
//Handle api error here
}
else
{
//Handle non-api error here
}
The swagger extension package is a very small package that comes with a couple of operation filters, that can be attached to your Swashbuckle.Swagger
swagger specification in order to always decorate your swagger documentation with the two http status codes that the exception handler return. To use those operation filters you need to add the swagger package to your dependencies.
Install-Package Frogvall.AspNetCore.ExceptionHandling.Swagger
or
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.Swagger" Version="7.0.0" />
...
</ItemGroup>
You can then add the operation filters to your swagger options object.
services.AddSwaggerGen(options =>
{
options.OperationFilter<ValidateModelOperationFilter>();
options.OperationFilter<InternalServerErrorOperationFilter>();
});
When deploying your service in AWS and using AWS XRay for tracing, this package comes with a couple of handy addons for making your XRay traces better. In order to utilize these addons you need to add the aws xray package to your dependencies.
Install-Package Frogvall.AspNetCore.ExceptionHandling.AwsXRay
or
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.AwsXRay" Version="7.0.0" />
...
</ItemGroup>
When using the exception handling middleware, the status code for the response has not yet been set when the AWS XRay middleware catches the exception. Hence, the status code will be set to 200 in your XRay trace, even though it should be something else. This could be remedified by adding XRay before the exception handler, but then the exception metadata will be missing from the XRay trace instead, and any exception thrown by the XRay middleware will crash the application.
Another way to remedify the problem is to add the ExceptionStatusCodeDecoratorMiddleware
after the XRay middleware. The status decorator will catch the exception, use the exception mapper to decorate the status code and rethrow. The exception will then be caught by the XRay middleware, the XRay trace will be decorated with the correct status code and exception metadata and rethrown and finally handled by the exception handler middleware.
// Order is important
app.UseApiExceptionHandler();
app.UseXRay("MyServiceName");
app.ExceptionStatusCodeDecoratorMiddleware();
When using the exception handler filter in concordance with the AWS XRay middleware, the exception thrown will never reach the AWS XRay middleware and hence the XRay trace will never be decorated with the exception metadata. This package includes an exception listener (see #exception-listeners) that will decorate the XRay trace when the exception handler filter catches an exception. To use the AWS XRay exception listener, add its handle action when adding the exception handler filter.
services.AddControllers(mvcOptions =>
{
mvcOptions.Filters.AddApiExceptionFilter(AwsXRayExceptionListener.AddExceptionMetadataToAwsXRay);
});