« Back to Index

Simple retry logic for HTTP request

View original Gist on GitHub

Tags: #go #http

1. golang simple retry logic for http request.go

// report indicates the failing test and the host it is associated with.
func report(host, msg string, err error) {
	log.Printf("%s, %s: %v\n", host, msg, err)
}

// TestStatusCode verifies the given status code will return a corresponding
// custom error page. If a status code that has no custom error page is
// provided, then we'll allow for an override to verify that a default custom
// error page was returned.
//
// We allow for an optional variadic parameter to be provided so a caller can
// specify a default status code to verify against in case the given status
// code is expected to return a different status code.
func TestStatusCode(statusCode int, host, needle string, config *settings.Config, defaultStatusCode ...int) {
	client := &http.Client{
		Timeout: time.Second * time.Duration(config.Timeout),
	}

	path := fmt.Sprintf("/httpbin/status/%d", statusCode)
	method := "GET"
	uuid := time.Now().UnixNano()

	url := fmt.Sprintf("https://%s%s?cachebust=%d", host, path, uuid)

	req, err := http.NewRequest(method, url, nil)
	if err != nil {
		msg := fmt.Sprintf("test%d: failed to create new request", statusCode)
		report(host, msg, err)
		return
	}

	if config.Environment == "stage" {
		req.SetBasicAuth(config.BasicAuthUser, config.BasicAuthPass)

		// not all services use the same stage auth credentials
		if creds, ok := config.BasicAuthOverride[host]; ok {
			req.SetBasicAuth(creds["user"], creds["pass"])
		}
	}

	// set debug flag so we can inspect Surrogate-Control.
	req.Header.Set("Fastly-Debug", "true")

	// set header to allow request through to HTTPBin
	req.Header.Set("X-Origin-HTTPBin", config.SecretHash)

	// implement retry logic around our http request...
	var resp *http.Response
	{
		attempts := 0

		for attempts < 2 {
			attempts++

			resp, err = client.Do(req)
			if err != nil {
				if config.Debug {
					msg := fmt.Sprintf("test%d: failed to make http request", statusCode)
					report(host, msg, err)
				}
				continue
			}

			defer resp.Body.Close()
			break
		}

		if attempts == 2 && resp == nil {
			msg := fmt.Sprintf("test%d: failed to make http request after two attempts", statusCode)
			report(host, msg, err)
			return
		}
	}

	// in case the provided status code doesn't have a custom error page of its
	// own but uses a different status code, then enable the caller to provide
	// the correct status code that was expected to be handled for that code.
	//
	// for example, 503 should really return a 500 as there is currently no
	// custom error page for a 503 so we return the custom 500 instead.
	statusToVerify := statusCode
	if len(defaultStatusCode) == 1 {
		statusToVerify = defaultStatusCode[0]
	}

	if resp.StatusCode != statusToVerify {
		err := fmt.Errorf(resp.Status)
		msg := fmt.Sprintf("test%d: unexpected response status code", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		msg := fmt.Sprintf("test%d: failed to read response body", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	if !strings.Contains(string(body), needle) {
		err := fmt.Errorf("missing error message in body")
		msg := fmt.Sprintf("test%d: failed to load correct error page", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	if config.Debug {
		msg := fmt.Sprintf("test%d: passed", statusCode)
		log.Println(host, msg)
	}
}

2. abstracted example.go

// report indicates the failing test and the host it is associated with.
func report(host, msg string, err error) {
	log.Printf("%s, %s: %v\n", host, msg, err)
}

// requestWithRetry will attempt to make a HTTP request multiple times before
// failing the given test scenario
func requestWithRetry(client *http.Client,
	req *http.Request,
	resp *http.Response,
	retries int,
	debug bool,
	msg, host string) (*http.Response, error) {

	attempts := 0
	var err error

	for attempts < retries {
		attempts++

		resp, err = client.Do(req)
		if debug {
			if err != nil {
				report(host, msg, err)
			}
			continue
		}

		break
	}

	if attempts == retries && resp == nil {
		report(host, fmt.Sprintf("%s after two attempts", msg), err)
		return resp, err
	}

	return resp, nil
}

// TestStatusCode verifies the given status code will return a corresponding
// custom error page. If a status code that has no custom error page is
// provided, then we'll allow for an override to verify that a default custom
// error page was returned.
//
// We allow for an optional variadic parameter to be provided so a caller can
// specify a default status code to verify against in case the given status
// code is expected to return a different status code.
func TestStatusCode(statusCode int, host, needle string, config *settings.Config, defaultStatusCode ...int) {
	client := &http.Client{
		Timeout: time.Second * time.Duration(config.Timeout),
	}

	path := fmt.Sprintf("/httpbin/status/%d", statusCode)
	method := "GET"
	uuid := time.Now().UnixNano()

	url := fmt.Sprintf("https://%s%s?cachebust=%d", host, path, uuid)

	req, err := http.NewRequest(method, url, nil)
	if err != nil {
		msg := fmt.Sprintf("test%d: failed to create new request", statusCode)
		report(host, msg, err)
		return
	}

	if config.Environment == "stage" {
		req.SetBasicAuth(config.BasicAuthUser, config.BasicAuthPass)

		// not all services use the same stage auth credentials
		if creds, ok := config.BasicAuthOverride[host]; ok {
			req.SetBasicAuth(creds["user"], creds["pass"])
		}
	}

	// set debug flag so we can inspect Surrogate-Control.
	req.Header.Set("Fastly-Debug", "true")

	// set header to allow request through to HTTPBin
	req.Header.Set("X-Origin-HTTPBin", config.SecretHash)

	var resp *http.Response
	msg := fmt.Sprintf("test%d: failed to make http request", statusCode)

	resp, err = requestWithRetry(client, req, resp, config.Retries, config.Debug, msg, host)
	if err != nil {
		return
	}
	defer resp.Body.Close()

	// in case the provided status code doesn't have a custom error page of its
	// own but uses a different status code, then enable the caller to provide
	// the correct status code that was expected to be handled for that code.
	//
	// for example, 503 should really return a 500 as there is currently no
	// custom error page for a 503 so we return the custom 500 instead.
	statusToVerify := statusCode
	if len(defaultStatusCode) == 1 {
		statusToVerify = defaultStatusCode[0]
	}

	if resp.StatusCode != statusToVerify {
		err := fmt.Errorf(resp.Status)
		msg := fmt.Sprintf("test%d: unexpected response status code", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		msg := fmt.Sprintf("test%d: failed to read response body", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	if !strings.Contains(string(body), needle) {
		err := fmt.Errorf("missing error message in body")
		msg := fmt.Sprintf("test%d: failed to load correct error page", statusCode)
		report(host, msg, err)
		log.Printf("%#v\n", resp)
		return
	}

	if config.Debug {
		msg := fmt.Sprintf("test%d: passed", statusCode)
		log.Println(host, msg)
	}
}

3. example with jitter.go

// retry accepts a function and retries it up to n times before returning an error
func retry(n int, metricWriter metrics.Writer, kind string, cb func() error) error {
	var err error
	if err = cb(); err == nil {
		return nil
	}

	for i := 0; i < n; i++ {
		metricWriter.Incr("api_gateway.retry", 1.0, metrics.Tag{Key: "kind", Value: kind}, metrics.Tag{Key: "error", Value: err.Error()})
		// Full Jitter from https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
		// backoff = random_between(0, baseBackoff * 2 ** attempt)
		maxBackoff := float64(50*time.Millisecond) * math.Exp2(float64(i))
		backoff := time.Duration(rand.Float64() * maxBackoff)

		log.WithFields(log.Fields{"retry_kind": kind, "count": i, "backoff": backoff, "error": err}).Info("retrying, backing off.")

		time.Sleep(backoff)

		err = cb()
		if err == nil {
			return nil
		}
	}

	return err
}