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.
Top comments (6)
What about?
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. :)
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/
Definitely good arguments to be made in favour of that, but somewhat outside of the scope of this post. 😀
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.
I've used EchoAPI for API design and testing, and its user-friendly interface combined with a robust feature set has made the process much more efficient. These tools simplify API development, making them accessible even for beginners. Additionally, the ability to share API collections enhances team collaboration, leading to a smoother and more coordinated workflow.