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