Imagine a situation. You’re having a backend serving music. It has an API level, some domain logic, and storage. You have different kinds of entities in the API (tracks, playlists, and so on) and you need some way to filter them. There’s a lot of entities stored in the storage so you have to invent a paging strategy for them.
That’s what you probably come up with for client requests:
curl "https://api.com/tracks?cursor=c93d3bf7&limit=12"
A client doesn’t even attempt to interpret cursor value, it’s returned in response headers by the backend. Opaque cursor value serves well because it’s very useful for no-offset pagination.
The problem is, you cannot validate the cursor value on the API level. That’s because track storage interprets it in one way and playlist storage interprets it in the other way. So you have to pass the cursor from API through domain logic to the storage. The storage then parses the cursor and chooses entities to return with the next page. The problem arises when you need to return the “this is invalid cursor” error.
package storage
// Public error, to be used by upper levels
var ErrInvalidCursor = errors.New("cursor has invalid value")
Classic Go way
Just return errors as is, in a well-known idiom
package domain
// Call storage and provide cursor
if err := trackStorage.FilterTracks(..., cursor); err != nil {
return err
}
It doesn’t scale well because most of the time you want more context. You want to trace the top-level error back to the source. That’s why https://github.com/pkg/errors was born.
On the API level you just do something like:
package api
cursor := GetCursorCtx(ctx) // Extract cursor from query string
err := trackScenarios.FilterTracks(..., cursor) // Call domain level
if err != nil {
if err == storage.ErrInvalidCursor {
return renderErrorResponse(http.BadRequest, errors.New("invalid cursor"))
}
return renderErrorResponse(http.InternalError, err)
}
“Nice and clean” pkg/errors way
pkg/errors
allows you to attach context to an error. This improves traceability.
package domain
if err := storage.FilterTracks(..., cursor); err != nil {
return errors.Wrap(err, "cannot filter tracks")
}
Or, if you don’t have anything to say and just want to attach the stack trace:
package domain
if err := storage.FilterTracks(..., cursor); err != nil {
return errors.WithStack(err)
}
Looks nice. And it makes code so much easier to debug using logs!
But you cannot compare the error on the API level as is, you have to unwrap it.
package api
cursor := GetCursorCtx(ctx)
err := trackScenarios.FilterTracks(..., cursor)
if err != nil {
if errors.Cause(err) == storage.ErrInvalidCursor { // <- here we use the errors.Cause()
return renderErrorResponse(http.BadRequest, errors.New("invalid cursor"))
}
return renderErrorResponse(http.InternalError, err)
}
This unwrapping is not a big deal, right? Wrong!
pkg/errors is broken
That’s what you should do instead of wrapping of errors by default:
package domain
if err := storage.FilterTracks(..., cursor); err != nil {
if err == storage.ErrInvalidCursor {
return err
}
return errors.WithStack(err)
}
This is subtle but important.
Let’s define terms. We call error recognizable if it was stated in the API contract.
For example, storage.FilterTracks(..., cursor)
states that it returns ErrInvalidCursor
if the cursor is invalid.
It states it in a comment because there’s no other way in Go. All other errors are unrecognizable. They’re truly unexpected. For example, storage is corrupted.
The proposed guideline is this:
Return the recognizable error as is and wrap unrecognizable errors
The rationale
Wrapping Pros
Strong arguments:
- You don’t need to explicitly check recognizable errors. Less code.
- As a consequence of #1, it’s much easier to add new types of errors down the stack. No need to change the glue code. Less work if you want to add a new error type.
- You don’t need to remember the types of errors a particular interface should return. Less mental load.
Weak arguments:
- Explicit checking doesn’t work as expected in the example below. During debugging you cannot understand what was the source of the problem
foo()
orbar()
. I.e. you lose stack traces. Weak because you need trackbacks only for unrecognizable errors (they’re for humans) and panics (they’re for programmers). - It’s cumbersome to document recognizable errors. You should mention every function returning them (i.e on the entire call stack). Weak because it can be solved by convention.
- You use recognizable errors only on levels that are important for them (generating and handling this error). Weak because a function doesn’t know how client code is going to use it. Or how it’s going to handle its errors.
func f() {
if err := foo(); err != nil {
if err == ErrInvalidCursor {
return err
}
return errors.Wrap(err, "cannot do foo")
}
if err := bar(); err != nil {
if err == ErrInvalidCursor {
return err
}
return errors.Wrap(err, "cannot do bar")
}
}
Wrapping Cons
Strong arguments:
- The code deteriorates fast because:
- As an API client, you’re not sure if this method could return a particular type of error. Underlying code leaves a feeling that implementer didn’t care what he returns. Client code starts to look like
catch Throwable
in OOP languages. - As an API designer, you don’t care if the caller checks this particular type of error.
- As an API client, you’re not sure if this method could return a particular type of error. Underlying code leaves a feeling that implementer didn’t care what he returns. Client code starts to look like
- If the ErrInvalidCursor is unexpected for some function, it shouldn’t handle it explicitly. This is a nice way to say “I don’t even know how to handle that”. Panic or return an unrecognizable error then.
- If the documentation says “Returns ErrInvalidCursor if the cursor is invalid” it must return it. Unwrapped.
- If API returns both wrapped and unwrapped errors, it’s broken. Return all wrapped or all unwrapped. See this issue
- The official blog doesn’t say anything about wrapping. That’s what it says:
When adding additional context to an error, either with fmt.Errorf or by implementing a custom type, you need to decide whether the new error should wrap the original. There is no single answer to this question; it depends on the context in which the new error is created. Wrap an error to expose it to callers. Do not wrap an error when doing so would expose implementation details.
Weak arguments (mostly pkg/errors-related):
- Sometimes you need double unwrapping, first using pkg/errors Unwrap and then standard library Unwrap. And those libraries conflict by name.
- It’s very hard to switch to stdlib’s Is() and As() incrementally if you’re using pkg/errors.
- Mix of pkg/errors and standard errors produces weird problems. Cause() is broken.
Trivia
- pkg/errors stack traces were created to allow not to write return errors.Wrap(err, “”). Proof.
- Use tools to replace pkg/errors by fmt.Errorf safely. An excellent article explaining how it works.
- pkg/errros can into their own Is and As.
- This is how you should use stderrors.
- Good introduction to the topic of stack traces in Go.