406, 404 or 400? Investigating the Fine Line Between API Status Codes.

Introduction

Hello, debuggers! I'm a junior developer who's just started my second year of engineering, and I'm currently working on building internal APIs with my team. As we all know, having a solid understanding of HTTP status codes and client-server communication is crucial when creating web services, no matter the scale. But, even experienced developers can face challenges when it comes to the nuances of these status codes and troubleshooting them.

For my first debugging writeathon, I'm excited to share a particular challenge I faced recently: investigating the fine line between the 406, 404, and 400 status codes for invalid requests. Whether you're a programming wizard or a junior like me, I hope you’ll find some helpful insights and tips in this article. So, let's dive in and explore how I troubleshooted this issue!

💡 The code examples I'll give in this article are written in C#, on top of ASP.NET Core.

The Problem

I recently was assigned the task of investigating an unexpected issue that we encountered while testing one of our GET endpoints. We discovered that, whenever the id query parameter was of the incorrect data type (in this case, not a GUID), our API was returning a 406 status code to the client. This was concerning as we were expecting the response to be 400 - Bad Request, indicating a client error due to incorrect input.

To better illustrate the problem, consider the following sample code snippet:

// implementation details omitted for brevity

[HttpGet("{id:guid}/discovery", Name = nameof (GetResource))]
public ActionResult<ResponseDto> GetResource(Guid id)
{
    return new ResponseDto();
}

So, to reiterate, when an invalid value type is passed in the path parameter of our /GetResource endpoint, the client receives a misleading error code, i.e. 406 - Not Acceptable. Based on our technical requirements, we needed the response to be 400 - Bad Request with an appropriate error message.

Can you spot that we have two problems? If not, it’s ok, I didn’t at first too! Let’s jump to the investigation to understand what’s going on.

Investigation and Diagnosis

The first step I took to investigate the issue was to review the meaning of HTTP status codes in the 400 realm. I wanted to make sure that I fully understood the difference between a 400 - Bad Request status code and a 406 - Not Acceptable status code. After refreshing my knowledge on the meaning of these HTTP status codes, I suspected that the issue might be related to the way our API routes were defined (or constrained), so I immediately turned my attention to our use of route constraints, which is a concept from the .Net framework.

After digging a little deeper, I discovered that the default .NET behaviour for this use case is to return a 404 Not Found status code, not 406 - Not Acceptable.

Phase 1

Let’s clarify the meaning of each status code involved in the context and make sure that the 400 status code is really what we want our API to return.

I’ve formatted in bold the relevant information for our use case.

400 - Bad Request (expected)

The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).

400 errors indicate an invalid request, which means either that mandatory parameters are missing, or that syntactically invalid parameter values have been detected (for example an expected URL being text only)

404 - Not Found (default .Net behaviour when using route constraints)

The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist.

406 - Not Acceptable (improper API response)

This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent (calling service).

This status code is often bound to Accept/Content-Type Header use cases, e.g. if you set Accept-Header to text/plain for the client and the backend can only produce application/json Content-Type.

These status code descriptions come from MDN web docs, IBM API status codes reference and GitHub issue.

At this point, here’s what we know for sure:

  1. The 406 status code is returned AFTER the server has performed content negotiation and is related to the user agent request header specifications. Currently, the server responds with a 406 status code if the id query parameter is of the incorrect data type, which is inaccurate.

  2. It should be clear to the client whether they have a typo (or an invalid type) in their request or whether the requested resource is missing. Based on the MDN web docs description, the 404 status code can be returned when the request is valid but the resource is missing (which is how .Net treats this use case, by default). In our specific use case, however, the request would simply be invalid as the GUID data type is enforced at the controller level with validation.
    \Hint: Here’s our second problem, and it appears to be the underlying cause of the first problem we encountered.*

  3. Based on the MDN documentation, the 400 Bad Request status code is indeed what should be returned for malformed request syntax.

  4. Whether the status code would be a 406or a 404, this is not the behaviour we want. This tells us that the issue is coming from how we validate the route parameter of our endpoint. Let's take a closer look and see how we can fix it!

Phase 2

The second phase of my investigation aimed at digging deeper into the inner workings of the framework's route constraints mechanism.

After reading more on the subject of enforcing types directly in route templates, I found this:

Don't use constraints for input validation. If constraints are used for input validation, invalid input results in a 404 Not Found response. Invalid input should produce a 400 Bad Request with an appropriate error message. Route constraints are used to disambiguate similar routes, not to validate the inputs for a particular route.

Route constraints generally inspect the route value associated via the route template and make a true or false decision about whether the value is acceptable.

Source: official Microsoft documentation.

This validated my suspicion that our route parameter validation implementation was incorrect. Route constraints should only be used to differentiate between similar action methods/paths and NOT for input validation.

