client-go: wrap error from previous attempt to provide more context

Kubernetes-commit: 868b5a31d382b325f49e1b831e6b094282d50cc7
This commit is contained in:
Abu Kashem 2022-03-14 18:57:56 -04:00 committed by Kubernetes Publisher
parent ed2838156a
commit 7c9347d386
4 changed files with 474 additions and 4 deletions

View File

@ -620,7 +620,7 @@ func (r *Request) Watch(ctx context.Context) (watch.Interface, error) {
} }
if err := r.retry.Before(ctx, r); err != nil { if err := r.retry.Before(ctx, r); err != nil {
return nil, err return nil, r.retry.WrapPreviousError(err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
@ -655,7 +655,7 @@ func (r *Request) Watch(ctx context.Context) (watch.Interface, error) {
// we need to return the error object from that. // we need to return the error object from that.
err = transformErr err = transformErr
} }
return nil, err return nil, r.retry.WrapPreviousError(err)
} }
} }
} }
@ -865,7 +865,7 @@ func (r *Request) request(ctx context.Context, fn func(*http.Request, *http.Resp
} }
if err := r.retry.Before(ctx, r); err != nil { if err := r.retry.Before(ctx, r); err != nil {
return err return r.retry.WrapPreviousError(err)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
updateURLMetrics(ctx, r, resp, err) updateURLMetrics(ctx, r, resp, err)
@ -895,7 +895,7 @@ func (r *Request) request(ctx context.Context, fn func(*http.Request, *http.Resp
return true return true
}() }()
if done { if done {
return err return r.retry.WrapPreviousError(err)
} }
} }
} }

View File

@ -2614,6 +2614,30 @@ func TestRequestWatchWithRetryInvokeOrder(t *testing.T) {
}) })
} }
func TestRequestWatchWithWrapPreviousError(t *testing.T) {
testWithWrapPreviousError(t, func(ctx context.Context, r *Request) error {
w, err := r.Watch(ctx)
if err == nil {
// in this test the the response body returned by the server is always empty,
// this will cause StreamWatcher.receive() to:
// - return an io.EOF to indicate that the watch closed normally and
// - then close the io.Reader
// since we assert on the number of times 'Close' has been called on the
// body of the response object, we need to wait here to avoid race condition.
<-w.ResultChan()
}
return err
})
}
func TestRequestDoWithWrapPreviousError(t *testing.T) {
// both request.Do and request.DoRaw have the same behavior and expectations
testWithWrapPreviousError(t, func(ctx context.Context, r *Request) error {
result := r.Do(ctx)
return result.err
})
}
func testRequestWithRetry(t *testing.T, key string, doFunc func(ctx context.Context, r *Request)) { func testRequestWithRetry(t *testing.T, key string, doFunc func(ctx context.Context, r *Request)) {
type expected struct { type expected struct {
attempts int attempts int
@ -3126,6 +3150,208 @@ func testWithRetryInvokeOrder(t *testing.T, key string, doFunc func(ctx context.
} }
} }
func testWithWrapPreviousError(t *testing.T, doFunc func(ctx context.Context, r *Request) error) {
var (
containsFormatExpected = "- error from a previous attempt: %s"
nonRetryableErr = errors.New("non retryable error")
)
tests := []struct {
name string
maxRetries int
serverReturns []responseErr
expectedErr error
wrapped bool
attemptsExpected int
contains string
}{
{
name: "success at first attempt",
maxRetries: 2,
serverReturns: []responseErr{
{response: &http.Response{StatusCode: http.StatusOK}, err: nil},
},
attemptsExpected: 1,
expectedErr: nil,
},
{
name: "success after a series of retry-after from the server",
maxRetries: 2,
serverReturns: []responseErr{
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: &http.Response{StatusCode: http.StatusOK}, err: nil},
},
attemptsExpected: 3,
expectedErr: nil,
},
{
name: "success after a series of retryable errors",
maxRetries: 2,
serverReturns: []responseErr{
{response: nil, err: io.EOF},
{response: nil, err: io.EOF},
{response: &http.Response{StatusCode: http.StatusOK}, err: nil},
},
attemptsExpected: 3,
expectedErr: nil,
},
{
name: "request errors out with a non retryable error",
maxRetries: 2,
serverReturns: []responseErr{
{response: nil, err: nonRetryableErr},
},
attemptsExpected: 1,
expectedErr: nonRetryableErr,
},
{
name: "request times out after retries, but no previous error",
maxRetries: 2,
serverReturns: []responseErr{
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: nil, err: context.Canceled},
},
attemptsExpected: 3,
expectedErr: context.Canceled,
},
{
name: "request times out after retries, and we have a relevant previous error",
maxRetries: 3,
serverReturns: []responseErr{
{response: nil, err: io.EOF},
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: nil, err: context.Canceled},
},
attemptsExpected: 4,
wrapped: true,
expectedErr: context.Canceled,
contains: fmt.Sprintf(containsFormatExpected, io.EOF),
},
{
name: "interleaved retry-after responses with retryable errors",
maxRetries: 8,
serverReturns: []responseErr{
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: nil, err: io.ErrUnexpectedEOF},
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: nil, err: io.EOF},
{response: retryAfterResponse(), err: nil},
{response: retryAfterResponse(), err: nil},
{response: nil, err: context.Canceled},
},
attemptsExpected: 9,
wrapped: true,
expectedErr: context.Canceled,
contains: fmt.Sprintf(containsFormatExpected, io.EOF),
},
{
name: "request errors out with a retryable error, followed by a non-retryable one",
maxRetries: 3,
serverReturns: []responseErr{
{response: nil, err: io.EOF},
{response: nil, err: nonRetryableErr},
},
attemptsExpected: 2,
wrapped: true,
expectedErr: nonRetryableErr,
contains: fmt.Sprintf(containsFormatExpected, io.EOF),
},
{
name: "use the most recent error",
maxRetries: 2,
serverReturns: []responseErr{
{response: nil, err: io.ErrUnexpectedEOF},
{response: nil, err: io.EOF},
{response: nil, err: context.Canceled},
},
attemptsExpected: 3,
wrapped: true,
expectedErr: context.Canceled,
contains: fmt.Sprintf(containsFormatExpected, io.EOF),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var attempts int
client := clientForFunc(func(req *http.Request) (*http.Response, error) {
defer func() {
attempts++
}()
resp := test.serverReturns[attempts].response
if resp != nil {
resp.Body = ioutil.NopCloser(bytes.NewReader([]byte{}))
}
return resp, test.serverReturns[attempts].err
})
base, err := url.Parse("http://foo.bar")
if err != nil {
t.Fatalf("Failed to create new HTTP request - %v", err)
}
req := &Request{
verb: "GET",
body: bytes.NewReader([]byte{}),
c: &RESTClient{
base: base,
content: defaultContentConfig(),
Client: client,
},
pathPrefix: "/api/v1",
rateLimiter: flowcontrol.NewFakeAlwaysRateLimiter(),
backoff: &noSleepBackOff{},
retry: &withRetry{maxRetries: test.maxRetries},
}
err = doFunc(context.Background(), req)
if test.attemptsExpected != attempts {
t.Errorf("Expected attempts: %d, but got: %d", test.attemptsExpected, attempts)
}
switch {
case test.expectedErr == nil:
if err != nil {
t.Errorf("Expected a nil error, but got: %v", err)
return
}
case test.expectedErr != nil:
if !strings.Contains(err.Error(), test.contains) {
t.Errorf("Expected error message to contain %q, but got: %v", test.contains, err)
}
urlErrGot, _ := err.(*url.Error)
if test.wrapped {
// we expect the url.Error from net/http to be wrapped by WrapPreviousError
unwrapper, ok := err.(interface {
Unwrap() error
})
if !ok {
t.Errorf("Expected error to implement Unwrap method, but got: %v", err)
return
}
urlErrGot, _ = unwrapper.Unwrap().(*url.Error)
}
// we always get a url.Error from net/http
if urlErrGot == nil {
t.Errorf("Expected error to be url.Error, but got: %v", err)
return
}
errGot := urlErrGot.Unwrap()
if test.expectedErr != errGot {
t.Errorf("Expected error %v, but got: %v", test.expectedErr, errGot)
}
}
})
}
}
func TestReuseRequest(t *testing.T) { func TestReuseRequest(t *testing.T) {
var tests = []struct { var tests = []struct {
name string name string

View File

@ -22,6 +22,7 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"time" "time"
"k8s.io/klog/v2" "k8s.io/klog/v2"
@ -83,6 +84,22 @@ type WithRetry interface {
// After should be invoked immediately after an attempt is made. // After should be invoked immediately after an attempt is made.
After(ctx context.Context, r *Request, resp *http.Response, err error) After(ctx context.Context, r *Request, resp *http.Response, err error)
// WrapPreviousError wraps the error from any previous attempt into
// the final error specified in 'finalErr', so the user has more
// context why the request failed.
// For example, if a request times out after multiple retries then
// we see a generic context.Canceled or context.DeadlineExceeded
// error which is not very useful in debugging. This function can
// wrap any error from previous attempt(s) to provide more context to
// the user. The error returned in 'err' must satisfy the
// following conditions:
// a: errors.Unwrap(err) = errors.Unwrap(finalErr) if finalErr
// implements Unwrap
// b: errors.Unwrap(err) = finalErr if finalErr does not
// implements Unwrap
// c: errors.Is(err, otherErr) = errors.Is(finalErr, otherErr)
WrapPreviousError(finalErr error) (err error)
} }
// RetryAfter holds information associated with the next retry. // RetryAfter holds information associated with the next retry.
@ -111,6 +128,16 @@ type withRetry struct {
// - for consecutive attempts, it is non nil and holds the // - for consecutive attempts, it is non nil and holds the
// retry after parameters for the next attempt to be made. // retry after parameters for the next attempt to be made.
retryAfter *RetryAfter retryAfter *RetryAfter
// we keep track of two most recent errors, if the most
// recent attempt is labeled as 'N' then:
// - currentErr represents the error returned by attempt N, it
// can be nil if attempt N did not return an error.
// - previousErr represents an error from an attempt 'M' which
// precedes attempt 'N' (N - M >= 1), it is non nil only when:
// - for a sequence of attempt(s) 1..n (n>1), there
// is an attempt k (k<n) that returned an error.
previousErr, currentErr error
} }
func (r *withRetry) SetMaxRetries(maxRetries int) { func (r *withRetry) SetMaxRetries(maxRetries int) {
@ -120,7 +147,17 @@ func (r *withRetry) SetMaxRetries(maxRetries int) {
r.maxRetries = maxRetries r.maxRetries = maxRetries
} }
func (r *withRetry) trackPreviousError(err error) {
// keep track of two most recent errors
if r.currentErr != nil {
r.previousErr = r.currentErr
}
r.currentErr = err
}
func (r *withRetry) IsNextRetry(ctx context.Context, restReq *Request, httpReq *http.Request, resp *http.Response, err error, f IsRetryableErrorFunc) bool { func (r *withRetry) IsNextRetry(ctx context.Context, restReq *Request, httpReq *http.Request, resp *http.Response, err error, f IsRetryableErrorFunc) bool {
defer r.trackPreviousError(err)
if httpReq == nil || (resp == nil && err == nil) { if httpReq == nil || (resp == nil && err == nil) {
// bad input, we do nothing. // bad input, we do nothing.
return false return false
@ -191,6 +228,7 @@ func (r *withRetry) prepareForNextRetry(ctx context.Context, request *Request) e
func (r *withRetry) Before(ctx context.Context, request *Request) error { func (r *withRetry) Before(ctx context.Context, request *Request) error {
if ctx.Err() != nil { if ctx.Err() != nil {
r.trackPreviousError(ctx.Err())
return ctx.Err() return ctx.Err()
} }
@ -221,6 +259,7 @@ func (r *withRetry) Before(ctx context.Context, request *Request) error {
// apiserver at least once before. This request should // apiserver at least once before. This request should
// also be throttled with the client-internal rate limiter. // also be throttled with the client-internal rate limiter.
if err := request.tryThrottleWithInfo(ctx, r.retryAfter.Reason); err != nil { if err := request.tryThrottleWithInfo(ctx, r.retryAfter.Reason); err != nil {
r.trackPreviousError(ctx.Err())
return err return err
} }
@ -245,6 +284,45 @@ func (r *withRetry) After(ctx context.Context, request *Request, resp *http.Resp
} }
} }
func (r *withRetry) WrapPreviousError(currentErr error) error {
if currentErr == nil || r.previousErr == nil {
return currentErr
}
// if both previous and current error objects represent the error,
// then there is no need to wrap the previous error.
if currentErr.Error() == r.previousErr.Error() {
return currentErr
}
previousErr := r.previousErr
// net/http wraps the underlying error with an url.Error, if the
// previous err object is an instance of url.Error, then we can
// unwrap it to get to the inner error object, this is so we can
// avoid error message like:
// Error: Get "http://foo.bar/api/v1": context deadline exceeded - error \
// from a previous attempt: Error: Get "http://foo.bar/api/v1": EOF
if urlErr, ok := r.previousErr.(*url.Error); ok && urlErr != nil {
if urlErr.Unwrap() != nil {
previousErr = urlErr.Unwrap()
}
}
return &wrapPreviousError{
currentErr: currentErr,
previousError: previousErr,
}
}
type wrapPreviousError struct {
currentErr, previousError error
}
func (w *wrapPreviousError) Unwrap() error { return w.currentErr }
func (w *wrapPreviousError) Error() string {
return fmt.Sprintf("%s - error from a previous attempt: %s", w.currentErr.Error(), w.previousError.Error())
}
// checkWait returns true along with a number of seconds if // checkWait returns true along with a number of seconds if
// the server instructed us to wait before retrying. // the server instructed us to wait before retrying.
func checkWait(resp *http.Response) (int, bool) { func checkWait(resp *http.Response) (int, bool) {

View File

@ -20,9 +20,12 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"reflect" "reflect"
"strings"
"testing" "testing"
"time" "time"
@ -233,3 +236,166 @@ func TestIsNextRetry(t *testing.T) {
}) })
} }
} }
func TestWrapPreviousError(t *testing.T) {
const (
attempt = 2
previousAttempt = 1
containsFormatExpected = "- error from a previous attempt: %s"
)
var (
wrappedCtxDeadlineExceededErr = &url.Error{
Op: "GET",
URL: "http://foo.bar",
Err: context.DeadlineExceeded,
}
wrappedCtxCanceledErr = &url.Error{
Op: "GET",
URL: "http://foo.bar",
Err: context.Canceled,
}
urlEOFErr = &url.Error{
Op: "GET",
URL: "http://foo.bar",
Err: io.EOF,
}
)
tests := []struct {
name string
previousErr error
currentErr error
expectedErr error
wrapped bool
contains string
}{
{
name: "current error is nil, previous error is nil",
},
{
name: "current error is nil",
previousErr: errors.New("error from a previous attempt"),
},
{
name: "previous error is nil",
currentErr: urlEOFErr,
expectedErr: urlEOFErr,
wrapped: false,
},
{
name: "both current and previous errors represent the same error",
currentErr: urlEOFErr,
previousErr: &url.Error{Op: "GET", URL: "http://foo.bar", Err: io.EOF},
expectedErr: urlEOFErr,
},
{
name: "current and previous errors are not same",
currentErr: urlEOFErr,
previousErr: errors.New("unknown error"),
expectedErr: urlEOFErr,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, "unknown error"),
},
{
name: "current error is context.Canceled",
currentErr: context.Canceled,
previousErr: io.EOF,
expectedErr: context.Canceled,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
},
{
name: "current error is context.DeadlineExceeded",
currentErr: context.DeadlineExceeded,
previousErr: io.EOF,
expectedErr: context.DeadlineExceeded,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
},
{
name: "current error is a wrapped context.DeadlineExceeded",
currentErr: wrappedCtxDeadlineExceededErr,
previousErr: io.EOF,
expectedErr: wrappedCtxDeadlineExceededErr,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
},
{
name: "current error is a wrapped context.Canceled",
currentErr: wrappedCtxCanceledErr,
previousErr: io.EOF,
expectedErr: wrappedCtxCanceledErr,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, io.EOF.Error()),
},
{
name: "previous error should be unwrapped if it is url.Error",
currentErr: urlEOFErr,
previousErr: &url.Error{Err: io.ErrUnexpectedEOF},
expectedErr: urlEOFErr,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, io.ErrUnexpectedEOF.Error()),
},
{
name: "previous error should not be unwrapped if it is not url.Error",
currentErr: urlEOFErr,
previousErr: fmt.Errorf("should be included in error message - %w", io.EOF),
expectedErr: urlEOFErr,
wrapped: true,
contains: fmt.Sprintf(containsFormatExpected, "should be included in error message - EOF"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
retry := &withRetry{
previousErr: test.previousErr,
}
err := retry.WrapPreviousError(test.currentErr)
switch {
case test.expectedErr == nil:
if err != nil {
t.Errorf("Expected a nil error, but got: %v", err)
return
}
case test.expectedErr != nil:
// make sure the message from the returned error contains
// message from the "previous" error from retries.
if !strings.Contains(err.Error(), test.contains) {
t.Errorf("Expected error message to contain %q, but got: %v", test.contains, err)
}
currentErrGot := err
if test.wrapped {
currentErrGot = errors.Unwrap(err)
}
if test.expectedErr != currentErrGot {
t.Errorf("Expected current error %v, but got: %v", test.expectedErr, currentErrGot)
}
}
})
}
t.Run("Before should track previous error", func(t *testing.T) {
retry := &withRetry{
currentErr: io.EOF,
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// we pass zero Request object since we expect 'Before'
// to check the context for error at the very beginning.
err := retry.Before(ctx, &Request{})
if err != context.Canceled {
t.Errorf("Expected error: %v, but got: %v", context.Canceled, err)
}
if retry.currentErr != context.Canceled {
t.Errorf("Expected current error: %v, but got: %v", context.Canceled, retry.currentErr)
}
if retry.previousErr != io.EOF {
t.Errorf("Expected previous error: %v, but got: %v", io.EOF, retry.previousErr)
}
})
}