Transiting API Boundaries

Published
2019-12-01
Last modified
2019-12-01

The context package was added to Go in 2014. It's generally agreed that context is kind of ugly and unwieldy, but it solves some practical problems.

One of the problems that context solves is cancellation. While the Go language makes concurrency easy with goroutines and channels, if you want to communicate with a goroutine to cancel it, before context, you would have had to implement it yourself. It's not hard to do, but making sure that cancellation propagates properly is a chore.

The other thing that context solves is transiting API boundaries. Arbitrary values can be attached to contexts, and at first glance this seems like a incomprehensible, horrible thing to do. Why use this instead of passing in regular arguments to a function?

Go's http package provides a classic example. http.Handler is an interface containing the method:

ServeHTTP(ResponseWriter, *Request)

Anything that implements this method can be used to easily run an HTTP server. Since Handler is an interface, it's easy to write middleware, wrappers that take a Handler and return a Handler, like the standard StripPrefix function. You can write middleware that makes sure the user is logged in and checks whether the user has the right authorization to access a certain page.

http.ListenAndServe(":80", logInUser(checkIfAdmin(myHandler))

But wait! How do you pass the user credentials from the logInUser Handler to the checkIfAdmin Handler? The signature of the ServeHTTP method cannot be changed.

You could define your own private interface:

type myHandler interface {
	http.Handler
	checkCredentials(userCredentials) bool
}

func logInUser(h myHandler) Handler {...}
func checkIfAdmin(h Handler) myHandler {...}

But what if you want to add middleware between these two functions?

http.ListenAndServe(":80", logInUser(
	somepackage.CanonicalizePath(checkIfAdmin(myHandler))

There's no way to make somepackage understand your custom interface! This is what it means to transit API boundaries, where you have one API that goes "through" another API.

my API -> other API -> my API

By using values attached to a context, you can pass arguments through the middle API.

This is not a new problem or solution. Richard Stallman discussed this exact problem in his 1981 ACM paper on Emacs, where he justifies using dynamic scoping in Emacs. In fact, context values in Go are like dynamically scoped variables in a different shape (the "scope" is the context object which is determined at runtime for each call).

Another example of transiting APIs in Go are wrapped error values. Go 1.13 introduced a standard way for wrapping and unwrapping errors. This is primarily useful for making sure errors can safely transit different APIs. Consider a retry helper function:

myFunction := func() error {...}
err := retry.Retry(myFunction)
if err == myErr {...}

If the retry package were to add context to any errors without a standard wrapping convention, it would irreversibly destroy any error that your myFunction API might return, but with Go 1.13's error wrapping, we can allow any custom error values to transit the retry package's API:

myFunction := func() error {...}
// This error value is coming from my API, going through the retry package,
// and then back to my API.
err := retry.Retry(myFunction)
if errors.Is(err, myErr) {...}