[HttpGet("{id:guid}/discovery", Name = nameof (GetResource))]

Examples of similar routes that need route constraints for the routing middleware to map the request correctly:

[HttpGet("{bookId}")
public ActionResult GetBookDetails(int bookId) {}

[HttpGet("{bookTitle}")
public ActionResult GetBookDetails(string bookTitle) {}

In this sample code, the routing middleware gets confused and doesn't know what action method to invoke. The solution is to add route constraints:

[HttpGet("{bookId:int}")
public ActionResult GetBookDetails(int bookId) {}

[HttpGet("{bookTitle:string}")
public ActionResult GetBookDetails(string bookTitle) {}

Basic type validation is performed in the background by .Net, by validating the type against the type declaration from the method parameter definition, like so:

public ActionResult<ResponseDto> GetResource(Guid id){}

❗️ Note that this is only basic type validation. In this specific case, it does not check for empty (default) GUID value. Further validation should be performed at the DTO level with custom attributes.

Solution

With these clarifications out of the way, my first step toward fixing the problem was simply to remove the GUID route constraint. This action immediately resolved the issue.

[HttpGet("{id}/discovery", Name = nameof (GetResource))]
public ActionResult<ResponseDto> GetResource(Guid id)
{
    return new ResponseDto();
}

With this implementation, a 400 - Bad Request status code is now properly sent back to the client in the event of an incorrect type being passed in the id path parameter.

💡This endpoint was tested with thorough unit test cases.

However, at this point, one issue remains: If we wanted to properly use route constraints to differentiate similar action methods, the client would still receive a 406 - Not Acceptable response if no match is found for a route. As we saw previously, the proper response for this use case should be the default .Net Core response of sending back a 404 - Not Found status code.

Based on our technical requirements and priorities, I didn't get to continue the investigations as this next issue was out of scope. However, to ensure that it doesn't go unnoticed in the future, I took a proactive approach and added it to our technical debt list. While I did not have a definite answer on the root cause of the issue, I made an educated guess and documented it for future reference. It's crucial to understand that an API's behaviour is shaped by various factors, including the implementation and design choices of the project's dependencies, and there may be valid reasons in this case for overriding the default .Net behaviour and sending back a 406 status code. It was important to me to make sure that we were all aware of this potential issue and have it documented so that we can address it together as a team if it comes up in the future.

Lessons Learned

During this debugging experience, I encountered several roadblocks and learned many valuable lessons along the way.

One of the most valuable lessons I learned was to not make assumptions and thoroughly investigate the problem before jumping to conclusions. This can also mean always questioning what you know first! In the case of the 406/400 issue, it would have been easy to assume that the bug was related to a typo or some other external factor, without taking the time to fully investigate the issue. So, even though I deal with status codes every day, my first thought was to review their meaning anyway to make sure I was not missing anything important in the definition subtleties. With this approach, I was able to discover the root cause of the issue and avoid wasting time and resources on unnecessary debugging or troubleshooting. This, I think, is an important lesson for any developer to learn, as it can help prevent a lot of frustrating and wasted effort down the road.

In the same train of thought, I learned when to ask for help and seek out resources when faced with so many possibilities to explore. It's easy to go down rabbit holes because, more often than not, one problem will uncover many other underlying problems you didn't even know existed in the first place. Presenting to your teammates your possible avenues of investigation and asking for some guidance early on and frequently is a great skill to leverage.

Finally, this debugging experience has taught me the importance of recognizing when to stop and asking myself “Should I continue down this path?” As you progress in your career, you'll realize that the most effective programmers don't always have a one-size-fits-all magical answer for when to stop investigating a problem. Instead, they understand that the right moment to stop will vary depending on the situation. The key skill to develop here is the ability to question often whether it's worth investing more time and resources in something, based on the business and technical requirements. As demonstrated in the solution section, although I was unable to resolve every issue, I made a judgment call on when to stop and move on to other tasks.

Conclusion

Every debugging challenge I face is an opportunity for me to improve my problem-solving skills and shape my problem-solving framework. Over time, this framework becomes more refined and easier to apply when tackling new problems. By repeatedly going through the steps of gathering information, being methodical and always questioning what I know first (even for seemingly simple issues), discussing the possible avenues of investigation with my team, and questioning often whether it's time to stop investigating, I have developed a reusable framework of important habits and steps to take when faced with a coding problem. Not only this framework helps me to solve problems more efficiently, but it also allows me to approach new problems with greater confidence.

To sum up, debugging skills are an essential part of software development, and it's important to continue sharpening these skills to become a better problem-solver. I encourage all readers to share their own debugging stories and engage with the community to learn new strategies and approaches to solving coding problems. Every challenge can be an opportunity for growth and development, so just remember to always be curious, stay focused, and never give up when faced with a problem.

Happy debugging!