API Integrations: Client Classes and Error Handling

The way API clients signal results and errors is critical to integration quality, especially for under-documented APIs.

The previous article introduced the concept of the client class – the class implementing all interactions with a specific third-party API. A dedicated class is a step in the right direction, but is insufficient to guarantee quality unless properly implemented.

In this and following articles, we’ll discuss how to build robust client classes. Let’s start with the problem of error handling.

Problem Statement

A robust API integration must be prepared to handle errors that absent from integrating with own code. For example:

  • Unavailability due to network or service disruption.
  • Unexpected results or errors as there’s much more things that can go wrong: wrong HTTP headers, wrong encoding, invalid payloads and many others.

Consequently, error handling and recovery is central to a quality API integration. We’ll cover four approaches, ranging from familiar (exceptions) to exotic (result monads).

Method 1: Exceptions

The most familiar approach is using a return value on success and raising an exception on error. This behavior is prevalent in Ruby and other languages. Simplicity and familiarity are its advantages:

  • Everyone knows how to raise exceptions, which makes the client class code easy to read and write.
  • Everyone knows how to rescue exceptions, which makes error handling code easy to write (but not necessarily read).
  • Unhandled errors cannot go unnoticed, as unhandled exceptions will result in a server error.

Regrettably, there are subtle downsides:

  • Exception-based control flow is non-idiomatic and less readable.
  • Exceptions caused by API errors and internal client class bugs are lumped together; distinguishing them requires extra care.
  • When working in the console, exceptions caused by API errors must be rescued and assigned to a variable for further inspection.

The last point is especially bothersome when working with under-documented APIs. They require much more exploration and experimentation, so developer convenience becomes crucial.

When using this method, it makes sense to define a dedicated exception class for API errors to make it easier to distinguish them from client class bugs. For example, the What Weather API from the previous article could define WhatWeather::ServiceError and use it like in the snippet below:

def show
  begin
    weather = $weather.find_by_name(params[:name])
  rescue WhatWeather::ServiceError
    # If the API returns an error then return a 200 and an error message.
    # Exceptions unrelated to the API will propagate up the stack and likely
    # cause an internal server error (as they should!).
    render "unavailable"
    return
  end

  # Happy path continues here
end

This brings us to the next method which addresses these drawbacks elegantly and idiomatically.

Method 2: Tuples and Pattern Matching

The Go-inspired alternative is to return [:ok, result] on success and [:error, details] on error, with exceptions left for truly exceptions situations, like inconsistent internal client state.

This method coupled with pattern matching, which landed in Ruby 2.7, results in idiomatic and readable control flow. The snippet below calls the imaginary What Weather API and uses pattern matching to decide what to do next:

def show
  case $weather.find_by_name(params[:name])
  in [:ok, result]
    @weather = result.current_weather
  in [:error, :timeout] | [:error, :unavailable]
    render "unavailable"
    return
  end

  # Continue with the happy path.
end

Flow control for successes and errors is unified and it’s still possible to raise an exception when needed. Let’s discuss two concerns related to the method, followed by its drawbacks.

First, if the client return value matches no cases then NoMatchingPatternError will be raised. It’s helpful in identifying all errors occurring in the wild, but may be too radical. If that’s the case then a generic [:error, _] catch-all pattern and a log statement with error details should be suffice.

Second, raising an exception on API errors may sometimes be preferable. In those cases, the client class can offer bang methods, like #find_by_name!, that call the non-bang version under the hood but raise on error.

Evidently, all drawbacks of method 1 were address but there’s a new one: if an API call isn’t wrapped in case then execution will continue along the happy path even upon errors. Fortunately, this problem can be solved with static code analysis which leads us to the third method.

Method 3: Tuples, Pattern Matching, and Linting

Given the API client is available via a global variable, it’s likely that most, if not all, API calls will be made through that variable. This makes it easy for a custom linter rule to inspect all method calls on that global and ensure they’re wrapped in case.

The linter could work like that: a configuration setting lists all global variables to check. Whenever a method is called with a global from the list as the recipient then the linter should ensure it’s inside a case statement.

If building a custom linter rule is infeasible (thought, it’s not as difficult as it sounds) then a makeshift approach based on grep may be good enough. For example, detecting all method calls on $weather that aren’t wrapped in case could be accomplished with grep -r '$weather.' . | grep -v 'case $weather'.

Method 4: Result Monads

Monads deserve a separate article, so we’ll describe them briefly and use dry-monads in examples that follow.

A result monad resembles a polymorphic implementation of booleans with a more sophisticated interface. There are two types of results: successes and failures. A success wraps the result of a computation (an API call in our case); a failure wraps an error description. A result can be transformed into another result via a method called #bind. When it’s called on a successful result, it passes the wrapped value to a block and returns another result (a success or failure). When it’s called on a failure then it does nothing and lets that failure propagate forward. There’s similarity to short-circuit logic of boolean operators.

Result moands shine when composition is required, so let’s enhance our example controller action: it geolocates us via the $geolocation monad-based service and then returns the weather at our location. A monad-based version of the weather API client class could look like in the following snippet:

class WeatherService::WhatWeather
  # Required to access monad-specific features.
  extend Dry::Monads[:result]

  def find_by_name(name)
    result = http_client.get(
      "/api/weather",
      params: { name: name },
      headers: { "X-API-Key": api_key }
    )

    if result.status == 200
      # Build the weather object and wrap it in a success monad.
      Success(build_weather(result))
    else
      # Extract error details from the response and wrap it in a failure monad.
      Failure(result.json)
    end
  end
end

The controller action would use $geolocation.locate_request followed by $weather.find_by_location. Both methods return result monads that can be combined using #bind:

def show
  weather =
    # locate_request returns either a success or failure.
    $geolocation.locate_request(request).bind do |location|
      # #bind on a success unwraps the value and processes them further via this
      # block. $weather.find_by_location returns another result which becomes
      # the result of the whole computation.
      #
      # However, if geolocation fails then #bind will be called on a failure and
      # would NOT call this block. It'd simply return the original failure.
      $weather.find_by_location(location)
    end

  # weather is a result monad, NOT the actual value.
  if weather.success?
    # Forcibly extract the weather value.
    @weather = weather.value!
  else
    render "unavailable"
  end
end

Monads may look unfamiliar and may require some time to learn. However, frequent composition of operations (not only third-party API calls) can make them a worthwhile investment.

Conclusion

We’ve discussed four different approaches to error handling. Methods 3 and 4 may be my personal favorites, but the real point is to broaden the inventory of techniques at our disposal, so that we’re capable of making the right choice after taking the project state, goals, and team into consideration.

Enjoyed the article? Follow me on Twitter!

I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.

Leave your email to receive updates about articles.