Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Match server twirp errors using errors.As #323

Merged
merged 8 commits into from
Jun 16, 2021
7 changes: 4 additions & 3 deletions clientcompat/internal/clientcompat/clientcompat.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 83 additions & 33 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,94 @@ title: "Errors"
sidebar_label: "Errors"
---

Twirp clients always return errors that can be cast to `twirp.Error`.
Transport-level errors are wrapped as a `twirp.Error` with the `twirp.Internal` code.
Twirp errors are JSON responses with `code`, `msg` and (optional) `meta` keys:

Twirp server implementations can return `twirp.Error` or a regular `error`. Regular
errors are wrapped with the `twirp.Internal` code.
```json
{
"code": "internal",
"msg": "something went wrong",
}
```

Common error codes are `internal`, `not_found`, `invalid_argument` and `permission_denied`. See [twirp.ErrorCode](https://pkg.go.dev/github.com/twitchtv/twirp#ErrorCode) for the full list of available codes.

The [Errors Spec](spec_v7.md#error-codes) has more details about the protocol and HTTP status mapping.

In Go, Twirp errors satisfy the [twirp.Error](https://pkg.go.dev/github.com/twitchtv/twirp#Error) interface. An easy way to instantiate Twirp errors is using the [twirp.NewError](https://pkg.go.dev/github.com/twitchtv/twirp#NewError) constructor.


### twirp.Error interface
### Go Clients

Twirp clients always return errors that can be cast to the `twirp.Error` interface.

```go
type Error interface {
Code() ErrorCode // identifies a valid error type
Msg() string // free-form human-readable message
resp, err := client.MakeHat(ctx, req)
if err != nil {
if twerr, ok := err.(twirp.Error); ok {
// twerr.Code()
// twerr.Msg()
// twerr.Meta("foobar")
}
}
```

WithMeta(key string, val string) Error // set metadata
Meta(key string) string // get metadata value
MetaMap() map[string]string // see all metadata
Transport-level errors (like connection errors) are returned as internal errors by default. If desired, the original client-side error can be unwrapped:

Error() string // as an error, should return "twirp error <Code>: <Msg>"
```go
resp, err := client.MakeHat(ctx, req)
if err != nil {
if twerr, ok := err.(twirp.Error); ok {
if twerr.Code() == twirp.Internal {
if transportErr := errors.Unwrap(twerr); transportErr != nil {
// transportErr could be something like an HTTP connection error
}
}
}
}
```

### Error Codes
### Go Services

See [twirp.ErrorCode](https://pkg.go.dev/github.com/twitchtv/twirp#ErrorCode)
for valid types in the `twirp` package.
Example implementation returning Twirp errors:

```go
func (s *Server) FindUser(ctx context.Context, req *pb.FindUserRequest) (*pb.FindUserResp, error) {
if req.UserId == "" {
return nil, twirp.NewError(twirp.InvalidArgument, "user_id is required")
}

user, err := s.DB.FindByID(ctx, req.UserID)
if err != nil {
return nil, twirp.WrapError(twirp.NewError(twirp.Internal, "something went wrong"), err)
}

if user == nil {
return nil, twirp.NewError(twirp.NotFound, "user not found")
}

return &pb.FindUserResp{
Login: user.Login,
// ...
}, nil
}
```

Errors that can be matched as `twirp.Error` are sent through the wire and returned with the same code in the client.

Regular non-twirp errors are automatically wrapped as internal errors (using [twirp.InternalErrorWith(err)](https://pkg.go.dev/github.com/twitchtv/twirp#InternalErrorWith)). The original error is accessible in service hooks and middleware (e.g. using `errors.Unwrap`). But the original error is NOT serialized through the network; clients cannot access the original error, and will instead receive a `twirp.Error` with code `twirp.Internal`.

Example implementation returnin non-twirp errors:

```go
func (s *Server) FindUser(ctx context.Context, req *pb.FindUserRequest) (*pb.FindUserResp, error) {
return nil, errors.New("this non-twirp error will be serialized as a twirp.Internal error")
}
```

Twirp uses `errors.As(err, &twerr)` to know if a returned error is a `twirp.Error` or not.

**NOTE**: services generated with Twirp versions older than `v8.1.0` match withtype cast `err.(twirp.Error)` instead of `errors.As(err, &twerr)`. This means that wrapped Twirp errors or custom implementations that respond to `As(interface{}) bool` are still returned as internal errors, instead of being returned as the appropriate Twirp error. See release `v8.1.0` or PR [#323](https://github.com/twitchtv/twirp/pull/323) for details.

See [Errors Spec](spec_v7.md#error-codes) for the protocol Error codes and HTTP status mapping.

### HTTP Errors from Intermediary Proxies

Expand All @@ -45,7 +105,7 @@ depending on the HTTP status of the invalid response:

| HTTP status code | Twirp Error Code
| ------------------------ | ------------------
| 3xx (redirects) | Internal
| 3xx (redirects) | Internalreturn nil, fmt.Errorf("this non-twirp error will
| 400 Bad Request | Internal
| 401 Unauthorized | Unauthenticated
| 403 Forbidden | PermissionDenied
Expand Down Expand Up @@ -76,7 +136,7 @@ if unavailable {
}
```

The metadata is available on the client:
Metadata is available on the client as expected:

```go
if twerr.Code() == twirp.Unavailable {
Expand All @@ -91,24 +151,14 @@ If your service requires errors with complex metadata, you should consider addin
wrappers on top of the auto-generated clients, or just include business-logic errors as
part of the Protobuf messages (add an error field to proto messages).

### Writing HTTP Errors outside Twirp services

Twirp services can be [muxed with other HTTP services](mux.md). For consistent responses
and error codes _outside_ Twirp servers, such as HTTP middleware, you can call `twirp.WriteError`.

The error is expected to satisfy a `twirp.Error`, otherwise it is wrapped with `twirp.InternalError`.
### Writing HTTP Errors outside Twirp services

Usage:
Twirp services can be [muxed with other HTTP services](mux.md). For consistent responses and error codes _outside_ Twirp servers, such as HTTP middleware, you can call [twirp.WriteError](https://pkg.go.dev/github.com/twitchtv/twirp#WriteError).

```go
rpc.WriteError(w, twirp.NewError(twirp.Unauthenticated, "invalid token"))
twerr := twirp.NewError(twirp.Unauthenticated, "invalid token")
twirp.WriteError(respWriter, twerr)
```

To simplify `twirp.Error` composition, a few constructors are available, such as `NotFoundError`
and `RequiredArgumentError`. See [docs](https://godoc.org/github.com/twitchtv/twirp#Error).

With constructor:

```go
rpc.WriteError(w, twirp.RequiredArgumentError("user_id"))
```
As with returned service errors, the error is expected to satisfy the `twirp.Error` interface, otherwise it is wrapped as a `twirp.InternalError`.
11 changes: 3 additions & 8 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ package twirp

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -355,14 +356,8 @@ func (e *wrappedErr) Cause() error { return e.cause } // for github.com/pkg/err
// Useful outside of the Twirp server (e.g. http middleware).
// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err)
func WriteError(resp http.ResponseWriter, err error) error {
return writeError(resp, err)
}

// writeError writes Twirp errors in the response.
func writeError(resp http.ResponseWriter, err error) error {
// Non-twirp errors are wrapped as Internal (default)
twerr, ok := err.(Error)
if !ok {
var twerr Error
if !errors.As(err, &twerr) {
twerr = InternalErrorWith(err)
}

Expand Down
32 changes: 32 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,38 @@ func TestWriteError(t *testing.T) {
}
}

func TestWriteError_WithNonTwirpError(t *testing.T) {
resp := httptest.NewRecorder()
nonTwerr := errors.New("not a twirp error")
err := WriteError(resp, nonTwerr)
if err != nil {
t.Errorf("got an error from WriteError when not expecting one: %s", err)
return
}

if resp.Code != 500 {
t.Errorf("got wrong status. have=%d, want=%d", resp.Code, 500)
return
}

var gotTwerrJSON twerrJSON
err = json.NewDecoder(resp.Body).Decode(&gotTwerrJSON)
if err != nil {
t.Errorf("got an error decoding response body: %s", err)
return
}

if ErrorCode(gotTwerrJSON.Code) != Internal {
t.Errorf("got wrong error code. have=%s, want=%s", gotTwerrJSON.Code, Internal)
return
}

if gotTwerrJSON.Msg != ""+nonTwerr.Error() {
t.Errorf("got wrong error message. have=%s, want=%s", gotTwerrJSON.Msg, nonTwerr.Error())
return
}
}

func TestWrapError(t *testing.T) {
rootCause := errors.New("cause")
twerr := NewError(NotFound, "it ain't there")
Expand Down
7 changes: 4 additions & 3 deletions example/service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/empty_service/empty_service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/google_protobuf_imports/service.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/importable/importable.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/importer/importer.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/importer_local/importer_local.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions internal/twirptest/importmapping/x/x.twirp.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading