Keeping API Errors Clean – Square Corner Blog – Medium


At Caviar, we build a good number of APIs. We build APIs for our internal service-to-service communication, APIs for our mobile apps to consume, APIs for our web apps to consume, APIs for external partners, and probably a couple of APIs that I’m even forgetting about. One of the things we’ve learned and put into practice over the last couple of years is well-structured errors, which enable us to have consistent behavior with meaningful errors on the client side.

We started with the convention of just defining errors as a structured hash, following the pattern

{ "type": "SomeError", "message": "Something went wrong!" }

And, we would then render those errors in our Rails controllers as JSON.

class CartsController < ApplicationController
def show
errors, cart = CartRepository.new.fetch_cart(params.require(:id))
if errors.none?
render json: { content: cart }
else
render json: { errors: errors }, status: 422
end
end
end

And while this structure is good, we applied it inconsistently, often just returning an array of strings as the errors instead of having these structured errors. So, to increase adoption and to make errors feel good to use, we started to define these objects as Structs using Ruby 2.5’s new keyword_init arguments.

ValidationError = Struct.new(:type, :message, keyword_init: true)

Then we would use this new struct inline to make the creation and reading of errors clean. One important thing to note is that the activesupport/json gem makes the serialization of Structs to JSON perform exactly as you would hope, and so it will output the above structure.

def fetch_cart(id)
cart = Cart.find_by(id: id)
return [[ValidationError.new(type: 'ResourceNotFound', message: 'Cart not found')], nil] if cart.nil?
[[], cart]
end

As you can see, that can make the error defining lines quite long, and these errors were always the same, so we can extract them to constants.

CART_NOT_FOUND_ERROR = ValidationError.new(type: 'ResourceNotFound', message: 'Cart not found')
def fetch_cart(id)
cart = Cart.find_by(id: id)
return [[CART_NOT_FOUND_ERROR], nil] if cart.nil?
[nil, cart]
end

And now our errors were well-structured, easy to define, and—bonus!—super easy to test. Here is a spec for what this might look like:

RSpec.define CartsController do
it 'should return resource not found error when id does not exist' do
get :show, id: -1
expected_value = {
errors: [CartRepository::CART_NOT_FOUND_ERROR]
}
expect(response.body).to eq(expected_value.to_json)
end
end

Now we can write these tests quickly and without futzing around with message bodies. It makes our tests less brittle and improves developer productivity, all while enforcing a standardized error format.

The last step we’ve taken to date is to extract this into a utility gem and add a couple extra features. The gem is called clean_errors and we’re releasing it publicly today. Here is how it looks:

# No need to define ValidationError anymore, its in the gem
# ValidationError = Struct.new(:type, :message, keyword_init: true)
CART_NOT_FOUND_ERROR = CleanErrors::ValidationError('ResourceNotFound')

You’ll notice a couple of things right off the bat. There are no keyword arguments, there is no .new (did I make a typo?!), and there’s no message!

First, on the lack of a .new, we’re using a conversion method to shorten up your usage and get rid of a bit of boilerplate. Less code means less errors, and the conversion function lets us do a another nifty thing.

There is no message! Well, there is, but the conversion method is now going to use i18n localizations to look up the message. We did this because:

  1. having lots of strings in our source code felt wrong, and
  2. we want to future proof our service for international audiences.

It’s generally a good practice to use i18n for such strings, but error messages were an area where we were lax. Now we enforce it. But, what is the key? It is a simple “errors.#{type}”with the “errors” namespace being configurable.

Finally, we shortened off the keyword arguments. Usually I like the verbose, explicit nature of keyword arguments in dynamic languages like Ruby, but in this case we’re going for brevity. The consistent use of these throughout the codebase should act as its own documentation, and the API is so clean that I think it’s worth trading off that safety a bit.

That’s it! We use clean_errors at Caviar as a simple utility to make a big impact. Consistent error formatting across our APIs mean that clients can expect errors in a particular format and these utilities make it easier for developers to produce those consistent errors.



Source link