Go errors are a story, most teams lose the plot
The case for logical traces #
Unlike many other languages, errors in Go do not contain stack traces. Instead, errors are typically wrapped with context as they propagate up through the system.
func greetUser(id int64) error {
user, err := getUser(id)
if err != nil {
return fmt.Errorf("unable to get user: %w", err)
}
fmt.Printf("Hi %s", user.name)
return nil
}
The error produced by the example above might look like this:
unable to get user: failed to query users table: connection refused
Compared to the stack trace that might get produced by a Java application with the same failure:
java.sql.SQLException: Connection refused: connect
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129)
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97)
at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:825)
at com.mysql.cj.jdbc.ConnectionImpl.<init>(ConnectionImpl.java:448)
at com.mysql.cj.jdbc.ConnectionImpl.getInstance(ConnectionImpl.java:241)
at com.mysql.cj.jdbc.NonRegisteringDriver.connect(NonRegisteringDriver.java:198)
at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:677)
at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:189)
at com.example.db.ConnectionPool.acquire(ConnectionPool.java:87)
at com.example.repository.UserRepository.findById(UserRepository.java:42)
at com.example.service.UserService.getUser(UserService.java:28)
at com.example.handler.GreetingHandler.greetUser(GreetingHandler.java:19)
at com.example.handler.GreetingHandler$$FastClassBySpringCGLIB$$abc123.invoke(<generated>)
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
... 47 more
Our Go example essentially builds a logical trace. When maintained well, it has two advantages over a traditional stack trace: it’s human-readable and it’s complete even across channels, goroutines, and other handoffs that a stack trace might lose.
Gaps in your story #
Maintaining this context, however, takes constant discipline from your team. Any lapse may make it difficult, if not impossible, to trace errors that surface. Take the error from the previous section: connection refused is all that’d be logged if the context had not been added.
And, like comments, the context may become outdated and misleading as your application develops. A wrap message written two years ago may no longer capture enough detail to figure out why the underlying error occurred.
In practice, most teams I work with fail to keep up with this maintenance burden (or even realise it exists). As a result, the errors their software emits in production do not contain sufficient information to resolve their cause or sometimes even determine what went wrong.
Traditional stack traces do not suffer from this. They do not need to be kept up to date, and for the thread where the error occurred, they’re guaranteed to be complete and exact.
The right approach for most teams #
Logical traces and stack traces serve the same purpose: attaching context to errors so they can be understood later. Logical traces give you richer context but only when there are no gaps, making them expensive. Stack traces, on the other hand, give you cheaper context automatically and reliably.
Although stack traces are not added to errors in Go by default, they’re quite simple to add yourself. This allows us to choose between the two alternatives. However, the question isn’t actually which to use, it’s how much of the expensive, context-rich kind your team can sustain. This leads me to advocate a middle-ground approach for most teams:
By adding stack traces whenever your application creates an error or first encounters one made by a third-party you ensure all errors in your Go application contain a stack trace with the least amount of work and cognitive load. Additional context can be added throughout when desired by the programmer, but is no longer crucial. The stack trace will always be there to fall back on.
Returning to our earlier example: a bare connection refused error is perfectly debuggable if it carries a stack trace pointing through getUser and greetUser. Developers can still add context like failed to query users table: connection refused, but this approach doesn’t create a burden to do so just to produce useful errors.
The concept is simple enough to not require reliance on any third-party library. That said, pkg/errors1 by Dave Cheney remains a well-documented implementation to achieve just this if you’d rather not roll your own.
Next steps #
Getting your errors right is half the job. The other half is surfacing that context in your logs. Read my next post to find out more.
This package is in maintenance mode since some of its features have been added to the standard library. However, it can be considered feature-complete and remains a great choice. If you prefer something actively maintained,
cockroachdb/errorsfollows similar patterns. ↩︎