[v4] support error handling flow like gRPC (#158)
Some checks failed
sync / sync (push) Failing after 1h5m56s
test / test (push) Failing after 1h9m11s
coverage / build (push) Failing after 1h9m22s

* add status package with tests and integrate into response parsing
* improve unit-tests
* improve readme
This commit is contained in:
2025-09-30 12:28:39 +05:00
committed by GitHub
parent 9bb2f8cffa
commit 6209a03044
7 changed files with 308 additions and 26 deletions

View File

@@ -139,6 +139,7 @@ err := c.Call(
```go
import (
http "go.unistack.org/micro-client-http/v4"
status "go.unistack.org/micro-client-http/v4/status"
jsoncodec "go.unistack.org/micro-codec-json/v4"
)
@@ -151,4 +152,13 @@ err := c.Call(
"403": &protoSpecialError{}, // <- key is the HTTP status code that is mapped to this error
}),
)
if err != nil {
s, ok := status.FromError(err)
if !ok {...}
code := s.Code() // HTTP status code
message := s.Message() // HTTP status text
details := s.Details() // Error type mapped from ErrorMap
}
```

View File

@@ -16,6 +16,8 @@ import (
"go.unistack.org/micro/v4/logger"
"go.unistack.org/micro/v4/metadata"
"go.unistack.org/micro/v4/selector"
"go.unistack.org/micro-client-http/v4/status"
)
func (c *Client) fnCall(ctx context.Context, req client.Request, rsp any, opts ...client.CallOption) error {
@@ -246,6 +248,8 @@ func (c *Client) parseRsp(ctx context.Context, hrsp *http.Response, rsp any, opt
return nil
}
s := status.New(hrsp.StatusCode)
var mappedErr any
errMap, ok := errorMapFromOpts(opts)
@@ -257,17 +261,12 @@ func (c *Client) parseRsp(ctx context.Context, hrsp *http.Response, rsp any, opt
}
if !ok || mappedErr == nil {
return errors.New("go.micro.client", string(buf), int32(hrsp.StatusCode))
return s.Err()
}
if err = cf.Unmarshal(buf, mappedErr); err != nil {
return errors.InternalServerError("go.micro.client", "unmarshal response: %v", err)
}
if v, ok := mappedErr.(error); ok {
return v
}
// if the error map item does not implement the error interface, wrap it
return &Error{err: mappedErr}
return s.WithDetails(mappedErr).Err()
}

View File

@@ -11,11 +11,13 @@ import (
"github.com/stretchr/testify/require"
jsoncodec "go.unistack.org/micro-codec-json/v4"
"go.unistack.org/micro/v4/client"
microerr "go.unistack.org/micro/v4/errors"
"go.unistack.org/micro/v4/metadata"
"google.golang.org/protobuf/proto"
httpcli "go.unistack.org/micro-client-http/v4"
pb "go.unistack.org/micro-client-http/v4/builder/proto"
"go.unistack.org/micro-client-http/v4/status"
)
func TestClient_Call_Get(t *testing.T) {
@@ -648,7 +650,7 @@ func TestClient_Call_Delete(t *testing.T) {
}
}
func TestClient_Call_ErrorsMap(t *testing.T) {
func TestClient_Call_APIError_WithErrorsMap(t *testing.T) {
type (
request = pb.Test_Client_Call_Request
response = pb.Test_Client_Call_Response
@@ -659,7 +661,7 @@ func TestClient_Call_ErrorsMap(t *testing.T) {
tests := []struct {
name string
serverMock func() *httptest.Server
expectedErr error
expectedStatus *status.Status
}{
{
name: "default error",
@@ -699,7 +701,12 @@ func TestClient_Call_ErrorsMap(t *testing.T) {
require.NoError(t, err)
}))
},
expectedErr: &defaultError{Code: "default-error-code", Msg: "default-error-message"},
expectedStatus: status.New(http.StatusBadRequest).WithDetails(
&defaultError{
Code: "default-error-code",
Msg: "default-error-message",
},
),
},
{
name: "special error",
@@ -740,7 +747,13 @@ func TestClient_Call_ErrorsMap(t *testing.T) {
require.NoError(t, err)
}))
},
expectedErr: &specialError{Code: "special-error-code", Msg: "special-error-message", Warning: "special-error-warning"},
expectedStatus: status.New(http.StatusForbidden).WithDetails(
&specialError{
Code: "special-error-code",
Msg: "special-error-message",
Warning: "special-error-warning",
},
),
},
}
@@ -783,7 +796,11 @@ func TestClient_Call_ErrorsMap(t *testing.T) {
opts...,
)
require.Equal(t, tt.expectedErr.Error(), err.Error())
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
require.NotNil(t, s)
require.Equal(t, tt.expectedStatus, s)
require.Empty(t, rsp)
require.Equal(t, "application/json", respMetadata.GetJoined("Content-Type"))
@@ -792,6 +809,91 @@ func TestClient_Call_ErrorsMap(t *testing.T) {
}
}
func TestClient_Call_APIError_WithoutErrorsMap(t *testing.T) {
type (
request = pb.Test_Client_Call_Request
response = pb.Test_Client_Call_Response
)
serverMock := func() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Validate request
require.Equal(t, "POST", r.Method)
require.Equal(t, "/user/products", r.URL.RequestURI())
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
require.Equal(t, "My-Header-Value", r.Header.Get("My-Header"))
buf, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
c := jsoncodec.NewCodec()
req := &request{}
err = c.Unmarshal(buf, req)
require.NoError(t, err)
require.True(t, proto.Equal(&request{UserId: "123", OrderId: 456}, req))
// Return response
w.Header().Set("Content-Type", "application/json")
w.Header().Set("My-Header", "My-Header-Value")
w.WriteHeader(http.StatusConflict)
resp := map[string]interface{}{"message": "not-mapped-error"}
buf, err = c.Marshal(resp)
require.NoError(t, err)
_, err = w.Write(buf)
require.NoError(t, err)
}))
}
expectedStatus := status.New(http.StatusConflict)
server := serverMock()
defer server.Close()
httpClient := httpcli.NewClient(
client.Codec("application/json", jsoncodec.NewCodec()),
)
var (
ctx = metadata.NewOutgoingContext(
context.Background(),
metadata.Pairs("Authorization", "Bearer token", "My-Header", "My-Header-Value"),
)
req = &request{UserId: "123", OrderId: 456}
rsp = &response{}
respMetadata = metadata.Metadata{}
)
opts := []client.CallOption{
client.WithAddress(server.URL),
client.WithResponseMetadata(&respMetadata),
httpcli.Method(http.MethodPost),
httpcli.Path("/user/products"),
httpcli.Body("*"),
}
err := httpClient.Call(
ctx,
httpClient.NewRequest("test.service", "Test.Call", req),
rsp,
opts...,
)
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
require.NotNil(t, s)
require.Equal(t, expectedStatus, s)
require.Empty(t, rsp)
require.Equal(t, "application/json", respMetadata.GetJoined("Content-Type"))
require.Equal(t, "My-Header-Value", respMetadata.GetJoined("My-Header"))
}
func TestClient_Call_HeadersAndCookies(t *testing.T) {
type (
request = pb.Test_Client_Call_Request
@@ -1197,6 +1299,12 @@ func TestClient_Call_RequestTimeoutError(t *testing.T) {
opts...,
)
require.Error(t, err)
require.Equal(t, err, &microerr.Error{
ID: "go.micro.client",
Detail: "context deadline exceeded",
Status: "Request Timeout",
Code: http.StatusRequestTimeout,
})
}
func TestClient_Call_ContextDeadlineError(t *testing.T) {
@@ -1239,6 +1347,12 @@ func TestClient_Call_ContextDeadlineError(t *testing.T) {
opts...,
)
require.Error(t, err)
require.Equal(t, err, &microerr.Error{
ID: "go.micro.client",
Detail: "context deadline exceeded",
Status: "Request Timeout",
Code: http.StatusRequestTimeout,
})
}
func TestClient_Call_ContextCanceled(t *testing.T) {
@@ -1281,4 +1395,10 @@ func TestClient_Call_ContextCanceled(t *testing.T) {
opts...,
)
require.Error(t, err)
require.Equal(t, err, &microerr.Error{
ID: "go.micro.client",
Detail: "context canceled",
Status: "Request Timeout",
Code: http.StatusRequestTimeout,
})
}

View File

@@ -1,12 +0,0 @@
package http
import "fmt"
// Error is used when items in the error map do not implement the error interface and need to be wrapped.
type Error struct {
err any
}
func (e *Error) Error() string {
return fmt.Sprintf("%+v", e.err)
}

14
status/error.go Normal file
View File

@@ -0,0 +1,14 @@
package status
// Error is a thin wrapper around Status that implements the error interface.
type Error struct {
s *Status
}
func (e *Error) Error() string {
return e.s.String()
}
func (e *Error) HTTPStatus() *Status {
return e.s
}

54
status/status.go Normal file
View File

@@ -0,0 +1,54 @@
package status
import (
"errors"
"fmt"
"net/http"
)
// Status represents the outcome of an HTTP request in a style similar to gRPC status.
type Status struct {
code int // HTTP status code
message string // HTTP status text
details any // parsed error object
}
func New(statusCode int) *Status {
return &Status{code: statusCode, message: http.StatusText(statusCode)}
}
func FromError(err error) (*Status, bool) {
if err == nil {
return nil, false
}
var e *Error
if errors.As(err, &e) {
return e.HTTPStatus(), true
}
return nil, false
}
func (s *Status) Code() int {
return s.code
}
func (s *Status) Message() string {
return s.message
}
func (s *Status) Details() any {
return s.details
}
func (s *Status) WithDetails(details any) *Status {
s.details = details
return s
}
func (s *Status) String() string {
return fmt.Sprintf("http error: code = %d desc = %s", s.Code(), s.Message())
}
func (s *Status) Err() error {
return &Error{s: s}
}

97
status/status_test.go Normal file
View File

@@ -0,0 +1,97 @@
package status_test
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"go.unistack.org/micro-client-http/v4/status"
)
type fakeError struct{ s *status.Status }
func (fe *fakeError) Error() string {
return fe.s.String()
}
func TestNew(t *testing.T) {
s := status.New(http.StatusNotFound)
require.Equal(t, http.StatusNotFound, s.Code())
require.Equal(t, "Not Found", s.Message())
}
func TestFromError(t *testing.T) {
tests := []struct {
name string
input error
wantStatus *status.Status
wantOK bool
}{
{
name: "nil error",
input: nil,
wantStatus: nil,
wantOK: false,
},
{
name: "simple error",
input: errors.New("some error"),
wantStatus: nil,
wantOK: false,
},
{
name: "unexpected type of error",
input: func() error {
return &fakeError{s: status.New(http.StatusNotFound)}
}(),
wantStatus: nil,
wantOK: false,
},
{
name: "expected type of error",
input: status.New(http.StatusNotFound).Err(),
wantStatus: status.New(http.StatusNotFound),
wantOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, ok := status.FromError(tt.input)
require.Equal(t, tt.wantStatus, result)
require.Equal(t, tt.wantOK, ok)
})
}
}
func TestStatus_Code(t *testing.T) {
s := status.New(http.StatusNotFound)
require.Equal(t, http.StatusNotFound, s.Code())
}
func TestStatus_Message(t *testing.T) {
s := status.New(http.StatusNotFound)
require.Equal(t, "Not Found", s.Message())
}
func TestStatus_WithDetails(t *testing.T) {
s := status.New(http.StatusNotFound).WithDetails(errors.New("some error"))
require.Equal(t, errors.New("some error"), s.Details())
}
func TestStatus_String(t *testing.T) {
s := status.New(http.StatusInternalServerError)
expected := fmt.Sprintf("http error: code = %d desc = %s", 500, "Internal Server Error")
require.Equal(t, expected, s.String())
}
func TestStatus_Err(t *testing.T) {
var e *status.Error
s := status.New(http.StatusForbidden)
require.Error(t, s.Err())
require.ErrorAs(t, s.Err(), &e)
require.Equal(t, status.New(http.StatusForbidden), e.HTTPStatus())
}