Search code examples
gogo-http

Unable to create a handler that responds with error in Go to cause a retry


I am trying to verify if the go-retryablehttp execution performs retries as per the specified config.

The verification methodology is to create a test that

  • creates a retryable client
  • creates a new request
  • creates a new server with the error handler
  • serve the request
  • verify the retry count.

The above is what I have tried to capture in the below code block

//function that returns 500 error
   func InternalServerErrorHandler(w http.ResponseWriter, r *http.Request) {
       http.Error(w, fmt.Sprintf("test_%d_body", http.StatusInternalServerError), http.StatusInternalServerError)
   }
func TestCreateToolsClient(t *testing.T) {

    //create a new server 
    ts := httptest.NewServer(http.HandlerFunc(InternalServerErrorHandler))
    defer ts.Close()

    //create a request
    request, err := retryablehttp.NewRequest(http.MethodGet, ts.URL, nil)
    if err != nil {
        log.Fatal(err)
    }
    //create a retryable client
    var options retryablehttp.Options
    options.RetryWaitMin = 10 * time.Millisecond
    options.RetryWaitMax = 50 * time.Millisecond
    options.RetryMax = 6
    options.Timeout = 60000 * time.Millisecond
    retryableClient := retryablehttp.NewClient(options)
    retryCount := -1
    // to verify from stdout if the # of retry actually is getting counted
    retryableClient.RequestLogHook = func(req *http.Request, retryNumber int) {
        retryCount = retryNumber
        log.Println("Retrying")
    }

    //execute the request
    response, err := retryableClient.Do(request)
    if err != nil {
        return
    }
    //verify
    require.Equal(t, http.StatusInternalServerError, response.StatusCode)
    require.Equal(t, 2, retryCount)
}

My understanding is

  • every retryableClient.Do(request) should take time=Timeout if there is error
  • given that the handler returns error, it should make the retry attempt equal to the options.RetryMax = 6 times

I tried debugging the code, and turns out

// Attempt the request
resp, err = c.HTTPClient.Do(req.Request)

here has err as nil.

Unsure what am I doing wrong.

I have created a go playground here


