API Design: Errors
samwho keyboard logo

API Design: Errors

Errors are one of the easiest things to overlook when creating an API. Your users will have problems from time to time, and an error is the first thing they're going to see when they do. It's worth spending time on them to make using your API a more pleasant experience.

# Guiding Principles

A good error message should do the following:

  1. Explain what went wrong.
  2. Explain what you can do about it.
  3. Be easy to isolate and handle if it's a recoverable error.

# Case study

We're going to re-use our HTTP client case study from the previous post, the API surface of which looks like this:

package http

import (
  "io"
)

type Response struct {
  StatusCode int
  Headers map[string]string
  Body io.ReadCloser
}

type options struct {}
type Option = func(*options)

var (
  FollowRedirects = func(o *options) {}
  Header = func(key, value string) func(*options) {}
)

func Get(url string, options ...Option) (Response, error) {}

And here's a realistic example of what calling it would look like:

package main

import (
  "fmt"
  "http"
  "io"
  "os"
)

func main() {
  res, err := http.Get("https://example.com", http.FollowRedirects, http.Header("Accept-Encoding", "gzip"))
  if err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
  }

  if res.StatusCode != 200 {
    fmt.Fprintf(os.Stderr, "non-200 status code: %v\n", res.StatusCode)
    os.Exit(1)
  }

  _, err := io.Copy(os.Stdout, res.Body)
  if err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
  }

  if err := res.Body.Close(); err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
    }
}

I want to make clear that choosing Go for this is irrelevant. The principles we're going to talk about apply to most languages.

# Helpful error messages

One of the first distinctions you need to make when returning an error is whether the caller can do anything about it.

Take network errors as an example. The following are all part of normal operation for network-connected programs:

  1. The destination process has crashed and is starting back up.
  2. A node between you and the destination has gone bad and isn't forwarding any packets.
  3. The destination process is overloaded and is rate limiting clients to aid recovery.

Here's what I would like to see when one of the above happens:

HTTP GET request to https://example.com failed with error: connection refused, ECONNREFUSED. Run man 2 connect for more information. You can pass http.NumRetries(int) as an option, but keep in mind that retrying too much can get you rate limited or blacklisted from some sites.

This saves me some digging online, a trip to the documentation, and a slap on the wrist by an angry webmaster or automated rate limiter. It hits points 1 and 2 from our guiding principles really well.

This isn't what you would want to show to the end-user, though. We'll need to give the API user the tools to either handle the error (like offering them the option to retry), or show the end-user a nice error message.

# Different types of error

Different languages have different best practices for separating out types of error. We'll look at Go, Java, Ruby and Python.

# Go

The idiomatic way of doing this in Go is to export functions in your API that can check properties of the error thrown.

package http

type httpError struct {
  retryable bool
}

func IsRetryable(err error) bool {
  httpError, ok := err.(httpError)
  return ok && httpError.retryable
}

And using it:

package main

func main() {
  res, err := http.Get("https://example.com")
  if err != nil {
    if http.IsRetryable(err) {
      // retry
    } else {
      // bail
    }
  }

This idea extends to any property the error might have. Anything you think the API user might want to make a decision about should be exposed in this way.

# Java

The mechanism for doing this in Java is a little more clear: custom exception types.

public class HttpException extends Exception {
  private final boolean retryable;

  private HttpException(String msg, Throwable cause, boolean retryable) {
    super(msg, cause);
    this.retryable = retryable;
  }

  public boolean isRetryable() {
    return this.retryable;
  }
}

And using it:

public final class Main {
  public static void main(String... args) {
    Response res;
    try {
      res = Http.get("https://example.com");
    } catch (HttpException e) {
      if (e.isRetryable()) {
        // retry
      } else {
        // bail
      }
    }
  }
}

# Python

The story is similar in Python.

class Error(Exception):
  pass

class HttpError(Error):
  def __init__(self, message, retryable):
    self.message = message
    self.retryable = retryable

  def is_retryable(self):
    return self.retryable

And using it:

try:
  res = Http.get("https://example.com")
except HttpError as err:
  if err.is_retryable():
    # retry
  else:
    # bail

Writing a generic Error class that extends from Exception is common practice when writing Python libraries. It allows users of your API to write catch-all error handling should they wish.

# Ruby

And again in Ruby.

class HttpError < StandardError
  def initialize message, retryable
    @retryable = retryable
    super(message)
  end

  def is_retryable
    @retryable
  end
end

And using it:

begin
  res = Http.get("https://example.com")
rescue HttpError => e
  if e.is_retryable
    # retry
  else
    # bail

Pretty much identical to Python.

# Conclusion

Don't neglect your error messages. They're often the first contact users have with your writing, and if it sucks they're going to get frustrated.

You want to give users of your code the flexibility to handle errors in whatever way makes the most sense to them. Give them as much information about the situation as you can using the methods above.

I wanted to, but didn't, touch on Rust and functional languages. Their methods of error handling are significantly different to the above. If you know of good patterns in other languages, I've love to hear about them in the comments.

powered by buttondown