From ca196ff70b9083bc4265abec1e4e56fe487c214e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Vall=C3=A9s?= Date: Fri, 15 Dec 2023 07:45:56 +0100 Subject: [PATCH] feat: add package to handle end-user error messages. Because - Connector errors produce an unfriendly output in VDP. - In order to trigger a pipeline, several repositories are involved in the execution. This commit - Implements a way to add and extract end-user messages to errors. --- errmsg/README.md | 50 ++++++++++++++++++++++++++ errmsg/errmsg.go | 67 +++++++++++++++++++++++++++++++++++ errmsg/errmsg_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 6 ++++ go.sum | 11 ++++-- 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 errmsg/README.md create mode 100644 errmsg/errmsg.go create mode 100644 errmsg/errmsg_test.go diff --git a/errmsg/README.md b/errmsg/README.md new file mode 100644 index 0000000..29e65b1 --- /dev/null +++ b/errmsg/README.md @@ -0,0 +1,50 @@ +# errmsg + +Add end-user messages to errors. + +`err.Error()` doesn't usually provide a human-friendly output. `errmsg` allows +errors to carry an (extendable) end-user message that can be used in e.g. +handlers. + +Here is an example on how it can be used: + +```go +package connector + +import ( + // ... + "github.com/instill-ai/x/errmsg" +) + +func (c *Client) sendReq(reqURL, method, contentType string, data io.Reader) ([]byte, error) { + // ... + + res, err := c.HTTPClient.Do(req) + if err != nil { + err := fmt.Errorf("failed to call connector vendor: %w", err) + return nil, errmsg.AddMessage(err, "Failed to call Vendor API.") + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + err := fmt.Errorf("vendor responded with status code %d", res.StatusCode) + msg := fmt.Sprintf("Vendor responded with a %d status code.", res.StatusCode) + return nil, errmsg.AddMessage(err, msg) + } + + // ... +} +``` + +```go +package handler + +func (h *PublicHandler) DoAction(ctx context.Context, req *pb.DoActionRequest) (*pb.DoActionResponse, error) { + resp, err := h.triggerActionSteps(ctx, req) + if err != nil { + resp.Outputs, resp.Metadata, err = h.triggerNamespacePipeline(ctx, req) + return nil, status.Error(asGRPCStatus(err), errmsg.MessageOrErr(err)) + } + + return resp, nil +} +``` diff --git a/errmsg/errmsg.go b/errmsg/errmsg.go new file mode 100644 index 0000000..605128d --- /dev/null +++ b/errmsg/errmsg.go @@ -0,0 +1,67 @@ +package errmsg + +import ( + "errors" + "fmt" +) + +// endUserError is an error that holds an end-user message. +type endUserError struct { + message string + cause error +} + +// Error implements the error interface by returning the internal error message. +func (e *endUserError) Error() string { return e.cause.Error() } + +// Unwrap implements the Unwrap interface. +func (e *endUserError) Unwrap() error { return e.cause } + +// As implements the required function to ensure errors.As can properly match +// endUserEror targets. +func (e *endUserError) As(target any) bool { + if tgt, ok := target.(**endUserError); ok { + *tgt = e + return true + } + + return false +} + +// AddMessage adds an end-user message to an error, prepending it to any +// potential existing message. +func AddMessage(err error, msg string) error { + if msgInCause := Message(err); msgInCause != "" { + msg = fmt.Sprintf("%s %s", msg, msgInCause) + } + + return &endUserError{ + cause: err, + message: msg, + } +} + +// Message extracts an end-user message from the error. +func Message(err error) string { + for err != nil { + eu := new(endUserError) + if errors.As(err, &eu) && eu.message != "" { + return eu.message + } + + err = errors.Unwrap(err) + } + + return "" +} + +// MessageOrErr extracts an end-user message from the error. If no message is +// found, err.Error() is returned. +func MessageOrErr(err error) string { + msg := Message(err) + if msg == "" { + return err.Error() + } + + return msg +} diff --git a/errmsg/errmsg_test.go b/errmsg/errmsg_test.go new file mode 100644 index 0000000..17234fb --- /dev/null +++ b/errmsg/errmsg_test.go @@ -0,0 +1,82 @@ +package errmsg + +import ( + "errors" + "fmt" + "testing" + + qt "github.com/frankban/quicktest" + pkgerrors "github.com/pkg/errors" +) + +func TestAddAndExtractMessage(t *testing.T) { + c := qt.New(t) + + testcases := []struct { + name string + wantMsg string + wantErr string + err error + }{ + { + name: "no message", + wantMsg: "boom", + wantErr: "boom", + err: errors.New("boom"), + }, + { + name: "message on top of stack", + wantMsg: "Something went wrong.", + wantErr: "boom", + err: AddMessage(errors.New("boom"), "Something went wrong."), + }, + { + name: "message in wrapped error (fmt)", + wantMsg: "Something went wrong.", + wantErr: "bang: boom", + err: fmt.Errorf( + "bang: %w", + AddMessage(errors.New("boom"), "Something went wrong."), + ), + }, + { + name: "message in wrapped error (pkgerrors.Wrap)", + wantMsg: "Something went wrong.", + wantErr: "bang: boom", + err: pkgerrors.Wrap( + AddMessage(pkgerrors.New("boom"), "Something went wrong."), + "bang", + ), + }, + { + name: "message in joint error", + wantMsg: "Something went wrong.", + wantErr: "bang\nboom", + err: errors.Join( + errors.New("bang"), + AddMessage(errors.New("boom"), "Something went wrong."), + ), + }, + { + name: "multi-message error", + wantMsg: "An error happened. Something went wrong.", + wantErr: "bang: boom", + err: AddMessage( + // handle error coming from downstream + fmt.Errorf("bang: %w", + // downstream error also contains message + AddMessage(errors.New("boom"), "Something went wrong."), + ), + // add message to downstream error + "An error happened.", + ), + }, + } + + for _, tc := range testcases { + c.Run(tc.name, func(c *qt.C) { + c.Check(MessageOrErr(tc.err), qt.Equals, tc.wantMsg) + c.Check(tc.err, qt.ErrorMatches, tc.wantErr) + }) + } +} diff --git a/go.mod b/go.mod index e371c53..0d41938 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/instill-ai/x go 1.21 require ( + github.com/frankban/quicktest v1.14.6 github.com/google/uuid v1.3.0 github.com/iancoleman/strcase v0.2.0 + github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.7.0 go.temporal.io/sdk v1.13.1 go.uber.org/zap v1.21.0 @@ -21,10 +23,14 @@ require ( github.com/gogo/status v1.1.0 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/stretchr/objx v0.3.0 // indirect go.temporal.io/api v1.6.1-0.20211110205628-60c98e9cbfe2 // indirect go.uber.org/atomic v1.9.0 // indirect diff --git a/go.sum b/go.sum index 9696e13..cf866cf 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go. github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -65,8 +67,9 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -81,6 +84,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -90,6 +95,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -98,6 +104,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -201,7 +209,6 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=