retry

package module
v0.0.0-...-e35e3d2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 16, 2024 License: MIT Imports: 6 Imported by: 0

README

Retry Blocks in Go using rangefunc

This is an example package demonstrating how Go 1.22's rangfunc experiment can be used to implement retry blocks; automatic request retries using native Go scopes and syntax.

It is inspired by Xe Iaso's post I wish Go had a retry block.

How it works

A Retry function implements a rangefunc. You call the function with a Context, each iteration through the for loop is passed a child context with a timeout applied.

var r retry.Retry

for childCtx, _ := r.Retry(ctx) {
	// perform the fallible request
	err := someFallibleRequest(childCtx)
	if err == nil {
		// success, exit the loop
		break
	}
}

return r.Err()

For a complete example, see ExampleRetry in retry_test.go.

One advantage of this approach over pre-1.22 rangefunc is that errors can be handled idiomatically within the for loop. For example, the popular cenkalti/backoff package requires wrapping errors which should not be retried:

err := backoff.Retry(func() error {
	err := someFallibleRequest(ctx)
	if errorIsNotRecoverable(err) {
		return backoff.Permanent(err)
	}
	return nil
}, NewExponentialBackOff())
if err != nil {
	return err
}

Using rangefunc the code can directly return such an error:

var r retry.Retry

for childCtx, _ := r.Retry(ctx) {
	err := someFallibleRequest(childCtx)
	select {
	case errorIsNotRecoverable(err):
		return err

	case err == nil:
		return nil
	}
}

return r.Err()

The examples above discard any errors which cause a retry. These errors can be recorded using the CancelCauseFunc yielded alongside the child context:

var r retry.Retry

for _, cause := range r.Retry(ctx) {
    cause(errors.New("foobar"))
}

// outputs:
//   retry count exceeded
//   foobar
//   foobar
//   foobar
//   foobar
fmt.Print(r.Err())

Thoughts on the rangefunc proposal

Generally, very positive! Kudos for a design which has the flexibility to be used for iterating over collections can also be used to provide fairly aribitrary request retries and backoffs. Other new additions in Go 1.22 (range-over-int, rand/v2.N) made parts of the implementation nicer as well.

One limitation I noticed is that there is very limited ability for feedback to flow from the for loop body back into the rangefunc. In the current design, the only feedback is a boolean true/false to exit the loop.

Look at the example of making an HTTP GET request:

var r Retry

for childCtx := range r.Retry(ctx) {
	req, err := http.NewRequestWithContext(childCtx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	rsp, err := http.DefaultClient.Do(req)
	switch {
	case err != nil:
		// ???
		continue

	case rsp.StatusCode < 200 || rsp.StatusCode >= 300:
		// ???
		continue

	default:
		return nil
	}
}

return r.Err()

An error returned from NewRequestWithContext is a fatal error, it signifies a flaw within the URL. Such an error should not be retried, and should be returned immediately.

However an error from Do or a bad HTTP status code should cause the request to be retried. With the current propoal these errors are lost. If the maximum number of retries is reached without success then a generic ErrRetriesExceeded is returned.

One potential solution would be to add an optional secondary return value to the yield function¹:

package iter

type Seq[V any] func(yield func(V) bool)
type SeqReturn[V, R any] func(yield func(V) (R, bool))

The continue statement would be extended to allow it to accept a single value:

var r Retry

for childCtx := range r.Retry(ctx) {
	req, err := http.NewRequestWithContext(childCtx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	rsp, err := http.DefaultClient.Do(req)
	switch {
	case err != nil:
		continue err

	case rsp.StatusCode < 200 || rsp.StatusCode >= 300:
		continue fmt.Errorf("HTTP status failed: %d", rsp.StatusCode)

	default:
		return nil
	}
}

return r.Err()

If no value is passed with a continue, or control reaches the end of the loop and repeats, then the zero value is returned.

This would allow the Retry function to accumulate errors from each request and return them from the Retry.Err() function:

func retryLoop(ctx, retries int, yield func(context.Context) bool) error {
	err, loop := yield(ctx)
	if !loop {
		return nil
	}

	for _ = range retries {
		e, loop := yield(ctx)
		if !loop {
			return nil
		}

		err = errors.Join(err, e)
	}

	return errors.Join(err, ErrRetriesExceeded)
}

While this improves the retry use case, it would be a fairly complicated addition to the proposal and the language for what's likely a fringe benefit. Because similar results can be achieved using existing language features without changing language constructs such a change doesn't seem beneficial.

¹ I don't believe this is an original idea; I have vague memories that this is possible using yield statements in Python/Ruby/Javascript/... A cursory search didn't show any results. (Yield any results?)

Documentation

Overview

Package retry is an exploration use of Go 1.22's experimental range functions to provide a "retry loop". It was inspired by Xe Iaso's post I wish Go had a retry block.

This code is a proof-of-concept only; it's received only light testing and is not intended for production use. Because it uses an experimental Go feature you must set GOEXPERIMENT=rangefunc when building.

Index

Constants

View Source
const (
	// DefaultRetries is the default value used for [Retry.Retries]
	// if no value is provided.
	DefaultRetries = 3

	// DefaultBackoff is the default value used for [Retry.Backoff]
	// if not value is provided.
	DefaultBackoff = time.Second * 5
)

Variables

View Source
var ErrRetriesExceeded = errors.New("retry count exceeded")

ErrRetriesExceeded is the error value returned if all attempted retries fail.

Functions

This section is empty.

Types

type Retry

type Retry struct {
	// Retries is the number of times a failed request will be retried
	// before the request is considered failed.
	//
	// This must be a non-negative value.
	Retries int

	// Timeout is an optional time limit applied to each yielded context.
	//
	// This must be a non-negative value.
	Timeout time.Duration

	// Backoff is the initial cooldown delay applied between a failed
	// request and the next retry.
	//
	// This must be a non-negative value.
	Backoff time.Duration
	// contains filtered or unexported fields
}

Retry controls the behavior of a retry loop.

A Retry object should not be reused.

func (*Retry) Err

func (r *Retry) Err() error

Err returns the error result from a retried operation.

It returns [nil] if any request succeeded, ErrRetriesExceeded if all retry attempts failed, or the context error if its done channel was signaled.

func (*Retry) Retry

Retry returns a range function to retry a fallible request.

The returned iter.Seq2 should be used as the range in a for loop. Each iteration through the loop is passed a child context.Context of ctx and an associated cancel function. Cancel causes are recorded, and if the request fails (due to, e.g., the retries failing) then all cause errors are joined together and returned in Retry.Err.

A randomized exponential backoff is applied between any failed request and before attempting to retry the loop.

The caller should ensure that any blocking I/O is bound to the yielded context. Failure to do so can cause the loop to hang.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL