Errors
A drop-in replacement for the errors standard library with some additional functionality.
go get sjdaws.com/pkg/errors
Using this package
Full documentation is available on go.dev.
Note: A PublicError has all the functionality of a TraceableError.
Standard functions
This package contains several pass-through functions to achieve compatibility with the errors standard library.
Compatible functions
This package also contains several functions that are backwards compatible with the errors standard library but behave differently.
New1 creates a new TraceableError instance.
TraceableError.Error returns an error message prepended with context as a string.
1 New has the same signature as fmt.Errorf allowing verbs to be formatted using operands.
Additional functions
This package also contains several functions that provide additional functionality.
Public wraps an error in a PublicError instance.
PublicError.Code returns the error code as an integer.
PublicError.Message returns all error messages as a string.
PublicError.Messages returns all error messages as a string slice.
TraceableError.Trace returns a stack trace for an error as a string.
TraceableError.Unwrap returns the previous error in an error stack.
TraceableError.Wrap wraps an error in another error with additional context.
Examples
- Traceable errors
- Public errors
Traceable errors
A lot of go code returns an error object as is:
value, err := function()
if err != nil {
return err
}
Most loggers will add context about where the error was logged, and panics will include context about where the panic occurred, however tracing the origin of an error can become difficult, especially as project complexity increases and errors are passed through many layers of an application.
This example will start with a very simple application that increases in complexity to illustrate how debugging the origin of an error can become difficult and how traceable errors can help.
Single function
Initially, the application has one function which panics.
Code
Run this code in the Go Playground
// main.go
package main
func main() {
panic("oh no")
}
Output
panic: oh no
goroutine 1 [running]:
main.main()
/go/main.go:5 +0x1
exit status 2
This is very easy to debug as the panic provides useful information, specifically:
main.main() points to the function main() within the main package
/go/main.go:5 points to line 5 in the file /go/main.go
There is only one step to debugging this panic:
- The panic occurred on line 5 because that is all the application does
Two functions
The application now has a second function which returns an error.
Code
Run this code in the Go Playground
// main.go
package main
import (
"fmt"
"strconv"
)
func main() {
integer, err := toInteger("A")
if err != nil {
panic(err)
}
fmt.Printf("Integer value: %d\n", integer)
}
func toInteger(value string) (int, error) {
integer, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return integer, nil
}
Output
panic: strconv.Atoi: parsing "A": invalid syntax
goroutine 1 [running]:
main.main()
/go/main.go:12 +0x1
exit status 2
Debugging this panic requires an extra step, but it is still very simple:
- The panic occurred on line 12 when the call to
toInteger on line 10 returned an error
toInteger returned an error on line 21 because the call to strconv.Atoi on line 19 returned an error
Multiple error origins
The application has changed so toInteger can handle a wider range of inputs, as a side effect a switch statement has been added to handles different types.
Code
Run this code in the Go Playground
// main.go
package main
import (
"fmt"
"strconv"
)
func main() {
integer, err := toInteger([]string{"A"})
if err != nil {
panic(err)
}
fmt.Printf("Integer value: %d\n", integer)
}
func toInteger(value any) (int, error) {
var integer int
var err error
switch (value).(type) {
case int:
integer = value.(int)
case []string:
integer, err = stringSliceToInteger(value.([]string))
case string:
integer, err = stringToInteger(value.(string))
default:
return 0, fmt.Errorf("unable to convert type '%T' to integer", value)
}
return integer, err
}
func stringToInteger(value string) (int, error) {
integer, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return integer, nil
}
func stringSliceToInteger(value []string) (int, error) {
integer, err := strconv.Atoi(value[0])
if err != nil {
return 0, err
}
return integer, nil
}
Output
panic: strconv.Atoi: parsing "A": invalid syntax
goroutine 1 [running]:
main.main()
/go/main.go:12 +0x1
exit status 2
Without debating the quality of the code, this is where debugging can start to become a bit more difficult:
- The panic occurred on line 12 when the call to
toInteger on line 10 returned an error
toInteger returned an error on line 37 because either the call to stringSliceToInteger on line 28 returned an error or the call to stringToInteger on line 31 returned an error
It's difficult to tell from the error message which function the error originated from, or what the original data in was.
Wrapping errors in context
The errors can be wrapped using this package to provide additional context along with the original error message.
Code
Run this code in the Go Playground
// main.go
package main
import (
"fmt"
"strconv"
"sjdaws.com/pkg/errors"
)
func main() {
integer, err := toInteger([]string{"A"})
if err != nil {
panic(err)
}
fmt.Printf("Integer value: %d\n", integer)
}
func toInteger(value any) (int, error) {
var integer int
var err error
switch (value).(type) {
case int:
integer = value.(int)
case []string:
integer, err = stringSliceToInteger(value.([]string))
case string:
integer, err = stringToInteger(value.(string))
default:
return 0, fmt.Errorf("unable to convert type '%T' to integer", value)
}
if err != nil {
return 0, errors.Wrap(err, "unable to convert value '%s' to integer", value)
}
return integer, err
}
func stringToInteger(value string) (int, error) {
integer, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return integer, nil
}
func stringSliceToInteger(value []string) (int, error) {
integer, err := strconv.Atoi(value[0])
if err != nil {
return 0, err
}
return integer, nil
}
Output
panic: unable to convert string slice '[A]' to integer: strconv.Atoi: parsing "A": invalid syntax
goroutine 1 [running]:
main.main()
/go/main.go:14 +0x1
exit status 2
The additional context in the error message unable to convert string slice '[A]' to integer: provides information about the data which was being converted. In this case the value was a slice and therefore the error was thrown from stringSliceToInteger.
Using this information, debugging is relatively straight forward again:
- The panic occurred on line 12 when the call to
toInteger on line 10 returned an error
toInteger returned an error on line 37 because the call to stringSliceToInteger on line 28 returned an error
stringSliceToInteger returned as error on line 58 because the call to strconv.Atoi on line 56 returned an error
Tracing wrapped errors
Invoking Error() on this package prefixes the original error message with the additional context added when the error occurred. While this is often enough to debug simple errors, it may not be sufficient for complex applications where an error can traverse many layers, especially if data is being manipulated along the way.
A wrapped error is like an onion, where the middle is the original error, and each wrapping adds an additional layer outside. Wrapping and re-wrapping errors as it makes its way back through the stack provides a much better insight on what caused the error in the first place. Wrapping essentially provides telemetry for logged errors.
Wrapped errors from this package have a Trace() function which will unwrap the error and print out the context added to each layer to assist with understanding the cause of the error and the path used to invoke it.
Code
Run this code in the Go Playground
// main.go
package main
import (
"fmt"
"strconv"
"sjdaws.com/pkg/errors"
)
func main() {
integer, err := toInteger([]string{"A"})
if err != nil {
if errors.As(err, &errors.TraceableError{}) {
panic(err.(errors.TraceableError).Trace())
}
panic(err)
}
fmt.Printf("Integer value: %d\n", integer)
}
func toInteger(value any) (int, error) {
var integer int
var err error
switch (value).(type) {
case int:
integer = value.(int)
case []string:
integer, err = stringSliceToInteger(value.([]string))
case string:
integer, err = stringToInteger(value.(string))
default:
return 0, fmt.Errorf("unable to convert type '%T' to integer", value)
}
if err != nil {
return 0, errors.Wrap(err, "unable to convert value '%s' to integer", value)
}
return integer, err
}
func stringToInteger(value string) (int, error) {
integer, err := strconv.Atoi(value)
if err != nil {
return 0, errors.Wrap(err, "unable to convert string '%s' to integer", value)
}
return integer, nil
}
func stringSliceToInteger(value []string) (int, error) {
integer, err := strconv.Atoi(value[0])
if err != nil {
return 0, errors.Wrap(err, "unable to convert string slice '%v' to integer", value)
}
return integer, nil
}
Output
panic: strconv.Atoi: parsing "A": invalid syntax
- /go/main.go:44: unable to convert value '[A]' to integer
- /go/main.go:62: unable to convert string slice '[A]' to integer
goroutine 1 [running]:
main.main()
/go/main.go:12 +0x1
exit status 2
From the trace it's easy to see the path of the error. The last item in the stack shows the error originated on line 62 in /go/main.go. The context added with each wrapper is also shown, in this case additional context was added to the error returned on line 44 in /go/main.go.
Because context follow the same format as fmt.Sprintf it can be used to show what data was passed and what data was received throughout the stack.
Public errors
Because errors only contain a single message, they're often masked from end users to ensure potentially sensitive information isn't exposed. This offloads the messaging to the UX layer which either creates strong coupling between the UX layer and the package throwing the error or returning vague error messages, and in some cases both.
// Strongly coupled with package for error comparison
if errors.As(err, &package.NotFoundError{}) {
return "Page not found"
}
// Vague messaging for all other errors
return "An error occurred"
In some systems there may be entire tables of comparisons with error codes, error messages, or error types to determine the appropriate error to return to the end user. This package attempts to simplify this problem by providing a wrapper that works in a similar way to error wrapping allowing the end user error messages to be set by the package throwing the error.
This example will show how to wrap an error message so the internal context isn't lost while maintaining useful error messages for end users.
For the sake of simplicity, this example prints to stdout and everything is contained in the main package. This is an emulation of a real world database package and responding with an error message in a http response or similar.
Authentication failure
This application attempts to authenticate a user in a database, but the password is invalid.
Code
Run this code in the Go Playground
// main.go
package main
import (
"errors"
"fmt"
"os"
)
func main() {
db := Connect()
user, err := db.Login("bob", "passwrod")
if err != nil {
fmt.Println(handleError(err))
os.Exit(1)
}
fmt.Printf("Hi %s!\n", user["name"])
}
func handleError(err error) string {
log.Println(err)
return fmt.Sprintf("ERROR: %s\n", err)
}
// Emulated database.go package
var ErrInvalidPassword = errors.New("the password entered is incorrect")
var ErrInvalidUsername = errors.New("the username entered can not be found")
type DB struct{}
func Connect() *DB {
return &DB{}
}
func (d *DB) Login(username string, password string) (map[string]any, error) {
user := map[string]any{
"id": 1,
"name": "bob",
}
if username != "bob" {
return user, ErrInvalidUsername
}
if username == "bob" && password != "password" {
return user, ErrInvalidPassword
}
return user, nil
}
Output
ERROR: the password entered is incorrect
2000/01/01 00:00:00 the password entered is incorrect
Process finished with the exit code 1
Like most applications, the calling function doesn't know why the error occurred, but it has to handle the error it received. In this example the error is being printed back to the end user as-is, however the error message contains information that could be used to perform account enumeration. The error is useful for the application, such as for debugging or rate limiting incorrect logins, but opens a potential attack vector if displayed to the end user.
To mitigate this the calling function needs to either handle the error and provide an appropriate error message or display a generic error message as per the intro to this example.
Public messaging
Using this package, the database package can set a separate internal and public message.
Code
Run this code in the Go Playground
// main.go
package main
import (
"fmt"
"log"
"os"
"sjdaws.com/pkg/errors"
)
func main() {
db := Connect()
user, err := db.Login("bob", "passwrod")
if err != nil {
fmt.Println(handleError(err))
os.Exit(1)
}
fmt.Printf("Hi %s!\n", user["name"])
}
func handleError(err error) string {
log.Println(err)
if errors.As(err, &errors.PublicError{}) {
return fmt.Sprintf("ERROR: %v\n", err.(errors.PublicError).Message())
}
return fmt.Sprintln("An error occurred.")
}
// Emulated database.go package
var ErrInvalidPassword = errors.Public(errors.New("the password entered is incorrect"), 403, "Authentication failed.")
var ErrInvalidUsername = errors.Public(errors.New("the username entered can not be found"), 403, "Authentication failed.")
type DB struct{}
func Connect() *DB {
return &DB{}
}
func (d *DB) Login(username string, password string) (map[string]any, error) {
user := map[string]any{
"id": 1,
"name": "bob",
}
if username != "bob" {
return user, ErrInvalidUsername
}
if username == "bob" && password != "password" {
return user, ErrInvalidPassword
}
return user, nil
}
Output
ERROR: Authentication failed.
2000/01/01 00:00:00 the password entered is incorrect
Process finished with the exit code 1
Changing the error type allows the application to use the error to perform logic, without having to handle how it will be displayed to an end user.
Additionally, public errors also allow many errors messages to be added to a single error, which is useful for items such as validation where the UX layer is able to format error sets.