Improving API error responses with the Result pattern

| 8 min. (1634 words)

In the expanding world of APIs, meaningful error responses can be just as important as well-structured success responses. In this post, I’ll take you through some of the different options for creating responses that I’ve encountered during my time working at Raygun. We’ll go over the pros and cons of some common options, and end with what I consider to be one of the best choices when it comes to API design, the Result Pattern. This pattern can lead to an API that will cleanly handle error states and easily allow for consistent future endpoint development. It has been particularly useful to me while developing the recently released Raygun API Project, where it has allowed for faster development of endpoints by simplifying the code needed to handle error states.

What defines a “useful” error response?

A useful error response is one that provides all the information a developer needs to correct the error state. This can be achieved through a helpful error message and consistent use of HTTP status codes.

What options do I have for creating responses?

Here, I will take you through a couple of different options for creating and handling error responses, the pros and cons of each and show you how the Result pattern can provide you with quality and consistent responses. In these examples, I’m using C# and .NET, but the same ideas can be applied to other languages and frameworks too.

In this post:

Option one: Null checking

Our first option uses a null value to indicate if a problem occurred with the request.

In the following example, you can see how when the CreateUser function does not succeed in creating a user, it will return a null value. We then use this as an indication to return a 400 Bad Request response.

[HttpPost]
public ActionResult<User> CreateUser([FromServices] IUserService service, [FromBody] CreateUserRequest request)
{
 var createdUser = service.CreateUser(request);

 if (createdUser is null)
 {
   return Problem(detail: "Failed to create user", statusCode: StatusCodes.Status400BadRequest
);
 }

 return createdUser;
}

Within the UserService we may have something like the following:

public User? CreateUser(CreatUserRequest request)
{
 if (!request.EmailAddress.Contains('@'))
 {
   return null;
 }

 // excluded for brevity
}

Now, if we call the endpoint with an email address that does not contain the @ symbol, we’ll receive the following response:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "Failed to create user",
  "traceId": "00-d82c53bff5687d0a789092202b84c8e2-75c3503892c8f4e6-00"
}

On the surface, there don’t seem to be any issues with this approach, as we’re able to tell the developer that their request failed. However, due to the vague error message, they won’t know why the request has failed and will be unable to correct their request.

Pros:

  • Fast to implement

Cons:

  • Provided error message is unclear and vague
  • Explicit handling of null values would be required on all controller actions
  • No flexibility on the error code returned
  • High potential for inconsistent use of error codes as each controller action will define the type of response code

Option two: Exceptions as flow control

In this option, we’ll explore using exceptions to capture more information so as to provide the user with more informative error messages and response codes.

To get started, we create an exception class that will describe the error that has occurred:

public class BadRequestException : Exception
{
 public BadRequestException(string message)
   : base(message)
 {
 }
}

Now we can update our controller action to catch the new exception:

[HttpPost]
public ActionResult CreateUser([FromServices] IUserService service, [FromBody] CreatUserRequest request)
{
 try
 {
   var createdUser = service.CreateUser(request);
   return Ok(createdUser);
 }
 catch (BadRequestException exception)
 {
   return Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest);
 }
}

Finally, we update the UserService to throw the exception in the correct situation:

public User CreateUser(CreatUserRequest request)
{
 if (!request.EmailAddress.Contains('@'))
 {
   throw new BadRequestException("EmailAddress must contain an '@'");
 }

 // excluded for brevity
}

Calling the endpoint with the same request as before returns this response:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "EmailAddress must contain an '@'",
  "traceId": "00-94b344c12a6deae65a40aed65af6f26d-d056ed66a00e792f-00"
}

We can now tell the users of our API why a request has failed and they can correct their requests based on the response.

From here, we could take it an extra step by adding a middleware that will capture exceptions for us and create the Bad Request response.

public class ErrorResponseHandlingMiddleware
{
 private readonly RequestDelegate _next;

 public ErrorResponseHandlingMiddleware(RequestDelegate next)
 {
   _next = next;
 }

 public async Task InvokeAsync(HttpContext context)
 {
   try
   {
     await _next(context);
   }
   catch (BadRequestException ex)
   {
     var factory = context.RequestServices.GetRequiredService<ProblemDetailsFactory>();
     var problemDetails = factory.CreateProblemDetails(context, detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);

     await Results
      .Problem(problemDetails)
      .ExecuteAsync(context);
   }
 }
}

Add the middleware in the program.cs file:

app.UseMiddleware<ErrorResponseHandlingMiddleware>();

Now, we no longer need the try/catch block in the controller action.

[HttpPost]
public ActionResult CreateUser([FromServices] IUserService service, [FromBody] CreatUserRequest request)
{
 var createdUser = service.CreateUser(request);
 return Ok(createdUser);
}

