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:
- Explain what went wrong.
- Explain what you can do about it.
- 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:
- The destination process has crashed and is starting back up.
- A node between you and the destination has gone bad and isn't forwarding any packets.
- 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 passhttp.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.