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  }