Skip to content

Commit

Permalink
Implemented new retry system (#566)
Browse files Browse the repository at this point in the history
* Implemented new retry system

* Fix lint

* Addressed PR comments
  • Loading branch information
ezilber-akamai committed Aug 16, 2024
1 parent 7f1781b commit 3203901
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 33 deletions.
106 changes: 74 additions & 32 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,45 +135,87 @@ type RequestParams struct {

// Generic helper to execute HTTP requests using the net/http package
//
// nolint:unused
// nolint:unused, funlen, gocognit
func (c *httpClient) doRequest(ctx context.Context, method, url string, params RequestParams, mutators ...func(req *http.Request) error) error {
req, bodyBuffer, err := c.createRequest(ctx, method, url, params)
if err != nil {
return err
}
var (
req *http.Request
bodyBuffer *bytes.Buffer
resp *http.Response
err error
)

if err := c.applyMutators(req, mutators); err != nil {
return err
}
for attempt := 0; attempt < httpDefaultRetryCount; attempt++ {
req, bodyBuffer, err = c.createRequest(ctx, method, url, params)
if err != nil {
return err
}

if c.debug && c.logger != nil {
c.logRequest(req, method, url, bodyBuffer)
}
if err = c.applyMutators(req, mutators); err != nil {
return err
}

resp, err := c.sendRequest(req)
if err != nil {
return err
}
defer resp.Body.Close()
if c.debug && c.logger != nil {
c.logRequest(req, method, url, bodyBuffer)
}

if err := c.checkHTTPError(resp); err != nil {
return err
}
processResponse := func() error {
defer func() {
closeErr := resp.Body.Close()
if closeErr != nil && err == nil {
err = closeErr
}
}()
if err = c.checkHTTPError(resp); err != nil {
return err
}
if c.debug && c.logger != nil {
var logErr error
resp, logErr = c.logResponse(resp)
if logErr != nil {
return logErr
}
}
if params.Response != nil {
if err = c.decodeResponseBody(resp, params.Response); err != nil {
return err
}
}
return nil
}

if c.debug && c.logger != nil {
resp, err = c.logResponse(resp)
if err != nil {
return err
resp, err = c.sendRequest(req)
if err == nil {
if err = processResponse(); err == nil {
return nil
}
}
}

if params.Response != nil {
if err := c.decodeResponseBody(resp, params.Response); err != nil {
return err
if !c.shouldRetry(resp, err) {
break
}

retryAfter, retryErr := c.retryAfter(resp)
if retryErr != nil {
return retryErr
}

// Sleep for the specified duration before retrying.
// If retryAfter is 0 (i.e., Retry-After header is not found),
// no delay is applied.
time.Sleep(retryAfter)
}

return nil
return err
}

// nolint:unused
func (c *httpClient) shouldRetry(resp *http.Response, err error) bool {
for _, retryConditional := range c.retryConditionals {
if retryConditional(resp, err) {
return true
}
}
return false
}

// nolint:unused
Expand Down Expand Up @@ -579,14 +621,14 @@ func (c *Client) UseCache(value bool) {
}

// SetRetryMaxWaitTime sets the maximum delay before retrying a request.
func (c *Client) SetRetryMaxWaitTime(max time.Duration) *Client {
c.resty.SetRetryMaxWaitTime(max)
func (c *Client) SetRetryMaxWaitTime(maxWaitTime time.Duration) *Client {
c.resty.SetRetryMaxWaitTime(maxWaitTime)
return c
}

// SetRetryWaitTime sets the default (minimum) delay before retrying a request.
func (c *Client) SetRetryWaitTime(min time.Duration) *Client {
c.resty.SetRetryWaitTime(min)
func (c *Client) SetRetryWaitTime(minWaitTime time.Duration) *Client {
c.resty.SetRetryWaitTime(minWaitTime)
return c
}

Expand Down
4 changes: 3 additions & 1 deletion client_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ type httpClient struct {
//nolint:unused
debug bool
//nolint:unused
retryConditionals []RetryConditional
retryConditionals []httpRetryConditional
//nolint:unused
retryAfter httpRetryAfter

//nolint:unused
pollInterval time.Duration
Expand Down
127 changes: 127 additions & 0 deletions retries_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package linodego

import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"time"

"golang.org/x/net/http2"
)

const (
// nolint:unused
httpRetryAfterHeaderName = "Retry-After"
// nolint:unused
httpMaintenanceModeHeaderName = "X-Maintenance-Mode"

// nolint:unused
httpDefaultRetryCount = 1000
)

// RetryConditional is a type alias for a function that determines if a request should be retried based on the response and error.
// nolint:unused
type httpRetryConditional func(*http.Response, error) bool

// RetryAfter is a type alias for a function that determines the duration to wait before retrying based on the response.
// nolint:unused
type httpRetryAfter func(*http.Response) (time.Duration, error)

// Configures http.Client to lock until enough time has passed to retry the request as determined by the Retry-After response header.
// If the Retry-After header is not set, we fall back to the value of SetPollDelay.
// nolint:unused
func httpConfigureRetries(c *httpClient) {
c.retryConditionals = append(c.retryConditionals, httpcheckRetryConditionals(c))
c.retryAfter = httpRespectRetryAfter
}

// nolint:unused
func httpcheckRetryConditionals(c *httpClient) httpRetryConditional {
return func(resp *http.Response, err error) bool {
for _, retryConditional := range c.retryConditionals {
retry := retryConditional(resp, err)
if retry {
log.Printf("[INFO] Received error %v - Retrying", err)
return true
}
}
return false
}
}

// nolint:unused
func httpRespectRetryAfter(resp *http.Response) (time.Duration, error) {
retryAfterStr := resp.Header.Get(retryAfterHeaderName)
if retryAfterStr == "" {
return 0, nil
}

retryAfter, err := strconv.Atoi(retryAfterStr)
if err != nil {
return 0, err
}

duration := time.Duration(retryAfter) * time.Second
log.Printf("[INFO] Respecting Retry-After Header of %d (%s)", retryAfter, duration)
return duration, nil
}

// Retry conditions

// nolint:unused
func httpLinodeBusyRetryCondition(resp *http.Response, _ error) bool {
apiError, ok := getAPIError(resp)
linodeBusy := ok && apiError.Error() == "Linode busy."
retry := resp.StatusCode == http.StatusBadRequest && linodeBusy
return retry
}

// nolint:unused
func httpTooManyRequestsRetryCondition(resp *http.Response, _ error) bool {
return resp.StatusCode == http.StatusTooManyRequests
}

// nolint:unused
func httpServiceUnavailableRetryCondition(resp *http.Response, _ error) bool {
serviceUnavailable := resp.StatusCode == http.StatusServiceUnavailable

// During maintenance events, the API will return a 503 and add
// an `X-MAINTENANCE-MODE` header. Don't retry during maintenance
// events, only for legitimate 503s.
if serviceUnavailable && resp.Header.Get(maintenanceModeHeaderName) != "" {
log.Printf("[INFO] Linode API is under maintenance, request will not be retried - please see status.linode.com for more information")
return false
}

return serviceUnavailable
}

// nolint:unused
func httpRequestTimeoutRetryCondition(resp *http.Response, _ error) bool {
return resp.StatusCode == http.StatusRequestTimeout
}

// nolint:unused
func httpRequestGOAWAYRetryCondition(_ *http.Response, err error) bool {
return errors.As(err, &http2.GoAwayError{})
}

// nolint:unused
func httpRequestNGINXRetryCondition(resp *http.Response, _ error) bool {
return resp.StatusCode == http.StatusBadRequest &&
resp.Header.Get("Server") == "nginx" &&
resp.Header.Get("Content-Type") == "text/html"
}

// Helper function to extract APIError from response
// nolint:unused
func getAPIError(resp *http.Response) (*APIError, bool) {
var apiError APIError
err := json.NewDecoder(resp.Body).Decode(&apiError)
if err != nil {
return nil, false
}
return &apiError, true
}
81 changes: 81 additions & 0 deletions retries_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package linodego

import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"time"
)

func TestHTTPLinodeBusyRetryCondition(t *testing.T) {
var retry bool

// Initialize response body
rawResponse := &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewBuffer(nil)),
}

retry = httpLinodeBusyRetryCondition(rawResponse, nil)

if retry {
t.Errorf("Should not have retried")
}

apiError := APIError{
Errors: []APIErrorReason{
{Reason: "Linode busy."},
},
}
rawResponse.Body = createResponseBody(apiError)

retry = httpLinodeBusyRetryCondition(rawResponse, nil)

if !retry {
t.Errorf("Should have retried")
}
}

func TestHTTPServiceUnavailableRetryCondition(t *testing.T) {
rawResponse := &http.Response{
StatusCode: http.StatusServiceUnavailable,
Header: http.Header{httpRetryAfterHeaderName: []string{"20"}},
Body: io.NopCloser(bytes.NewBuffer(nil)), // Initialize response body
}

if retry := httpServiceUnavailableRetryCondition(rawResponse, nil); !retry {
t.Error("expected request to be retried")
}

if retryAfter, err := httpRespectRetryAfter(rawResponse); err != nil {
t.Errorf("expected error to be nil but got %s", err)
} else if retryAfter != time.Second*20 {
t.Errorf("expected retryAfter to be 20 but got %d", retryAfter)
}
}

func TestHTTPServiceMaintenanceModeRetryCondition(t *testing.T) {
rawResponse := &http.Response{
StatusCode: http.StatusServiceUnavailable,
Header: http.Header{
httpRetryAfterHeaderName: []string{"20"},
httpMaintenanceModeHeaderName: []string{"Currently in maintenance mode."},
},
Body: io.NopCloser(bytes.NewBuffer(nil)), // Initialize response body
}

if retry := httpServiceUnavailableRetryCondition(rawResponse, nil); retry {
t.Error("expected retry to be skipped due to maintenance mode header")
}
}

// Helper function to create a response body from an object
func createResponseBody(obj interface{}) io.ReadCloser {
body, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return io.NopCloser(bytes.NewBuffer(body))
}

0 comments on commit 3203901

Please sign in to comment.