1
16
17 package rest
18
19 import (
20 "context"
21 "errors"
22 "fmt"
23 "io"
24 "net/http"
25 "net/url"
26 "reflect"
27 "strings"
28 "testing"
29 "time"
30
31 "github.com/google/go-cmp/cmp"
32 )
33
34 var alwaysRetryError = IsRetryableErrorFunc(func(_ *http.Request, _ error) bool {
35 return true
36 })
37
38 func TestIsNextRetry(t *testing.T) {
39 fakeError := errors.New("fake error")
40 tests := []struct {
41 name string
42 attempts int
43 maxRetries int
44 request *http.Request
45 response *http.Response
46 err error
47 retryableErrFunc IsRetryableErrorFunc
48 retryExpected []bool
49 retryAfterExpected []*RetryAfter
50 }{
51 {
52 name: "bad input, response and err are nil",
53 maxRetries: 2,
54 attempts: 1,
55 request: &http.Request{},
56 response: nil,
57 err: nil,
58 retryExpected: []bool{false},
59 retryAfterExpected: []*RetryAfter{nil},
60 },
61 {
62 name: "zero maximum retry",
63 maxRetries: 0,
64 attempts: 1,
65 request: &http.Request{},
66 response: retryAfterResponse(),
67 err: nil,
68 retryExpected: []bool{false},
69 retryAfterExpected: []*RetryAfter{
70 {
71 Attempt: 1,
72 },
73 },
74 },
75 {
76 name: "server returned a retryable error",
77 maxRetries: 3,
78 attempts: 1,
79 request: &http.Request{},
80 response: nil,
81 err: fakeError,
82 retryableErrFunc: func(_ *http.Request, err error) bool {
83 if err == fakeError {
84 return true
85 }
86 return false
87 },
88 retryExpected: []bool{true},
89 retryAfterExpected: []*RetryAfter{
90 {
91 Attempt: 1,
92 Wait: time.Second,
93 Reason: "retries: 1, retry-after: 1s - retry-reason: due to retryable error, error: fake error",
94 },
95 },
96 },
97 {
98 name: "server returned a retryable HTTP 429 response",
99 maxRetries: 3,
100 attempts: 1,
101 request: &http.Request{},
102 response: &http.Response{
103 StatusCode: http.StatusTooManyRequests,
104 Header: http.Header{
105 "Retry-After": []string{"2"},
106 "X-Kubernetes-Pf-Flowschema-Uid": []string{"fs-1"},
107 },
108 },
109 err: nil,
110 retryExpected: []bool{true},
111 retryAfterExpected: []*RetryAfter{
112 {
113 Attempt: 1,
114 Wait: 2 * time.Second,
115 Reason: `retries: 1, retry-after: 2s - retry-reason: due to server-side throttling, FlowSchema UID: "fs-1"`,
116 },
117 },
118 },
119 {
120 name: "server returned a retryable HTTP 5xx response",
121 maxRetries: 3,
122 attempts: 1,
123 request: &http.Request{},
124 response: &http.Response{
125 StatusCode: http.StatusServiceUnavailable,
126 Header: http.Header{
127 "Retry-After": []string{"3"},
128 },
129 },
130 err: nil,
131 retryExpected: []bool{true},
132 retryAfterExpected: []*RetryAfter{
133 {
134 Attempt: 1,
135 Wait: 3 * time.Second,
136 Reason: "retries: 1, retry-after: 3s - retry-reason: 503",
137 },
138 },
139 },
140 {
141 name: "server returned a non response without without a Retry-After header",
142 maxRetries: 1,
143 attempts: 1,
144 request: &http.Request{},
145 response: &http.Response{
146 StatusCode: http.StatusTooManyRequests,
147 Header: http.Header{},
148 },
149 err: nil,
150 retryExpected: []bool{false},
151 retryAfterExpected: []*RetryAfter{
152 {
153 Attempt: 1,
154 },
155 },
156 },
157 {
158 name: "both response and err are set, err takes precedence",
159 maxRetries: 1,
160 attempts: 1,
161 request: &http.Request{},
162 response: retryAfterResponse(),
163 err: fakeError,
164 retryableErrFunc: func(_ *http.Request, err error) bool {
165 if err == fakeError {
166 return true
167 }
168 return false
169 },
170 retryExpected: []bool{true},
171 retryAfterExpected: []*RetryAfter{
172 {
173 Attempt: 1,
174 Wait: time.Second,
175 Reason: "retries: 1, retry-after: 1s - retry-reason: due to retryable error, error: fake error",
176 },
177 },
178 },
179 {
180 name: "all retries are exhausted",
181 maxRetries: 3,
182 attempts: 4,
183 request: &http.Request{},
184 response: nil,
185 err: fakeError,
186 retryableErrFunc: alwaysRetryError,
187 retryExpected: []bool{true, true, true, false},
188 retryAfterExpected: []*RetryAfter{
189 {
190 Attempt: 1,
191 Wait: time.Second,
192 Reason: "retries: 1, retry-after: 1s - retry-reason: due to retryable error, error: fake error",
193 },
194 {
195 Attempt: 2,
196 Wait: time.Second,
197 Reason: "retries: 2, retry-after: 1s - retry-reason: due to retryable error, error: fake error",
198 },
199 {
200 Attempt: 3,
201 Wait: time.Second,
202 Reason: "retries: 3, retry-after: 1s - retry-reason: due to retryable error, error: fake error",
203 },
204 {
205 Attempt: 4,
206 },
207 },
208 },
209 }
210
211 for _, test := range tests {
212 t.Run(test.name, func(t *testing.T) {
213 restReq := &Request{
214 bodyBytes: []byte{},
215 c: &RESTClient{
216 base: &url.URL{},
217 },
218 }
219 r := &withRetry{maxRetries: test.maxRetries}
220
221 retryGot := make([]bool, 0)
222 retryAfterGot := make([]*RetryAfter, 0)
223 for i := 0; i < test.attempts; i++ {
224 retry := r.IsNextRetry(context.TODO(), restReq, test.request, test.response, test.err, test.retryableErrFunc)
225 retryGot = append(retryGot, retry)
226 retryAfterGot = append(retryAfterGot, r.retryAfter)
227 }
228
229 if !reflect.DeepEqual(test.retryExpected, retryGot) {
230 t.Errorf("Expected retry: %t, but got: %t", test.retryExpected, retryGot)
231 }
232 if !reflect.DeepEqual(test.retryAfterExpected, retryAfterGot) {
233 t.Errorf("Expected retry-after parameters to match, but got: %s", cmp.Diff(test.retryAfterExpected, retryAfterGot))
234 }
235 })
236 }
237 }
238
239 func TestWrapPreviousError(t *testing.T) {
240 const (
241 attempt = 2
242 previousAttempt = 1
243 containsFormatExpected = "- error from a previous attempt: %s"
244 )
245 var (
246 wrappedCtxDeadlineExceededErr = &url.Error{
247 Op: "GET",
248 URL: "http://foo.bar",
249 Err: context.DeadlineExceeded,
250 }
251 wrappedCtxCanceledErr = &url.Error{
252 Op: "GET",
253 URL: "http://foo.bar",
254 Err: context.Canceled,
255 }
256 urlEOFErr = &url.Error{
257 Op: "GET",
258 URL: "http://foo.bar",
259 Err: io.EOF,
260 }
261 )
262
263 tests := []struct {
264 name string
265 previousErr error
266 currentErr error
267 expectedErr error
268 wrapped bool
269 contains string
270 }{
271 {
272 name: "current error is nil, previous error is nil",
273 },
274 {
275 name: "current error is nil",
276 previousErr: errors.New("error from a previous attempt"),
277 },
278 {
279 name: "previous error is nil",
280 currentErr: urlEOFErr,
281 expectedErr: urlEOFErr,
282 wrapped: false,
283 },
284 {
285 name: "both current and previous errors represent the same error",
286 currentErr: urlEOFErr,
287 previousErr: &url.Error{Op: "GET", URL: "http://foo.bar", Err: io.EOF},
288 expectedErr: urlEOFErr,
289 },
290 {
291 name: "current and previous errors are not same",
292 currentErr: urlEOFErr,
293 previousErr: errors.New("unknown error"),
294 expectedErr: urlEOFErr,
295 wrapped: true,
296 contains: fmt.Sprintf(containsFormatExpected, "unknown error"),
297 },
298 {
299 name: "current error is context.Canceled",
300 currentErr: context.Canceled,
301 previousErr: io.EOF,
302 expectedErr: context.Canceled,
303 wrapped: true,
304 contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
305 },
306 {
307 name: "current error is context.DeadlineExceeded",
308 currentErr: context.DeadlineExceeded,
309 previousErr: io.EOF,
310 expectedErr: context.DeadlineExceeded,
311 wrapped: true,
312 contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
313 },
314 {
315 name: "current error is a wrapped context.DeadlineExceeded",
316 currentErr: wrappedCtxDeadlineExceededErr,
317 previousErr: io.EOF,
318 expectedErr: wrappedCtxDeadlineExceededErr,
319 wrapped: true,
320 contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
321 },
322 {
323 name: "current error is a wrapped context.Canceled",
324 currentErr: wrappedCtxCanceledErr,
325 previousErr: io.EOF,
326 expectedErr: wrappedCtxCanceledErr,
327 wrapped: true,
328 contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
329 },
330 {
331 name: "previous error should be unwrapped if it is url.Error",
332 currentErr: urlEOFErr,
333 previousErr: &url.Error{Err: io.ErrUnexpectedEOF},
334 expectedErr: urlEOFErr,
335 wrapped: true,
336 contains: fmt.Sprintf(containsFormatExpected, io.ErrUnexpectedEOF.Error()),
337 },
338 {
339 name: "previous error should not be unwrapped if it is not url.Error",
340 currentErr: urlEOFErr,
341 previousErr: fmt.Errorf("should be included in error message - %w", io.EOF),
342 expectedErr: urlEOFErr,
343 wrapped: true,
344 contains: fmt.Sprintf(containsFormatExpected, "should be included in error message - EOF"),
345 },
346 }
347
348 for _, test := range tests {
349 t.Run(test.name, func(t *testing.T) {
350 retry := &withRetry{
351 previousErr: test.previousErr,
352 }
353
354 err := retry.WrapPreviousError(test.currentErr)
355 switch {
356 case test.expectedErr == nil:
357 if err != nil {
358 t.Errorf("Expected a nil error, but got: %v", err)
359 return
360 }
361 case test.expectedErr != nil:
362
363
364 if !strings.Contains(err.Error(), test.contains) {
365 t.Errorf("Expected error message to contain %q, but got: %v", test.contains, err)
366 }
367
368 currentErrGot := err
369 if test.wrapped {
370 currentErrGot = errors.Unwrap(err)
371 }
372 if test.expectedErr != currentErrGot {
373 t.Errorf("Expected current error %v, but got: %v", test.expectedErr, currentErrGot)
374 }
375 }
376 })
377 }
378
379 t.Run("Before should track previous error", func(t *testing.T) {
380 retry := &withRetry{
381 currentErr: io.EOF,
382 }
383
384 ctx, cancel := context.WithCancel(context.Background())
385 cancel()
386
387
388
389 err := retry.Before(ctx, &Request{})
390 if err != context.Canceled {
391 t.Errorf("Expected error: %v, but got: %v", context.Canceled, err)
392 }
393 if retry.currentErr != context.Canceled {
394 t.Errorf("Expected current error: %v, but got: %v", context.Canceled, retry.currentErr)
395 }
396 if retry.previousErr != io.EOF {
397 t.Errorf("Expected previous error: %v, but got: %v", io.EOF, retry.previousErr)
398 }
399 })
400 }
401
View as plain text