This could be further extended by creating new exception types and handling them in the middleware, allowing you to return different response codes. However, we’ve introduced exceptions as a mechanism to control execution flow, which creates a lot of other potential problems. These problems are explored in depth in many other posts, so I won’t go into detail here but to summarize a few points:

  • Using exceptions for flow control can make the code execution path harder to follow
  • Raising exceptions creates a performance cost
  • It’s harder to distinguish real exceptions from ones used for flow control.

Pros:

  • Error messages are informative because they can be defined in the exception message
  • Error response codes are flexible by handling different exception types
  • Controller actions are simple
  • By using custom exception types, the response code can be more consistent in its use

Cons:

  • Introduces exceptions as a form of flow control
  • Can interfere with exception logging tools (such as Raygun!)
  • Use of a middleware obscures how responses are created making the code execution harder to follow

Option three: The Result pattern

What is the Result pattern?

Quite simply, the Result software design pattern is when an operation returns an object containing the outcome of the operation as well as any data that the operation returned. Implementing a rudimentary result type would be quite simple but I am using FluentResults here as it is a well-featured library and provides me with what I need without needing to create it myself.

What is FluentResults?

As the GitHub page says:

“FluentResults is a lightweight .NET library developed to solve a common problem. It returns an object indicating success or failure of an operation instead of throwing/using exceptions.”

Example:

Install the package:

Install-Package FluentResults

Update the code as follows:

  1. Create a Result type to be returned from the UserService
using FluentResults;

namespace Example.Errors;

public class RequestValidationError : Error
{
 public RequestValidationError(string message)
   : base(message)
 {
 }
}
  1. Update the UserService to return the new Result type
public Result<User?> CreateUser(CreatUserRequest request)
{
 if (!request.Email.Contains('@'))
 {
   return new RequestValidationError("Email must contain a '@'");
 }

 // excluded for brevity
}
  1. Update the controller action to handle the new return type:
[HttpPost]
public ActionResult CreateUser([FromServices] IUserService service, [FromBody] CreatUserRequest request)
{
 var createdUser = service.CreateUser(request);

 if (createdUser.IsFailed)
 {
   return Problem(detail: createdUser.Errors.First().Message, statusCode: StatusCodes.Status400BadRequest);
 }

 return Ok(createdUser.Value);
}

This will now produce the same result as the example using exceptions but with the benefit of not using exceptions. But similar to the exception example we can extract this logic out of the controller action.

To extract the logic from the controller action we can create a base controller class to be extended by our controller.

public class ResultsControllerBase : ControllerBase
{
 protected ActionResult Ok<T>(IResult<T> result)
 {
   if (result.IsSuccess)
   {
     return base.Ok(result.Value);
   }

   var error = result.Errors.First();

   if (error is RequestValidationError)
   {
     return Problem(detail: error.Message, statusCode: StatusCodes.Status400BadRequest);
   }

   // Throw - because we've got an error we haven't accounted for
   throw new Exception(result.ToString());
 }
}

Now, within the UserController we extend the new base class and remove the logic from the action method.

public class UserController : ResultsControllerBase
{
  [HttpPost]
  public ActionResult CreateUser([FromServices] IUserService service, [FromBody] CreatUserRequest request)
  {
   var createdUser = service.CreateUser(request);
   return Ok(createdUser);
  }

To extend this further you can determine the type of HTTP response to return based on the type of error. For example, the UserService could return a ResourceConflictError if there’s already an existing user to allow the controller base to return a 409 Conflict response in this case.

Pros

  • Error messages are informative as they can be defined on the Result type
  • Error response codes are flexible by handling different Result types in the base class
  • Controller actions have minimal to no logic
  • By handling different Result types, the response codes can more consistent
  • Following the execution path is still simple with use of the base class
  • Enables services to be explicit about the return values and possible errors
  • Allows the domain to specify errors that are meaningful outside the context of just HTTP responses

Cons:

  • Need to ensure the Results pattern is only used where needed so it doesn’t find its way into your whole codebase, as it can produce bloat to functions that don’t need it
  • It is not natively supported by .NET and is dependent on a third party library

Conclusion

Implementing useful error responses in your API is not just a matter of handling errors efficiently; it’s also about creating a more intuitive and user-friendly interface for those who interact with your API. By utilizing the Result pattern, as discussed in this post, you can provide clear, actionable error messages that empower developers to understand and rectify issues independently. This approach not only enhances the user experience but also encourages better practices in API design. Additionally, by avoiding common anti-patterns and embracing a more structured error-handling method, you can ensure that your API remains robust, maintainable, and scalable.

Read about other lessons from our API project here.