Custom Error Handling in Go: Enhancing Debugging with BugSnag

kanmo
4 min readAug 28, 2024

--

Error handling in Go is distinctive and different from other languages. When we first started developing a Go application, we struggled to find the best way to handle errors in our project.

The following aspects of Go’s error handling particularly challenged us:

What information is used for error handling?

In Go, when a function or method call results in an error, the caller is required to check the error.

_, err := ExecuteSomething()
if err != nil {
// check error and do something
}

Unlike try-catch, where you can catch errors only where necessary, Go requires application developers to explicitly handle errors every time, reducing the likelihood of overlooked errors. However, as we continued developing the application, we encountered the following question:

  • What information should be attached to an error and returned to the caller when an error occurs?
  • Is it better to use type information for error handling, or should other information be used? Given that most applications have numerous application-specific errors, defining a unique error class for each one is not practical.

Stack trace Information

The default error class does not include stack traces. During service operation, we often use Bugsnag to monitor errors, but without stack trace information, it’s hard to immediately understand what went wrong just by looking at the dashboard.

Creating our own Error Class

In the early stages of the project, we created many unnecessary error classes due to inadequate error implementation. Additionally, we often handled errors using multiple pieces of information, leading to implementations full of copy-and-paste code. To resolve these issues,

we created a custom error class that meets the following requirements:

  • Contains application-specific error information and allows error handling based on that information.
  • Includes gRPC error code information (since our application uses gRPC).
  • Allows wrapping the error at the caller and creating a new error.
  • Outputs a stack trace that can be correctly displayed on the Bugsnag dashboard.

The fields of the error class are as follows:

type Error struct {
err error
messages []string
reason ErrorReason
shouldReport bool
code codes.Code
callers []uintptr
}
  • messages: A slice that holds error messages each time the error is wrapped.
  • reason: Holds application-specific information, intended to be defined in Protocol Buffers.
  • shouldReport: Specifies whether the error should be reported to BugSnag (since some errors can be ignored).
  • code: Sets the gRPC error code to respond to the client application. It is not set if gRPC is not used.
  • callers: Holds stack trace information. Stack trace generation is necessary when creating the error object.

The custom error object is generated as follows:

err := errors.New("Something went wrong")
err = werror.Wrap(err,
WithCode(codes.InvalidArgument),
WithReason(pb.ErrorReason_INVALID_REQUEST), // your application specific reason
)

Annotating Errors

As in the previous example, you can add application-specific error information to the error:

func WithReason(reason interface{}) Annotator {
if errReason, ok := reason.(ErrorReason); ok {
return func(err error) error {
if werr, ok := err.(*Error); ok {
werr.reason = errReason
return werr
}

werr := NewFromStandardError(err)
werr.reason = errReason

return WithCallers(1)(werr)
}
}

return func(err error) error {
return err
}
}

When making a function call, you can handle errors like this:

if err := doSomthing(); err != nil {
if werror.Reason(err) == pb.ErrorReason_INVALID_REQUEST {
// custom error handling
}
return err
}

Create Stack trace

To retain the call stack within the error information from the time the error occurs, the following implementation is used during error generation:

// WithCallers annotates an error with the stack trace.
// The offset parameter is used to identify the location where the error occurred.
func WithCallers(offset int) Annotator {
return func(err error) error {
if werr, ok := err.(*Error); ok {
if werr.callers != nil {
return werr
}

werr.callers = createCallers(offset + 1)
return werr
}

werr := NewFromStandardError(err)
werr.callers = createCallers(offset + 1)
return werr
}
}

func createCallers(offset int) []uintptr {
pcs := make([]uintptr, 100)
n := runtime.Callers(offset+2, pcs[:])
return pcs[:n]
}

BugSnag integration

To correctly display stack trace information on the Bugsnag dashboard, you must implement one of the following three interfaces:

  • errorWithStack
  • ErrorWithStackFrames
  • ErrorWithCallers

Our error class is implemented to satisfy the ErrorWithCallers interface:

type ErrorWithCallers interface {
Error() string
Callers() []uintptr
}

If you wrap an error directly when it occurs, the original error will not satisfy the above interface, and therefore will not be displayed correctly on the Bugsnag dashboard. To address this, the original error is processed as follows when creating the custom error object:

// NewFromStandardError creates a new *Error from the given error.
// If the error is not *Error, create a new *Error.
// we do not specify err field because bugsnag needs ErrorWithCallers interface.
func NewFromStandardError(err error) *Error {
return &Error{
cause: err,
messages: []string{err.Error()},
code: codes.Unknown,
reason: emptyReason{},
shouldReport: true,
}
}

With this, the stack trace will be correctly displayed on the Bugsnag dashboard.

Conclusion

In conclusion, error handling in Go requires a deliberate and thoughtful approach, especially when dealing with application-specific errors and ensuring visibility through tools like BugSnag.
By creating a custom error class that captures detailed information, including stack traces and error reasons, we can achieve more robust error management and simplify debugging.
While the initial setup might seem complex, the long-term benefits of clearer error reporting and more maintainable code make it a worthwhile investment for any Go application.

Github repository url: https://github.com/kanmo/werror

--

--

kanmo

I’ve been building robust backends for a decade, using Go and Elixir. But that’s not all there is to me - I’m also a dad of two who loves basketball.