github.com/instill-ai/component@v0.16.0-beta/pkg/connector/util/httpclient/httpclient.go (about) 1 package httpclient 2 3 import ( 4 "errors" 5 "fmt" 6 "net/http" 7 "net/url" 8 "time" 9 10 "github.com/go-resty/resty/v2" 11 "go.uber.org/zap" 12 13 "github.com/instill-ai/x/errmsg" 14 ) 15 16 const ( 17 reqTimeout = time.Second * 60 * 5 18 19 // MIMETypeJSON defines the MIME type for JSON documents. 20 MIMETypeJSON = "application/json" 21 ) 22 23 // Client performs HTTP requests for connectors, implementing error handling 24 // and logging in a consistent way. 25 type Client struct { 26 *resty.Client 27 28 name string 29 } 30 31 // Option provides configuration options for a client. 32 type Option func(*Client) 33 34 // WithLogger will use the provider logger to log the request and response 35 // information. 36 func WithLogger(logger *zap.Logger) Option { 37 return func(c *Client) { 38 logger := logger.With(zap.String("name", c.name)) 39 40 c.SetLogger(logger.Sugar()).OnError(func(req *resty.Request, err error) { 41 logger := logger.With(zap.String("url", req.URL)) 42 43 if v, ok := err.(*resty.ResponseError); ok { 44 logger = logger.With( 45 zap.Int("status", v.Response.StatusCode()), 46 zap.ByteString("body", v.Response.Body()), 47 ) 48 } 49 50 logger.Warn("HTTP request failed", zap.Error(err)) 51 }) 52 } 53 } 54 55 // ErrBody allows Client to extract an error message from the API. 56 type ErrBody interface { 57 Message() string 58 } 59 60 func wrapWithErrMessage(apiName string) func(*resty.Client, *resty.Response) error { 61 return func(_ *resty.Client, resp *resty.Response) error { 62 if !resp.IsError() { 63 return nil 64 } 65 66 var issue string 67 68 if v, ok := resp.Error().(ErrBody); ok && v.Message() != "" { 69 issue = v.Message() 70 } 71 72 // Certain errors are returned as text/plain, e.g. incorrect API key 73 // (401) vs invalid /query request (400) in Pinecone. 74 // This is also a fallback if the error format is unexpected. It's 75 // better to pass the error response to the user than displaying 76 // nothing. 77 if issue == "" { 78 issue = resp.String() 79 } 80 81 if issue == "" { 82 issue = fmt.Sprintf("Please refer to %s's API reference for more information.", apiName) 83 } 84 85 msg := fmt.Sprintf("%s responded with a %d status code. %s", apiName, resp.StatusCode(), issue) 86 return errmsg.AddMessage(fmt.Errorf("unsuccessful HTTP response"), msg) 87 } 88 } 89 90 // WithEndUserError will unmarshal error response bodies as the error struct 91 // and will use their message as an end-user error. 92 func WithEndUserError(e ErrBody) Option { 93 return func(c *Client) { 94 c.SetError(e).OnAfterResponse(wrapWithErrMessage(c.name)) 95 } 96 } 97 98 // New returns an httpclient configured to call a remote host. 99 func New(name, host string, options ...Option) *Client { 100 r := resty.New(). 101 SetBaseURL(host). 102 SetHeader("Accept", MIMETypeJSON). 103 SetTimeout(reqTimeout). 104 SetTransport(&http.Transport{ 105 DisableKeepAlives: true, 106 }) 107 108 c := &Client{ 109 Client: r, 110 name: name, 111 } 112 113 for _, o := range options { 114 o(c) 115 } 116 117 return c 118 } 119 120 // WrapURLError is a helper to add an end-user message to trasnport errros. 121 // 122 // Resty doesn't provide a hook for errors in `http.Client.Do`, e.g. if the 123 // connector configuration contains an invalid URL. This wrapper offers 124 // clients a way to handle such cases: 125 // 126 // if _, err := httpclient.New(name, host).R().Post(url); err != nil { 127 // return nil, httpclient.WrapURLError(err) 128 // } 129 func WrapURLError(err error) error { 130 uerr := new(url.Error) 131 if errors.As(err, &uerr) { 132 err = errmsg.AddMessage( 133 err, 134 fmt.Sprintf("Failed to call %s. Please check that the connector configuration is correct.", uerr.URL), 135 ) 136 } 137 138 return err 139 }