errors.go raw

   1  package linodego
   2  
   3  import (
   4  	"encoding/json"
   5  	"errors"
   6  	"fmt"
   7  	"io"
   8  	"net/http"
   9  	"reflect"
  10  	"slices"
  11  	"strings"
  12  
  13  	"github.com/go-resty/resty/v2"
  14  )
  15  
  16  const (
  17  	ErrorUnsupported = iota
  18  	// ErrorFromString is the Code identifying Errors created by string types
  19  	ErrorFromString
  20  	// ErrorFromError is the Code identifying Errors created by error types
  21  	ErrorFromError
  22  	// ErrorFromStringer is the Code identifying Errors created by fmt.Stringer types
  23  	ErrorFromStringer
  24  )
  25  
  26  // Error wraps the LinodeGo error with the relevant http.Response
  27  type Error struct {
  28  	Response *http.Response
  29  	Code     int
  30  	Message  string
  31  }
  32  
  33  // APIErrorReason is an individual invalid request message returned by the Linode API
  34  type APIErrorReason struct {
  35  	Reason string `json:"reason"`
  36  	Field  string `json:"field"`
  37  }
  38  
  39  func (r APIErrorReason) Error() string {
  40  	if len(r.Field) == 0 {
  41  		return r.Reason
  42  	}
  43  
  44  	return fmt.Sprintf("[%s] %s", r.Field, r.Reason)
  45  }
  46  
  47  // APIError is the error-set returned by the Linode API when presented with an invalid request
  48  type APIError struct {
  49  	Errors []APIErrorReason `json:"errors"`
  50  }
  51  
  52  // String returns the error reason in a formatted string
  53  func (r APIErrorReason) String() string {
  54  	return fmt.Sprintf("[%s] %s", r.Field, r.Reason)
  55  }
  56  
  57  func coupleAPIErrors(r *resty.Response, err error) (*resty.Response, error) {
  58  	if err != nil {
  59  		// an error was raised in go code, no need to check the resty Response
  60  		return nil, NewError(err)
  61  	}
  62  
  63  	if r.Error() == nil {
  64  		// no error in the resty Response
  65  		return r, nil
  66  	}
  67  
  68  	// handle the resty Response errors
  69  
  70  	// Check that response is of the correct content-type before unmarshalling
  71  	expectedContentType := r.Request.Header.Get("Accept")
  72  	responseContentType := r.Header().Get("Content-Type")
  73  
  74  	// If the upstream Linode API server being fronted fails to respond to the request,
  75  	// the http server will respond with a default "Bad Gateway" page with Content-Type
  76  	// "text/html".
  77  	if r.StatusCode() == http.StatusBadGateway && responseContentType == "text/html" { //nolint:goconst
  78  		return nil, Error{Code: http.StatusBadGateway, Message: http.StatusText(http.StatusBadGateway)}
  79  	}
  80  
  81  	if responseContentType != expectedContentType {
  82  		msg := fmt.Sprintf(
  83  			"Unexpected Content-Type: Expected: %v, Received: %v\nResponse body: %s",
  84  			expectedContentType,
  85  			responseContentType,
  86  			string(r.Body()),
  87  		)
  88  
  89  		return nil, Error{Code: r.StatusCode(), Message: msg}
  90  	}
  91  
  92  	apiError, ok := r.Error().(*APIError)
  93  	if !ok || (ok && len(apiError.Errors) == 0) {
  94  		return r, nil
  95  	}
  96  
  97  	return nil, NewError(r)
  98  }
  99  
 100  //nolint:unused
 101  func coupleAPIErrorsHTTP(resp *http.Response, err error) (*http.Response, error) {
 102  	if err != nil {
 103  		// an error was raised in go code, no need to check the http.Response
 104  		return nil, NewError(err)
 105  	}
 106  
 107  	if resp == nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
 108  		// Check that response is of the correct content-type before unmarshalling
 109  		expectedContentType := resp.Request.Header.Get("Accept")
 110  		responseContentType := resp.Header.Get("Content-Type")
 111  
 112  		// If the upstream server fails to respond to the request,
 113  		// the http server will respond with a default error page with Content-Type "text/html".
 114  		if resp.StatusCode == http.StatusBadGateway && responseContentType == "text/html" { //nolint:goconst
 115  			return nil, Error{Code: http.StatusBadGateway, Message: http.StatusText(http.StatusBadGateway)}
 116  		}
 117  
 118  		if responseContentType != expectedContentType {
 119  			bodyBytes, _ := io.ReadAll(resp.Body)
 120  			msg := fmt.Sprintf(
 121  				"Unexpected Content-Type: Expected: %v, Received: %v\nResponse body: %s",
 122  				expectedContentType,
 123  				responseContentType,
 124  				string(bodyBytes),
 125  			)
 126  
 127  			return nil, Error{Code: resp.StatusCode, Message: msg}
 128  		}
 129  
 130  		var apiError APIError
 131  		if err := json.NewDecoder(resp.Body).Decode(&apiError); err != nil {
 132  			return nil, NewError(fmt.Errorf("failed to decode response body: %w", err))
 133  		}
 134  
 135  		if len(apiError.Errors) == 0 {
 136  			return resp, nil
 137  		}
 138  
 139  		return nil, Error{Code: resp.StatusCode, Message: apiError.Errors[0].String()}
 140  	}
 141  
 142  	// no error in the http.Response
 143  	return resp, nil
 144  }
 145  
 146  func (e APIError) Error() string {
 147  	x := []string{}
 148  	for _, msg := range e.Errors {
 149  		x = append(x, msg.Error())
 150  	}
 151  
 152  	return strings.Join(x, "; ")
 153  }
 154  
 155  // NewError creates a linodego.Error with a Code identifying the source err type,
 156  // - ErrorFromString   (1) from a string
 157  // - ErrorFromError    (2) for an error
 158  // - ErrorFromStringer (3) for a Stringer
 159  // - HTTP Status Codes (100-600) for a resty.Response object
 160  func NewError(err any) *Error {
 161  	if err == nil {
 162  		return nil
 163  	}
 164  
 165  	switch e := err.(type) {
 166  	case *Error:
 167  		return e
 168  	case *resty.Response:
 169  		apiError, ok := e.Error().(*APIError)
 170  
 171  		if !ok {
 172  			return &Error{Code: ErrorUnsupported, Message: "Unexpected Resty Error Response, no error"}
 173  		}
 174  
 175  		return &Error{
 176  			Code:     e.RawResponse.StatusCode,
 177  			Message:  apiError.Error(),
 178  			Response: e.RawResponse,
 179  		}
 180  	case error:
 181  		return &Error{Code: ErrorFromError, Message: e.Error()}
 182  	case string:
 183  		return &Error{Code: ErrorFromString, Message: e}
 184  	case fmt.Stringer:
 185  		return &Error{Code: ErrorFromStringer, Message: e.String()}
 186  	default:
 187  		return &Error{Code: ErrorUnsupported, Message: fmt.Sprintf("Unsupported type to linodego.NewError: %s", reflect.TypeOf(e))}
 188  	}
 189  }
 190  
 191  func (err Error) Error() string {
 192  	return fmt.Sprintf("[%03d] %s", err.Code, err.Message)
 193  }
 194  
 195  func (err Error) StatusCode() int {
 196  	return err.Code
 197  }
 198  
 199  func (err Error) Is(target error) bool {
 200  	if x, ok := target.(interface{ StatusCode() int }); ok || errors.As(target, &x) {
 201  		return err.StatusCode() == x.StatusCode()
 202  	}
 203  
 204  	return false
 205  }
 206  
 207  // IsNotFound indicates if err indicates a 404 Not Found error from the Linode API.
 208  func IsNotFound(err error) bool {
 209  	return ErrHasStatus(err, http.StatusNotFound)
 210  }
 211  
 212  // ErrHasStatus checks if err is an error from the Linode API, and whether it contains the given HTTP status code.
 213  // More than one status code may be given.
 214  // If len(code) == 0, err is nil or is not a [Error], ErrHasStatus will return false.
 215  func ErrHasStatus(err error, code ...int) bool {
 216  	if err == nil {
 217  		return false
 218  	}
 219  
 220  	// Short-circuit if the caller did not provide any status codes.
 221  	if len(code) == 0 {
 222  		return false
 223  	}
 224  
 225  	var e *Error
 226  	if !errors.As(err, &e) {
 227  		return false
 228  	}
 229  
 230  	ec := e.StatusCode()
 231  
 232  	return slices.Contains(code, ec)
 233  }
 234