Skip to content

proposal: errors: add With(err, other error) error  #52607

Closed
@natefinch

Description

@natefinch

Background

Right now, the only way to wrap an error in the standard library is with fmt.Errorf which means that all you can do to an error is add text to its .Error() output.

When you receive an error from a function and want to return it up the stack with additional information, you have 3 options

  1. you can return some other error, which loses the original error's context
  2. you can wrap the error with fmt.Errorf, which just adds textual output, and therefore isn't something callers can programmatically check for
  3. you can write a complicated error wrapping struct that contains the metadata you want to check for, and behaves correctly for errors.Is, .As, and .Unwrap to allow callers to access the underlying cause of the error.

Proposal

This proposal offers a much easier way to do number 3, by implementing a new function in the stdlib errors package that supports wrapping any error with any other error, such that they are both discoverable with errors.Is and errors.As.

// With returns an error that adds flag to the list of errors that can be
// discovered by errors.Is and errors.As.
func With(err, flag error) error {
	if err == nil {
		return nil
	}
	if flag == nil {
		return err
	}

	return flagged{error: err, flag: flag}
}

type flagged struct {
	flag error
	error
}

func (f flagged) Is(target error) bool {
	return errors.Is(f.flag, target)
}

func (f flagged) As(target interface{}) bool {
	return errors.As(f.flag, target)
}

func (f flagged) Unwrap() error {
	return f.error
}

This means that:
errors.With(err, flag).Error() returns err.Error()
errors.Unwrap(With(err, flag)) returns err
errors.Is(With(err, flag), target) is the same as calling errors.Is(flag, target) || errors.Is(err, target)
errors.As(With(err, flag), target) is the same as calling errors.As(flag, target) || errors.As(err, target)

Why This Approach?

By reusing the existing error wrapping pattern, we make the smallest possible change to the standard library, while allowing the broadest applicability of the functionality. We introduce no new interfaces or concepts. The function is general enough to support many different use cases, and yet, its use would be invisible to anyone who is not interested in using error wrapping themselves. It imposes no added burden on third parties that check errors (beyond what is already standard with fmt.Errorf wrapping), and can be ignored by authors producing errors, if that is their wish.

Use Cases

The main use case for this function is incredibly common, and I've seen it in basically every go application I've ever written. You have a package that returns a domain-specific error, like a postgres driver returning pq.ErrNoRows. You want to pass that error up the stack to maintain the context of the original error, but you don't want your callers to have to know about postgres errors in order to know how to deal with this error from your storage layer. With the proposed With function, you can add metadata via a well-known error type so that the error your function returns can be checked consistently, regardless of the underlying implementation.

This kind of error is often called a Sentinel error. fs.ErrNotExist is a good example of a sentinel error that wraps the platform-specific syscall error.

// SetUserName sets the name of the user with the given id. This method returns 
// flags.NotFound if the user isn't found or flags.Conflict if a user with that
// name already exists. 
func (st *Storage) SetUserName(id uuid.UUID, name string) error {
    err := st.db.SetUser(id, "name="+name)
    if errors.Is(err, pq.ErrNoRows) {
       return nil, errors.With(err, flags.NotFound)
    }
    var pqErr *pq.Error
    if errors.As(err, &pqErr) && pqErr.Constraint == "unique_user_name" {
        return errors.With(err, flags.Conflict)
    }
    if err != nil {
       // some other unknown error
       return fmt.Errorf("error setting name on user with id %v: %w", err) 
    }
    return nil
}

This keeps the error categorization very near to the code that produces the error. Nobody outside of SetUserName needs to know anything about postgres driver errors.

Now in the API layer, you can translate this error to an HTTP error code trivially:

func (h *Handlers) HandleSetName(w http.ResponseWriter, r *http.Request) {
    name, id := getNameAndID(r)
    err := h.storage.SetUserName(id, name)
    if err != nil {
        handleError(err, w)
        return
    }
    // other stuff
}

func handleError(err error, w http.ResponseWriter) {
    switch {
    case errors.Is(err, flags.NotFound):
        http.Error(w, 404, "not found")
    case errors.Is(err, flags.Conflict):
        http.Error(w, 409, "conflict")
    default:
        // other, uncategorized error
        http.Error(w, 500, "internal server error")
        // probably log it, too
    }
}

This code doesn't know anything about postgres. It uses the standard errors.Is to check for errors it knows about. But if it then decides to log that error, it has full access to the original error's full context if it wants to dig into it.

This code is very insulated from any implementation changes to the storage layer, so long as it maintains its API contract by continuing to categorize the errors with the same error flags using errors.With.

What This Code Replaces

Without code like this, the handleError functions above would have to do something like this itself:

    var pqErr *pq.Error
    if errors.As(err, &pqErr) && pqErr.Constraint == "unique_user_name" {
        http.Error(w, 409, "conflict")
        return
    }

Not only is that super gross to be doing in the API layer, but it would silently break if someone decided to change database or even just the name of the constraint, and didn't realize that this far away method was digging deep into that error. I've seen and written this kind of code many times in my career, and it feels really wrong, but without stdlib support, it's hard to know what the alternative is.

Production Experience

I used this kind of error wrapping at Mattel for a long time, and now I'm introducing it to GitHub's codebase. It VASTLY improved our error handling in both cases, with very little cognitive or processing overhead. It has made it much easier to understand what an error return really means. It has made writing correctly-behaving API code much simpler by breaking the coupling between two disparate parts of the system.

Community Examples

Conclusion

Adding this method to the standard library would encourage the use of this pattern. Packages using this pattern have better-defined and more stable contracts about how callers can programmatically respond to different categories of errors, without having to worry about implementation details. This is especially true of application code, where error categorization has traditionally been difficult.

Special thanks to @rogpeppe for his suggested improvement to my original error flags idea.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions