k8s.io/client-go@v0.31.1/rest/with_retry_test.go (about)

     1  /*
     2  Copyright 2021 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    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  				// make sure the message from the returned error contains
   363  				// message from the "previous" error from retries.
   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  		// we pass zero Request object since we expect 'Before'
   388  		// to check the context for error at the very beginning.
   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  }