DEV Community

Cover image for API Design: Errors
Sam Rose
Sam Rose

Posted on • Originally published at samwho.dev on

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.

Top comments (5)

Collapse
 
theodesp profile image
Theofanis Despoudis • Edited

What about?

func WithRetry(req *http.Request) (*http.Response, error)  {
    res, err := http.DefaultClient.Do(req) // or pass a client here
    if err != nil {
        if IsRetryable(err) {
            // retry
        } else {
            // bail
        }
    }
    return res, nil
}

func main() {
    req, err := http.NewRequest("GET", "https://example.com", nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err.Error())
        os.Exit(1)
    }
    res, err := WithRetry(req)
    ...
}
Collapse
 
samwho profile image
Sam Rose • Edited

That looks perfectly reasonable. I was mostly avoiding Go's actual "net/http" package. Not for any particular reason, it's a nice API, just wanted a slightly different approach. :)

Collapse
 
theodesp profile image
Theofanis Despoudis • Edited

I think retries and traffic management flow should be left on the service mesh level.
There is a good read here

istio.io/docs/concepts/traffic-man...

I would also love to see something like that in Go
thepollyproject.org/

Thread Thread
 
samwho profile image
Sam Rose

Definitely good arguments to be made in favour of that, but somewhat outside of the scope of this post. 😀

Collapse
 
gklijs profile image
Gerard Klijs

Rust seems to be pretty similar to go. Most libraries define there 'own' error messages. I have a small library where I also added whether it was retriable (and if the error was cashed). The rust way is to return a result, witch either contains a value or an error. It's up to to user to handle possible error nicely, or not, in witch case the program will crash when there is an error.
In Clojure it's pretty common to just return nil when something went wrong. This is part of the language design, do unless Java where there is a high probability of a Null pointer exception, the result will probably just be that nothing happens. But if you want to, you can throw errors just like in Java. When you do get an error message in Clojure it's usually pretty vague, but work is done to improve on that.