Tuesday, August 27, 2019

Backwards Compatibility: Techniques for HTTP APIs

Like most web developers, I do a lot of coordination between servers and clients. The most obvious interaction is between a customer's browser and my servers, but I also see a good amount of server-to-server communication.

Changes in distributed systems don't always get rolled out at the same time. Even inside a single "service", if you run multiple copies of that service for redundancy or high availability you have a distributed system and changes you make will not be applied simultaneously. If you offer an API, you don't have any control over when your clients upgrade their systems, and you probably don't want to break them.

I've found a few techniques for handling compatibility between systems during upgrades that I hope will give you some ideas for approaching the problem in your own work. Please don't consider them exhaustive - there are as many variations on this subject as there are systems. My work currently focuses on APIs, browsers, HTTP clients, and databases, so that's what I'm going to talk about.

Today's topic is HTTP APIs. Generally people want to consume them and get the any new features you make available: clients often have an interest in upgrading. So, you can often just worry about not breaking your clients without them knowing. With that in mind, lets explore how to make both non-breaking and breaking changes without causing your clients too much pain.

Non-breaking Changes 

Most of the time, you want to evolve your API without forcing your clients to implement your changes right away. These are non-breaking changes - your clients may upgrade to support them in the near future, but they won't stop working when you deploy the change.

Since we're talking about HTTP APIs, changes break down pretty easily to adding fields and adding or changing endpoints. This even holds true in non-RESTy environments, but frameworks like GraphQL probably give you an easier path to doing upgrades. I haven't used them, so I'm just commenting on REST-style environments. I'd love to hear how people handle this elsewhere!


Adding an endpoint

You've got one endpoint, say /users/{userId}. You want to add a /blogs path. That's completely safe - if no one's using it, you don't have to worry about breaking them! Clients can start using the new endpoint whenever it's convenient.

Adding a field 

Most JSON and XML parsers tolerate new fields well, or can be configured to do so. Unfortunately, you don't really get a say here - you have to define up front that clients need to tolerate unexpected fields and if you have one super-important client that can't, you'll just have to use other techniques. If they can,, though, you can add new fields as in the JSON example all you want and no one will break.

{
  "originalField": "value",
  "newField": "value",
  "addedAYearLater": "value"
}

However, you can't do this with lists. For example, you could define a Users endpoint as:

GET /users
[
  { "userId": 1, "username": "bob"},
  { "userId": 2, "username": "sally"}
]

...but then you'd only be able to add elements to the list or fields to the User objects themselves. It's often better for future work to return an object, and when you need to maybe add paging you don't need to break everyone to do it:

{
  "users": [
    { "userId": 1, "username": "bob"},
    { "userId": 2, "username": "sally"}
  ] ,
  "offset": 10
}

Breaking Changes

Sometimes you just have to change a system. Maybe you need a new naming scheme to better support new clients, or you want to change technologies. Either way, your clients will have to change. You don't have to force them to change in lockstep with you though. Here's a few ways to do it.

Changing a response type

Sometimes we get the structure of a response wrong. Returning a list and later needing an object as in "Adding a field" is a good example.

In my opinion, you should never change the structure of a given endpoint once published, like you you should never rebase a commit published to another Git repository. It really messes with your consumers and usually breaks them until they change their code to match your new output. Here's a few options you can do instead:


  1. Implement a new endpoint that returns the desired structure, and deprecate the original until the clients have had a chance to update
  2. (Variation on (1)) Version your endpoints, in the path (/v1/users, /v2/users) or perhaps using a header. I would recommend against using query parameters, because in the abstract you'd be mixing filters with response structure and that can be confusing. It may be an option if your clients are limited to paths and query parameters, though.
  3. If the data is identical but the client needs a different structure, you might use the Accept header for a content-negiotiating strategy. This is perfect for letting clients choose between formats like XML and JSON, but it's also great for clients that need some metadata on the response that, for others, would be just noise and bandwidth.

Renaming a field

Naming is hard, 'nuff said. You should approach this differently depending on if you control the clients or not.

If you control the clients, you can do a three-step upgrade.
  1. Add an identical field with your new, improved name. Do not remove the original yet.
  2. Upgrade all your clients to use the new name.
  3. Remove the original field once there's no one using it.
If you don't control the clients, I'd look really hard at why you need to change the name. You can deprecate the original and remove it after a reasonable amount of time, or...not, if people keep using it. It might be a better idea to leave the name alone and improve your documentation to compensate, and avoid the coordination.

You can also let a bunch of name changes stack up for a while, and deprecate and change them in a single batch. That would give your clients higher stability - for example, you might make changes on a 6-month schedule, adding the new names and deprecating the old in the first release and removing the old names in the next.


Wrapping up

Different frameworks and API styles have different ways of approaching compatibility - explore your tools and see which ways they can help you support changes without breaking your clients. For example, in the Java world Jersey abstracts out media types, which makes it really easy to support XML, JSON, or pretty much any structure you want. Maybe your framework lets you version endpoints without much effort, so you favor that strategy over relying on clients ignoring new fields.

Whatever you choose, think about what your clients need and evolve you API in a way that lets them evolve along with you.