-
Notifications
You must be signed in to change notification settings - Fork 327
Description
This is a proposal for an improvement to how Twirp handlers return errors. I originally brought this up a couple of years ago on the Twirp Slack in a discussion with @spenczar and @marwan-at-work.
To summarize, I propose that we add the following interface to the twirp
package. A detailed proposal with background information follows.
// ErrorCoder may be implemented by third-party error types returned by
// Twirp handler functions to indicate a Twirp error code to report.
//
// If a non-nil error e returned by a handler is not an Error but is an
// ErrorCoder (or wraps one as indicated by errors.As), the server responds
// with an error using the code given by e.TwirpErrorCode and a message
// given by e.Error.
type ErrorCoder interface {
TwirpErrorCode() ErrorCode
}
If this proposal is acceptable, I'm happy to send a PR.
Background
At my company we use Twirp extensively for making RPCs across systems. We sometimes need to convey particular error conditions (entity not found, say) and handle them differently on the client side. We can do this by sending a particular Twirp error code as the response.
The way this works today is that if a handler returns an error that implements twirp.Error
, that Twirp error is used for the response. If the handler returns a non-nil error that does not implement twirp.Error
, then it is considered a generic internal error and wrapped using twirp.InternalErrorWith
.
This works well enough if the handler makes decisions about different error conditions directly. However, if the error is created a few functions down the call chain, it works less well:
- The function that generates the error might not be Twirp-specific, so it's not appropriate to return a
twirp.Error
, which is very much Twirp-specific. For instance, we have some servers that have shared internal logic which is used for generating web pages as well as Twirp responses and we don't want to generate Twirp errors in non-Twirp routes. - The error generated down the call stack might be wrapped with other errors before the handler route gets it.
To work around this issue, a common pattern is for our servers to use a function to match errors to various internal error variables/types and return the appropriate Twirp error:
func toTwirpError(err error) error {
switch {
case errors.Is(err, errJobFinished):
return twirp.NewError(twirp.FailedPrecondition, err.Error())
case errors.Is(err, errNoLogDataAfterWait):
return twirp.NewError(twirp.DeadlineExceeded, err.Error())
case errors.As(err, new(jobNotFoundError)):
return twirp.NotFoundError(err.Error())
// ... several more cases ...
default:
return err
}
}
All handlers have to remember to call toTwirpError
any time they are returning the results of some function call that might return one of these internal errors. This is tedious and easy to mess up. It also happens to be fairly inefficient.
Proposal
We propose to add the ErrorCoder
interface described above.
The idea is that the generated handler code will look something like this:
func writeError(ctx context.Context, resp http.ResponseWriter, err error, hooks *twirp.ServerHooks) {
// If we already have a twirp.Error, use it.
twerr, ok := err.(twirp.Error)
if !ok {
var ec twirp.ErrorCoder
if errors.As(err, &ec) {
// If we have a twirp.ErrorCoder, use the error code it
// provides.
twerr = twirp.NewError(ec.TwirpErrorCode(), err.Error())
} else {
// Otherwise, non-twirp errors are wrapped as Internal by default.
twerr = twirp.InternalErrorWith(err)
}
}
// ...
}
This allows us to create internal error types for each project which which are not Twirp-specific but can be turned into Twirp errors because they correspond to a Twirp error code. We can freely return those from any function whether or not it's being called as part of a Twirp route and even wrap them as they go up the call stack.
Backward compatibility
This is fully backward compatible and doesn't change the behavior of any existing code.
(Technically speaking, it could change the behavior of code which uses an error type which has a method TwirpErrorCode() twirp.ErrorCode
, but that seems very unlikely.)
Alternatives
One slight variant on this interface which would be slightly more flexible but require a little more boilerplate to implement would be to have the optional method return a Twirp Error
rather than just the ErrorCode
:
type Errorer interface {
TwirpError() Error
}
Naming
I think it's important that the method is called TwirpErrorCode
, not ErrorCode
, because the purpose of the interface is to be implemented by types outside of a Twirp context and so it's good for the method to make it clear that it is a Twirp-related adapter.
If the method is TwirpErrorCode
, you could then argue that the interface should be TwirpErrorCoder
, though it's a little redundant with the package name: twirp.TwirpErrorCoder
. I don't have a strong feeling about this, though. (The interface itself should rarely be named in any code.)