error handling and pattern matching in csharp
By Per Fröjd
- csharp
- api
- postgresql
- snippets
Error handling
During the time I’ve walked through a couple of backend projects I’m starting to find pet peeves, or concepts I likely care a bit too much about. API error handling and error responses is one of those pet peeves. Doesn’t matter if it’s the API you integrate with, or if it’s the one your building yourself, the error handling never really gets as good as it should be.
Fortunately, in the .NET world, there’s a neat and convenient place to start working on these issues and it’s the middleware. It’s basically a part of the request chain and allows us to (in simple terms), wrap the entire request in a try/catch
. By doing this we can handle our previously unhandled exceptions in a single location.
Look at some sample code for this:
public class ExceptionMiddleware {
private readonly RequestDelegate _next;
public ExceptionMiddleware(RequestDelegate next) {
_next = next;
}
public async Task InvokeAsync(HttpContext context) {
try {
await _next(context);
}
catch (Exception ex) {
// What happens here?
}
}
}
So the underlying idea here, is that whatever _next
ends up doing (likely the action of a controller), we’ll catch any exception that is uncaught. This means that we can keep the controllers slightly less cluttered with their own try/catch clauses. I’ve found that when error handling is spread out to each action it gets less consistent, where some actions may return a 401
on a failed transaction, and others might return a 500
, so that’s what I want to avoid.
So using the example snippet above, we start to flesh out the catch to include more and more different kinds of exceptions, and our first iteration looked something like:
public async Task InvokeAsync(HttpContext context) {
try {
await _next(context);
}
catch (ValidationException vex) {
// Respond appropriately
}
catch (AuthenticationException aex) {
// Respond appropriately
}
catch (EntityNotFoundException enfex) {
// Respond appropriately
}
catch (Exception ex) {
// We couldn't narrow down the exception, this is probably bad.
}
}
So in order to help us respond appropriately in each of the Exception-cases, we built a method to help us deal with the response, making sure that a ValidationException would map to a 401
, a AuthenticationException to 400
, and it looked something like this:
private Task HandleExceptionAsync(HttpContext context, ErrorCodes errorCode, HttpStatusCode statusCode,
string message, IEnumerable<string> errors = null) {
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)statusCode;
return context.Response.WriteAsync(new ErrorResponse(
(int)statusCode,
message,
(int)errorCode,
errors
).ToString());
}
We ended up creating a lot of specific custom exceptions for our application, to ensure that each unique exception could be caught and handled and via tools like Sentry
we gathered as much information as we could about the uncaught exception (which would generate a 500) and continously worked on narrowing it down.
To evolve it further, and make the catches somewhat more concise, we tried converting the same catch to use a new feature with csharp, pattern matching.
public async Task InvokeAsync(HttpContext context) {
try {
await _next(context);
}
catch (Exception ex) {
var func = ex switch {
EntityNotFoundException => HandleExceptionAsync(context,
ErrorCodes.ENTITY_NOT_FOUND, HttpStatusCode.NotFound, ex.Message),
BadRequestException => HandleExceptionAsync(context,
ErrorCodes.BAD_REQUEST, HttpStatusCode.BadRequest, ex.Message),
NotAuthorizedException => HandleExceptionAsync(context,
ErrorCodes.NOT_AUTHORIZED, HttpStatusCode.Unauthorized, ex.Message),
ForeignKeyViolation or UniqueEntityException => HandleExceptionAsync(context,
ErrorCodes.BAD_REQUEST, HttpStatusCode.BadRequest, ex.Message),
_ => HandleExceptionAsync(context, ex)
};
await func;
}
}
It lets us group exceptions in a nicer way, by allowing us to say ExceptionA or ExceptionB
even if both should result in a similar API response with similar HTTP status code and response. Take note of the _
which in this case becomes the catch-all mechanic for the pattern matching. Here we also try to utilize the concept of error codes.
Bonus
But we also have another (slightly more correct) use case for pattern matching. We tend to use postgreSQL
when possible, and since most of our work is done in .NET, the underlying connector tends to be npgsql
.
They’ve made it so the connector always deliver an exception of the same type, the PostgresException
which likely mimics the underlying actual Postgres error that the database engine generates. But in some cases, our API needs to be able to diffrentiate between these errors. A UniqueKeyViolation
could perhaps result in a 401
, whilst a ForeignKeyViolation
might be the results of actual bad data in the database (and should return a 500
).
public Exception TryHandle(Exception ex) {
if (ex is PostgresException pgException) {
var exception = pgException.SqlState switch {
PostgresErrorCodes.UniqueViolation => new UniqueEntityException(),
PostgresErrorCodes.ForeignKeyViolation => new ForeignKeyViolation(),
_ => HandleUnhandledPostgresException(pgException)
};
return exception;
}
return ex;
}
private Exception HandleUnhandledPostgresException(PostgresException peg) {
Console.WriteLine(peg.SqlState);
Console.WriteLine(peg.Detail);
Console.WriteLine(peg.Message);
return new UnhandledPostgresException();
}
With this, we can map a PostgresException
to our application-specific exception using the internal sqlState
property of the exception, which can then be caught appropriately by our middleware that we pieced together earlier in this post. A follow-up question might be how we can end up using this in a neat way, but that depends entirely on the way you’ve structured your application, and what contexts your database transactions end up in.
In this application, we’ve pieced together a ServiceContext-class, responsible for starting a transaction against the postgres connector, and commit/rollback transactions depending on their failure state, this means that all repository methods are called from within a transactional context, something like this.
public T RunInTransaction<T>(Func<T> func) {
using (BeginTransaction()) {
try {
var result = func();
Commit();
return result;
}
catch (Exception e) {
if (HasOpenTransaction()) Rollback();
throw _handler.TryHandle(e);
}
}
}
And would look to be consumed in a controller somewhat similar like this:
[Authorize]
[HttpGet("{id}")]
public IActionResult Get([FromRoute] string id) {
if (string.IsNullOrEmpty(id))
throw new BadRequestException("Invalid parameter (id)");
var entity = _serviceContext.RunInTransaction(() => {
return entityService.GetEntityById(parsed);
});
return Ok(entity);
}
Wrapping this up, I feel this provides a more concise way of handling responses and errors in a single place via the ErrorMiddleware, allowing us to throw exceptions anywhere between the Controller, Services and Repositories, but for them to ultimately be handled and responding appropriately to the end user. It does mean we have to manually map a lot of exceptions as we find them, but once they’re in place they feel pretty solid.