diff --git a/client_test.go b/client_test.go index b3897aa..427ba63 100644 --- a/client_test.go +++ b/client_test.go @@ -2,6 +2,7 @@ package dispatch_test import ( "context" + "net/http" "testing" "github.com/dispatchrun/dispatch-go" @@ -10,7 +11,7 @@ import ( func TestClient(t *testing.T) { recorder := &dispatchtest.CallRecorder{} - server := dispatchtest.NewDispatchServer(recorder) + server := dispatchtest.NewServer(recorder) client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { @@ -25,14 +26,14 @@ func TestClient(t *testing.T) { } recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{call}, }) } func TestClientEnvConfig(t *testing.T) { recorder := &dispatchtest.CallRecorder{} - server := dispatchtest.NewDispatchServer(recorder) + server := dispatchtest.NewServer(recorder) client, err := dispatch.NewClient(dispatch.Env( "DISPATCH_API_KEY=foobar", @@ -50,14 +51,14 @@ func TestClientEnvConfig(t *testing.T) { } recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{call}, }) } func TestClientBatch(t *testing.T) { recorder := &dispatchtest.CallRecorder{} - server := dispatchtest.NewDispatchServer(recorder) + server := dispatchtest.NewServer(recorder) client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { @@ -86,11 +87,11 @@ func TestClientBatch(t *testing.T) { recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{call1, call2}, }, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{call3, call4}, }) } diff --git a/dispatch_test.go b/dispatch_test.go index 4c21f41..add19bb 100644 --- a/dispatch_test.go +++ b/dispatch_test.go @@ -2,6 +2,7 @@ package dispatch_test import ( "context" + "net/http" "testing" "time" @@ -72,7 +73,7 @@ func TestDispatchEndpoint(t *testing.T) { func TestDispatchCall(t *testing.T) { recorder := &dispatchtest.CallRecorder{} - server := dispatchtest.NewDispatchServer(recorder) + server := dispatchtest.NewServer(recorder) client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { @@ -95,7 +96,7 @@ func TestDispatchCall(t *testing.T) { } recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{ dispatch.NewCall("http://example.com", "function1", dispatch.Input(dispatch.Int(11)), @@ -106,7 +107,7 @@ func TestDispatchCall(t *testing.T) { func TestDispatchCallEnvConfig(t *testing.T) { recorder := &dispatchtest.CallRecorder{} - server := dispatchtest.NewDispatchServer(recorder) + server := dispatchtest.NewServer(recorder) endpoint, err := dispatch.New(dispatch.Env( "DISPATCH_ENDPOINT_URL=http://example.com", @@ -128,7 +129,7 @@ func TestDispatchCallEnvConfig(t *testing.T) { } recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{ dispatch.NewCall("http://example.com", "function2", dispatch.Input(dispatch.String("foo")), @@ -140,7 +141,7 @@ func TestDispatchCallEnvConfig(t *testing.T) { func TestDispatchCallsBatch(t *testing.T) { var recorder dispatchtest.CallRecorder - server := dispatchtest.NewDispatchServer(&recorder) + server := dispatchtest.NewServer(&recorder) client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { @@ -184,7 +185,7 @@ func TestDispatchCallsBatch(t *testing.T) { } recorder.Assert(t, dispatchtest.DispatchRequest{ - ApiKey: "foobar", + Header: http.Header{"Authorization": []string{"Bearer foobar"}}, Calls: []dispatch.Call{call1, call2}, }) } diff --git a/dispatchserver/endpoint.go b/dispatchserver/endpoint.go new file mode 100644 index 0000000..fcd98c6 --- /dev/null +++ b/dispatchserver/endpoint.go @@ -0,0 +1,108 @@ +package dispatchserver + +import ( + "context" + "crypto/ed25519" + "net/http" + _ "unsafe" + + "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" + sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/dispatchrun/dispatch-go" + "github.com/dispatchrun/dispatch-go/internal/auth" +) + +// EndpointClient is a client for a Dispatch endpoint. +// +// Note that this is not the same as dispatch.Client, which +// is a client for the Dispatch API. The client here is used +// by a Dispatch server to interact with the functions provided +// by a Dispatch endpoint. +type EndpointClient struct { + httpClient connect.HTTPClient + signingKey ed25519.PrivateKey + header http.Header + opts []connect.ClientOption + + client sdkv1connect.FunctionServiceClient +} + +// NewEndpointClient creates an EndpointClient. +func NewEndpointClient(endpointUrl string, opts ...EndpointClientOption) (*EndpointClient, error) { + c := &EndpointClient{} + for _, opt := range opts { + opt(c) + } + + if c.httpClient == nil { + c.httpClient = http.DefaultClient + } + + // Setup request signing. + if c.signingKey != nil { + signer := auth.NewSigner(c.signingKey) + c.httpClient = signer.Client(c.httpClient) + } + + // Setup the gRPC client. + validator, err := validate.NewInterceptor() + if err != nil { + return nil, err + } + c.opts = append(c.opts, connect.WithInterceptors(validator)) + c.client = sdkv1connect.NewFunctionServiceClient(c.httpClient, endpointUrl, c.opts...) + + return c, nil +} + +// EndpointClientOption configures an EndpointClient. +type EndpointClientOption func(*EndpointClient) + +// SigningKey sets the signing key to use when signing requests bound +// for the endpoint. +// +// By default the EndpointClient does not sign requests to the endpoint. +func SigningKey(signingKey ed25519.PrivateKey) EndpointClientOption { + return func(c *EndpointClient) { c.signingKey = signingKey } +} + +// HTTPClient sets the HTTP client to use when making requests to the endpoint. +// +// By default http.DefaultClient is used. +func HTTPClient(client connect.HTTPClient) EndpointClientOption { + return func(c *EndpointClient) { c.httpClient = client } +} + +// RequestHeaders sets headers on the request to the endpoint. +func RequestHeaders(header http.Header) EndpointClientOption { + return func(c *EndpointClient) { c.header = header } +} + +// ClientOptions adds options for the underlying connect (gRPC) client. +func ClientOptions(opts ...connect.ClientOption) EndpointClientOption { + return func(c *EndpointClient) { c.opts = append(c.opts, opts...) } +} + +// Run sends a RunRequest and returns a RunResponse. +func (c *EndpointClient) Run(ctx context.Context, req dispatch.Request) (dispatch.Response, error) { + connectReq := connect.NewRequest(requestProto(req)) + + header := connectReq.Header() + for name, values := range c.header { + header[name] = values + } + + res, err := c.client.Run(ctx, connectReq) + if err != nil { + return dispatch.Response{}, err + } + return newProtoResponse(res.Msg), nil +} + +//go:linkname newProtoResponse github.com/dispatchrun/dispatch-go.newProtoResponse +func newProtoResponse(r *sdkv1.RunResponse) dispatch.Response + +//go:linkname requestProto github.com/dispatchrun/dispatch-go.requestProto +func requestProto(r dispatch.Request) *sdkv1.RunRequest diff --git a/dispatchserver/server.go b/dispatchserver/server.go new file mode 100644 index 0000000..2eca15d --- /dev/null +++ b/dispatchserver/server.go @@ -0,0 +1,90 @@ +package dispatchserver + +import ( + "context" + "net/http" + _ "unsafe" + + "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" + sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/dispatchrun/dispatch-go" +) + +// Handler handles requests to a Dispatch API server. +type Handler interface { + Handle(ctx context.Context, header http.Header, calls []dispatch.Call) ([]dispatch.ID, error) +} + +// HandlerFunc creates a Handler from a function. +func HandlerFunc(fn func(context.Context, http.Header, []dispatch.Call) ([]dispatch.ID, error)) Handler { + return handlerFunc(fn) +} + +type handlerFunc func(context.Context, http.Header, []dispatch.Call) ([]dispatch.ID, error) + +func (h handlerFunc) Handle(ctx context.Context, header http.Header, calls []dispatch.Call) ([]dispatch.ID, error) { + return h(ctx, header, calls) +} + +// New creates a Server. +func New(handler Handler, opts ...connect.HandlerOption) (*Server, error) { + validator, err := validate.NewInterceptor() + if err != nil { + return nil, err + } + opts = append(opts, connect.WithInterceptors(validator)) + grpcHandler := &dispatchServiceHandler{handler} + path, httpHandler := sdkv1connect.NewDispatchServiceHandler(grpcHandler, opts...) + return &Server{ + path: path, + handler: httpHandler, + }, nil +} + +// Server is a Dispatch API server. +type Server struct { + path string + handler http.Handler +} + +// Handler returns an HTTP handler for the Dispatch API server, along with +// the path that the handler should be registered at. +func (s *Server) Handler() (string, http.Handler) { + return s.path, s.handler +} + +// Serve serves the Server on the specified address. +func (s *Server) Serve(addr string) error { + mux := http.NewServeMux() + mux.Handle(s.Handler()) + server := &http.Server{Addr: addr, Handler: mux} + return server.ListenAndServe() +} + +type dispatchServiceHandler struct{ Handler } + +func (d *dispatchServiceHandler) Dispatch(ctx context.Context, req *connect.Request[sdkv1.DispatchRequest]) (*connect.Response[sdkv1.DispatchResponse], error) { + calls := make([]dispatch.Call, len(req.Msg.Calls)) + for i, c := range req.Msg.Calls { + calls[i] = newProtoCall(c) + } + ids, err := d.Handle(ctx, req.Header(), calls) + if err != nil { + return nil, err + } + if len(ids) != len(calls) { + panic("invalid handler response") + } + dispatchIDs := make([]string, len(ids)) + for i, id := range ids { + dispatchIDs[i] = string(id) + } + return connect.NewResponse(&sdkv1.DispatchResponse{ + DispatchIds: dispatchIDs, + }), nil +} + +//go:linkname newProtoCall github.com/dispatchrun/dispatch-go.newProtoCall +func newProtoCall(c *sdkv1.Call) dispatch.Call diff --git a/dispatchtest/endpoint.go b/dispatchtest/endpoint.go index 0449c5b..8c9e420 100644 --- a/dispatchtest/endpoint.go +++ b/dispatchtest/endpoint.go @@ -1,20 +1,14 @@ package dispatchtest import ( - "context" "crypto/ed25519" "encoding/base64" "fmt" "net/http" "net/http/httptest" - _ "unsafe" - "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" - sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" - "connectrpc.com/connect" - "connectrpc.com/validate" "github.com/dispatchrun/dispatch-go" - "github.com/dispatchrun/dispatch-go/internal/auth" + "github.com/dispatchrun/dispatch-go/dispatchserver" ) // NewEndpoint creates a Dispatch endpoint, like dispatch.New. @@ -44,8 +38,8 @@ type EndpointServer struct { // Client returns a client that can be used to interact with the // Dispatch endpoint. -func (e *EndpointServer) Client(opts ...EndpointClientOption) (*EndpointClient, error) { - return NewEndpointClient(e.server.URL, opts...) +func (e *EndpointServer) Client(opts ...dispatchserver.EndpointClientOption) (*dispatchserver.EndpointClient, error) { + return dispatchserver.NewEndpointClient(e.server.URL, opts...) } // URL is the URL of the server. @@ -58,70 +52,15 @@ func (e *EndpointServer) Close() { e.server.Close() } -// EndpointClient is a client for a Dispatch endpoint. -// -// Note that this is not the same as dispatch.Client, which -// is a client for the Dispatch API. The client here is -// useful when testing a Dispatch endpoint. -type EndpointClient struct { - signingKey string - - client sdkv1connect.FunctionServiceClient -} - -// NewEndpointClient creates an EndpointClient. -func NewEndpointClient(endpointUrl string, opts ...EndpointClientOption) (*EndpointClient, error) { - c := &EndpointClient{} - for _, opt := range opts { - opt(c) - } - - // Setup request signing. - var httpClient connect.HTTPClient = http.DefaultClient - if c.signingKey != "" { - privateKey, err := base64.StdEncoding.DecodeString(c.signingKey) - if err != nil || len(privateKey) != ed25519.PrivateKeySize { - return nil, fmt.Errorf("invalid signing key: %v", c.signingKey) - } - signer := auth.NewSigner(ed25519.PrivateKey(privateKey)) - httpClient = signer.Client(httpClient) - } - - // Setup the gRPC client. - validator, err := validate.NewInterceptor() - if err != nil { - return nil, err - } - c.client = sdkv1connect.NewFunctionServiceClient(httpClient, endpointUrl, connect.WithInterceptors(validator)) - - return c, nil -} - -// EndpointClientOption configures an EndpointClient. -type EndpointClientOption func(*EndpointClient) - // SigningKey sets the signing key to use when signing requests bound // for the endpoint. // // The signing key should be a base64-encoded ed25519.PrivateKey, e.g. // one provided by the KeyPair helper function. -// -// By default the EndpointClient does not sign requests to the endpoint. -func SigningKey(signingKey string) EndpointClientOption { - return func(c *EndpointClient) { c.signingKey = signingKey } -} - -// Run sends a RunRequest and returns a RunResponse. -func (c *EndpointClient) Run(ctx context.Context, req dispatch.Request) (dispatch.Response, error) { - res, err := c.client.Run(ctx, connect.NewRequest(requestProto(req))) - if err != nil { - return dispatch.Response{}, err +func SigningKey(signingKey string) dispatchserver.EndpointClientOption { + pk, err := base64.StdEncoding.DecodeString(signingKey) + if err != nil || len(pk) != ed25519.PrivateKeySize { + panic(fmt.Errorf("invalid signing key: %v", signingKey)) } - return newProtoResponse(res.Msg), nil + return dispatchserver.SigningKey(pk) } - -//go:linkname newProtoResponse github.com/dispatchrun/dispatch-go.newProtoResponse -func newProtoResponse(r *sdkv1.RunResponse) dispatch.Response - -//go:linkname requestProto github.com/dispatchrun/dispatch-go.requestProto -func requestProto(r dispatch.Request) *sdkv1.RunRequest diff --git a/dispatchtest/server.go b/dispatchtest/server.go index 5073021..21b5110 100644 --- a/dispatchtest/server.go +++ b/dispatchtest/server.go @@ -2,79 +2,29 @@ package dispatchtest import ( "context" - "fmt" "net/http" "net/http/httptest" + "slices" "strconv" - "strings" "testing" _ "unsafe" - "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" - sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" - "connectrpc.com/connect" "github.com/dispatchrun/dispatch-go" + "github.com/dispatchrun/dispatch-go/dispatchserver" ) -// DispatchServerHandler is a handler for a test Dispatch API server. -type DispatchServerHandler interface { - Handle(ctx context.Context, apiKey string, calls []dispatch.Call) ([]dispatch.ID, error) -} - -// DispatchServerHandlerFunc creates a DispatchServerHandler from a function. -func DispatchServerHandlerFunc(fn func(ctx context.Context, apiKey string, calls []dispatch.Call) ([]dispatch.ID, error)) DispatchServerHandler { - return dispatchServerHandlerFunc(fn) -} - -type dispatchServerHandlerFunc func(context.Context, string, []dispatch.Call) ([]dispatch.ID, error) - -func (h dispatchServerHandlerFunc) Handle(ctx context.Context, apiKey string, calls []dispatch.Call) ([]dispatch.ID, error) { - return h(ctx, apiKey, calls) -} - -// NewDispatchServer creates a new test Dispatch API server. -func NewDispatchServer(handler DispatchServerHandler) *httptest.Server { - mux := http.NewServeMux() - mux.Handle(sdkv1connect.NewDispatchServiceHandler(&dispatchServiceHandler{handler})) - return httptest.NewServer(mux) -} - -type dispatchServiceHandler struct { - DispatchServerHandler -} - -func (d *dispatchServiceHandler) Dispatch(ctx context.Context, req *connect.Request[sdkv1.DispatchRequest]) (*connect.Response[sdkv1.DispatchResponse], error) { - auth := req.Header().Get("Authorization") - apiKey, ok := strings.CutPrefix(auth, "Bearer ") - if !ok { - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("missing or invalid Authorization header: %q", auth)) - } - - calls := make([]dispatch.Call, len(req.Msg.Calls)) - for i, c := range req.Msg.Calls { - calls[i] = newProtoCall(c) - } - - ids, err := d.Handle(ctx, apiKey, calls) +// NewServer creates a new test Dispatch API server. +func NewServer(handler dispatchserver.Handler) *httptest.Server { + s, err := dispatchserver.New(handler) if err != nil { - return nil, err + panic(err) } - if len(ids) != len(calls) { - panic("invalid handler response") - } - dispatchIDs := make([]string, len(ids)) - for i, id := range ids { - dispatchIDs[i] = string(id) - } - return connect.NewResponse(&sdkv1.DispatchResponse{ - DispatchIds: dispatchIDs, - }), nil + mux := http.NewServeMux() + mux.Handle(s.Handler()) + return httptest.NewServer(mux) } -//go:linkname newProtoCall github.com/dispatchrun/dispatch-go.newProtoCall -func newProtoCall(c *sdkv1.Call) dispatch.Call - -// CallRecorder is a DispatchServerHandler that captures requests to the Dispatch API. +// CallRecorder is a dispatchserver.Handler that captures requests to the Dispatch API. type CallRecorder struct { requests []DispatchRequest calls int @@ -82,17 +32,17 @@ type CallRecorder struct { // DispatchRequest is a request to the Dispatch API captured by a CallRecorder. type DispatchRequest struct { - ApiKey string + Header http.Header Calls []dispatch.Call } -func (r *CallRecorder) Handle(ctx context.Context, apiKey string, calls []dispatch.Call) ([]dispatch.ID, error) { +func (r *CallRecorder) Handle(ctx context.Context, header http.Header, calls []dispatch.Call) ([]dispatch.ID, error) { base := r.calls r.calls += len(calls) r.requests = append(r.requests, DispatchRequest{ - ApiKey: apiKey, - Calls: calls, + Header: header.Clone(), + Calls: slices.Clone(calls), }) ids := make([]dispatch.ID, len(calls)) @@ -102,17 +52,35 @@ func (r *CallRecorder) Handle(ctx context.Context, apiKey string, calls []dispat return ids, nil } +// Assert asserts that specific calls were made to the Dispatch API server, +// and that specified headers are present. +// +// When validating request headers, Assert checks that the specified headers +// were present, but allows extra headers on the request. That is, it's not +// checking for an exact match with headers. func (r *CallRecorder) Assert(t *testing.T, want ...DispatchRequest) { t.Helper() got := r.requests if len(got) != len(want) { - t.Fatalf("unexpected number of requests: got %v, want %v", len(got), len(want)) + t.Errorf("unexpected number of requests: got %v, want %v", len(got), len(want)) } for i, req := range got { - if req.ApiKey != want[i].ApiKey { - t.Errorf("unexpected API key on request %d: got %v, want %v", i, req.ApiKey, want[i].ApiKey) + if i >= len(want) { + break + } + + // Check headers. + for name, want := range want[i].Header { + got, ok := req.Header[name] + if !ok { + t.Errorf("missing %s header in request %d", name, i) + } else if !slices.Equal(got, want) { + t.Errorf("unexpected %s header in request %d: got %v, want %v", name, i, got, want) + } } + + // Check calls. if len(req.Calls) != len(want[i].Calls) { t.Errorf("unexpected number of calls in request %d: got %v, want %v", i, len(req.Calls), len(want[i].Calls)) } else { diff --git a/error.go b/error.go index f827552..896a4da 100644 --- a/error.go +++ b/error.go @@ -13,6 +13,7 @@ import ( "reflect" "strings" + "connectrpc.com/connect" "golang.org/x/sys/unix" ) @@ -148,6 +149,9 @@ func errorStatus(err error, depth int) Status { case *tls.RecordHeaderError: return TLSErrorStatus + case *connect.Error: + return connectErrorStatus(e) + case status: return e.Status() @@ -211,6 +215,45 @@ func errnoStatus(errno unix.Errno) Status { } } +func connectErrorStatus(err *connect.Error) Status { + switch err.Code() { + case connect.CodeCanceled: // 408 Request Timeout + return TimeoutStatus + case connect.CodeUnknown: // 500 Internal Server Error + return TemporaryErrorStatus + case connect.CodeInvalidArgument: // 400 Bad Request + return InvalidArgumentStatus + case connect.CodeDeadlineExceeded: // 408 Request Timeout + return TimeoutStatus + case connect.CodeNotFound: // 404 Not Found + return NotFoundStatus + case connect.CodeAlreadyExists: // 409 Conflict + return PermanentErrorStatus + case connect.CodePermissionDenied: // 403 Forbidden + return PermissionDeniedStatus + case connect.CodeResourceExhausted: // 429 Too Many Requests + return ThrottledStatus + case connect.CodeFailedPrecondition: // 412 Precondition Failed + return PermanentErrorStatus + case connect.CodeAborted: // 409 Conflict + return PermanentErrorStatus + case connect.CodeOutOfRange: // 400 Bad Request + return InvalidArgumentStatus + case connect.CodeUnimplemented: // 404 Not Found + return NotFoundStatus + case connect.CodeInternal: // 500 Internal Server Error + return TemporaryErrorStatus + case connect.CodeUnavailable: // 503 Service Unavailable + return TemporaryErrorStatus + case connect.CodeDataLoss: // 500 Internal Server Error + return PermanentErrorStatus + case connect.CodeUnauthenticated: // 401 Unauthorized + return UnauthenticatedStatus + default: + return PermanentErrorStatus + } +} + func isIOError(err error) bool { switch err { case io.EOF, diff --git a/error_test.go b/error_test.go index 962b703..dc5ef0d 100644 --- a/error_test.go +++ b/error_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/dispatchrun/dispatch-go" ) @@ -703,6 +704,147 @@ func TestErrorStatus(t *testing.T) { status: dispatch.InvalidResponseStatus, }, + // The SDK uses the connect library when remotely interacting with functions. + // Connect uses gRPC error codes. Check that the correct Dispatch status is + // derived from these error codes. + + { + scenario: "connect.CodeCanceled", + error: func(*testing.T) error { + return connect.NewError(connect.CodeCanceled, errors.New("the request was canceled")) + }, + status: dispatch.TimeoutStatus, + }, + + { + scenario: "connect.CodeUnknown", + error: func(*testing.T) error { + return connect.NewError(connect.CodeUnknown, errors.New("unknown")) + }, + status: dispatch.TemporaryErrorStatus, + }, + + { + scenario: "connect.CodeInvalidArgument", + error: func(*testing.T) error { + underlying := connect.NewError(connect.CodeInvalidArgument, errors.New("invalid argument")) + return fmt.Errorf("something went wrong: %w", underlying) + }, + status: dispatch.InvalidArgumentStatus, + }, + + { + scenario: "connect.CodeDeadlineExceeded", + error: func(*testing.T) error { + return connect.NewError(connect.CodeDeadlineExceeded, errors.New("deadline exceeded")) + }, + status: dispatch.TimeoutStatus, + }, + + { + scenario: "connect.CodeNotFound", + error: func(*testing.T) error { + return connect.NewError(connect.CodeNotFound, errors.New("not found")) + }, + status: dispatch.NotFoundStatus, + }, + + { + scenario: "connect.CodeAlreadyExists", + error: func(*testing.T) error { + return connect.NewError(connect.CodeAlreadyExists, errors.New("already exists")) + }, + status: dispatch.PermanentErrorStatus, + }, + + { + scenario: "connect.CodePermissionDenied", + error: func(*testing.T) error { + return connect.NewError(connect.CodePermissionDenied, errors.New("permission denied")) + }, + status: dispatch.PermissionDeniedStatus, + }, + + { + scenario: "connect.CodeResourceExhausted", + error: func(*testing.T) error { + return connect.NewError(connect.CodeResourceExhausted, errors.New("resource exhausted")) + }, + status: dispatch.ThrottledStatus, + }, + + { + scenario: "connect.CodeFailedPrecondition", + error: func(*testing.T) error { + return connect.NewError(connect.CodeFailedPrecondition, errors.New("failed precondition")) + }, + status: dispatch.PermanentErrorStatus, + }, + + { + scenario: "connect.CodeAborted", + error: func(*testing.T) error { + return connect.NewError(connect.CodeAborted, errors.New("aborted")) + }, + status: dispatch.PermanentErrorStatus, + }, + + { + scenario: "connect.CodeOutOfRange", + error: func(*testing.T) error { + return connect.NewError(connect.CodeOutOfRange, errors.New("out of range")) + }, + status: dispatch.InvalidArgumentStatus, + }, + + { + scenario: "connect.CodeUnimplemented", + error: func(*testing.T) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("unimplemented")) + }, + status: dispatch.NotFoundStatus, + }, + + { + scenario: "connect.CodeInternal", + error: func(*testing.T) error { + return connect.NewError(connect.CodeInternal, errors.New("internal")) + }, + status: dispatch.TemporaryErrorStatus, + }, + + { + scenario: "connect.CodeUnavailable", + error: func(*testing.T) error { + return connect.NewError(connect.CodeUnavailable, errors.New("unavailable")) + }, + status: dispatch.TemporaryErrorStatus, + }, + + { + scenario: "connect.CodeDataLoss", + error: func(*testing.T) error { + return connect.NewError(connect.CodeDataLoss, errors.New("data loss")) + }, + status: dispatch.PermanentErrorStatus, + }, + + { + scenario: "connect.CodeUnauthenticated", + error: func(*testing.T) error { + return connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + }, + status: dispatch.UnauthenticatedStatus, + }, + + { + scenario: "connect.CodeUnauthenticated", + error: func(*testing.T) error { + return connect.NewError(connect.Code(9999), errors.New("unknown")) + }, + status: dispatch.PermanentErrorStatus, + }, + // The default behavior is to assume permanent errors, but we still want // to validate that a few common cases are handled as expected. //