"Go errors suck" ... Do this instead
Moving errors towards main and earlier in the execution lifecycle
Here at Elegant Engineer we love patterns which deliver far superior properties despite being similar difficulty to implement. This is a way to maximize velocity both in the short run and across the medium term (intra-quarterly) compounding our advantages.
Read on if you want to learn a pattern that:
Simplifies code such that portions cannot return errors
Improves reusability of components for future work
Reduces the number of tests needed
Highlights broken code earlier in the software development cycle, rather than amidst handling hot paths.
One gripe I’ve heard is that Go’s error handling can get very verbose, particularly requiring too many if err != nil
checks. Teams can also fall into traps of repeated log lines as the error moves up the stack, and implementation choices may require more costly integration/E2E tests to discover issues. It’s an easy trap to fall into. Read on to see how we can clean it up.
When building out components, we’re often faced with choices with how to structure out components, including choices that affect all stack depth. The deeper our stack goes, the more we proliferate the necessity to check for errors from the encapsulated code, obfuscate the source and hence create log lines for visibility into the application. As well, we may not invoke said error
producing code until much later in the lifecycle of our systems. Code can easily devolve into something like the example below… Note the necessity to return errors due to nested implementation, and the proliferation of log lines.
// Nested implementation
func main() {
val, err := doStuff()
if err != nil {
log.Println("[ERROR]: error doStuff -", err.Error())
}
log.Println("[INFO]:", val)
}
// Note doStuff only returns an error because getStuff does...
func doStuff() (int, error) {
stuff, err := getStuff()
if err != nil {
log.Println("[ERROR]: error getStuff -", err.Error())
}
var count int
for range stuff {
count++
}
return count, nil
}
// Here's the error returning function
func getStuff() ([]int, error) {
if rand.Int()%2 == 0 {
log.Println("[ERROR]: error in getStuff -", err.Error())
return nil, fmt.Errorf("random error")
}
return []int{1, 2, 3}, nil
}
Flatten the call stack to isolate error producing code
By shifting the errors up the call stack we can eliminate redundant if err != nil
blocks, log lines, and tests due to reduced cyclomatic complexity. Try to think about it like a flat recipe of steps, rather than a fractal nesting of functionality. Flattening out the calls of doStuff
and getStuff
yields code as follows
func main() {
// We move the error aspects up the stack
work, err := getStuff()
if err != nil {
log.Println("[ERROR]: error getStuff -", err.Error())
}
val := doStuff()
log.Println("[INFO]:", val)
}
// Note this no longer can error, nor log an error, and requires less testing for full coverage
func doStuff(work []int) int {
var count int
for range work {
count++
}
return count
}
func getStuff() ([]int, error) {
if rand.Int()%2 == 0 {
return nil, fmt.Errorf("random error")
}
return []int{1, 2, 3}, nil
}
Ok, so the implementation is a few lines shorter, nice. But let’s look at what else we we get… doStuff
becomes a pure function that can more easily be tested, used for other purposes, and cannot return an error. We reduced double logging the error for call stack visibility.
It’s a trivial example, so it may seem the benefits are trivial. Let’s apply this same concept to an HTTP server and see what more we can squeeze from such a small change? Spoiler: We can shift when we find out about an error from crucial HTTP request time to at application startup.
Move error producing code earlier in the application
// Two implementations of the same upload functionality
func main() {
// Create session at Request Time - We don't discover errors until there's an HTTP request made
http.HandleFunc("/upload", upload)
// Create session before server startup – catch errors before server startup
sess, err := session.NewSession()
if err != nil {
log.Fatalf("[ERROR]: NewSession failed before server startup %s", err.Error())
return
}
http.HandleFunc("/uploadSafer", uploadWithComponent(NewUploadComponent(sess)))
log.Println("HTTP Server Listening on :8090")
http.ListenAndServe(":8090", nil)
}
// upload attempts to create the session within the handler, meaning it could error here _during_ an active http request
func upload(w http.ResponseWriter, req *http.Request) {
sess, err := session.NewSession()
// We don't find out about the error until a request is being handled
if err != nil {
log.Printf("[ERROR]: NewSession failed %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "[ERROR]: NewSession failed %s", err.Error())
return
}
rc := NewUploadComponent(sess)
_, err = rc.Upload(&s3manager.UploadInput{Body: req.Body})
if err != nil {
log.Printf("[ERROR]: Upload failed %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "[ERROR]: Upload failed %s", err.Error())
return
}
// HTTP 200
fmt.Fprintf(w, "received\n")
}
func uploadWithComponent(rc *UploadComponent) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
_, err := rc.Upload(&s3manager.UploadInput{Body: req.Body})
if err != nil {
log.Printf("[ERROR]: Upload failed %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "[ERROR]: Upload failed %s", err.Error())
return
}
// HTTP 200
fmt.Fprintf(w, "received\n")
}
}
type UploadComponent struct {
*s3manager.Uploader
}
func NewUploadComponent(sess *session.Session) *UploadComponent {
return &UploadComponent{
Uploader: s3manager.NewUploader(sess),
}
}
In this case, by moving error prone parts towards main
we can discover issues with the session at server startup, rather than at HTTP request time (repeatedly). Our deploy tools can ensure we do not promote these broken systems, rather than finding out after the fact due to monitoring/alerting. We don’t even need a Canary or tests system because the containers will never become healthy.