Solution

  • Okay, I figured this out.

    Go playground with solution here

    My version of go is Go 1.17. If you run the above code in go playground (which has Go version 1.19), the retry works.

    For Go 1.17, retryable-http(v1.0.2) does not handle status error codes

    func DefaultRetryPolicy() func(ctx context.Context, resp *http.Response, err error) (bool, error) {
        return func(ctx context.Context, resp *http.Response, err error) (bool, error) {
            // do not retry on context.Canceled or context.DeadlineExceeded
            //fmt.Printf("jkajsuiohsd %v\n", ctx.Err())
            if ctx.Err() != nil {
                return false, ctx.Err()
            }
    
            if err != nil {
                if v, ok := err.(*url.Error); ok {
                    // Don't retry if the error was due to too many redirects.
                    if redirectsErrorRegex.MatchString(v.Error()) {
                        return false, nil
                    }
    
                    // Don't retry if the error was due to an invalid protocol scheme.
                    if schemeErrorRegex.MatchString(v.Error()) {
                        return false, nil
                    }
    
                    // Don't retry if the error was due to TLS cert verification failure.
                    if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
                        return false, nil
                    }
                }
    
                // The error is likely recoverable so retry.
                return true, nil
            }
            //EXPECT HANDLING BASED ON STATUS CODES, BUT ABSENT
            return false, nil
        }
    }
    
    

    For Go 1.19, retryable-http(v2.1) implements the functionality under baseRetryPolicy as shown here

    func baseRetryPolicy(resp *http.Response, err error) (bool, error) {
        if err != nil {
            if v, ok := err.(*url.Error); ok {
                // Don't retry if the error was due to too many redirects.
                if redirectsErrorRe.MatchString(v.Error()) {
                    return false, v
                }
    
                // Don't retry if the error was due to an invalid protocol scheme.
                if schemeErrorRe.MatchString(v.Error()) {
                    return false, v
                }
    
                // Don't retry if the error was due to TLS cert verification failure.
                if notTrustedErrorRe.MatchString(v.Error()) {
                    return false, v
                }
                if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
                    return false, v
                }
            }
    
            // The error is likely recoverable so retry.
            return true, nil
        }
    
        // 429 Too Many Requests is recoverable. Sometimes the server puts
        // a Retry-After response header to indicate when the server is
        // available to start processing request from client.
        if resp.StatusCode == http.StatusTooManyRequests {
            return true, nil
        }
    
        // Check the response code. We retry on 500-range responses to allow
        // the server time to recover, as 500's are typically not permanent
        // errors and may relate to outages on the server side. This will catch
        // invalid response codes as well, like 0 and 999.
        //THIS PART HERE FLAGS RETRIES ON STATUS CODES!
        if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
            return true, fmt.Errorf("unexpected HTTP status %s", resp.Status)
        }
    
        return false, nil
    }
    

    Finally, one has the following options.

    • Move to Go 1.19
    • Try using go-retryablehttp v2.01
    • If you were to use Go.17, create a custom retry policy by updating the CheckRetry function as below.
    func TestCreateToolsClient(t *testing.T) {
        ts := httptest.NewServer(http.HandlerFunc(InternalServerErrorHandler))
        defer ts.Close()
    
        request, err := retryablehttp.NewRequest(http.MethodGet, ts.URL, nil)
        if err != nil {
            log.Fatal(err)
        }
        var options retryablehttp.Options
        options.RetryWaitMin = 10 * time.Millisecond
        options.RetryWaitMax = 50 * time.Millisecond
        options.RetryMax = 6
        options.Timeout = 60 * time.Second
    
        //options.Timeout = 30000 * time.Millisecond
        retryableClient := retryablehttp.NewClient(options)
        retryCount := -1
        // to verify from stdout if the # of retry actually is getting counted
        retryableClient.RequestLogHook = func(req *http.Request, retryNumber int) {
            retryCount = retryNumber
            log.Println("Retrying")
        }
    
        // A regular expression to match the error returned by net/http when the
        // configured number of redirects is exhausted. This error isn't typed
        // specifically so we resort to matching on the error string.
        redirectsErrorRe := regexp.MustCompile(`stopped after \d+ redirects\z`)
    
        // A regular expression to match the error returned by net/http when the
        // scheme specified in the URL is invalid. This error isn't typed
        // specifically so we resort to matching on the error string.
        schemeErrorRe := regexp.MustCompile(`unsupported protocol scheme`)
    
        // A regular expression to match the error returned by net/http when the
        // TLS certificate is not trusted. This error isn't typed
        // specifically so we resort to matching on the error string.
        notTrustedErrorRe := regexp.MustCompile(`certificate is not trusted`)
        retryableClient.CheckRetry = func(_ context.Context, resp *http.Response, err error) (bool, error) {
            if err != nil {
                if v, ok := err.(*url.Error); ok {
                    // Don't retry if the error was due to too many redirects.
                    if redirectsErrorRe.MatchString(v.Error()) {
                        return false, v
                    }
    
                    // Don't retry if the error was due to an invalid protocol scheme.
                    if schemeErrorRe.MatchString(v.Error()) {
                        return false, v
                    }
    
                    // Don't retry if the error was due to TLS cert verification failure.
                    if notTrustedErrorRe.MatchString(v.Error()) {
                        return false, v
                    }
                    if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
                        return false, v
                    }
                }
    
                // The error is likely recoverable so retry.
                return true, nil
            }
    
            // 429 Too Many Requests is recoverable. Sometimes the server puts
            // a Retry-After response header to indicate when the server is
            // available to start processing request from client.
            if resp.StatusCode == http.StatusTooManyRequests {
                return true, nil
            }
    
            // Check the response code. We retry on 500-range responses to allow
            // the server time to recover, as 500's are typically not permanent
            // errors and may relate to outages on the server side. This will catch
            // invalid response codes as well, like 0 and 999.
            if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
                return true, fmt.Errorf("unexpected HTTP status %s", resp.Status)
            }
    
            return false, nil
        }