Skip to content

Retry

func Retry[T any](ctx context.Context, attempts int, fn AsyncFunc[T]) (T, error)

Retry calls fn until it returns a nil error, it has been called attempts times, or ctx is done. It runs fn on your goroutine and allocates nothing.

cfg, err := gollback.Retry(ctx, 5, func(ctx context.Context) (Config, error) {
return store.Load(ctx, "app")
})

attempts is the most times fn is called, the first call included. Below 1 means no limit: Retry calls fn until it succeeds or ctx is done, so give it a context with a deadline.

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conn, err := gollback.Retry(ctx, 0, dial) // as many attempts as fit in 30 seconds
  • When fn never succeeds, Retry returns its last error.
  • When ctx is done first, Retry returns ctx’s error joined with fn’s last error, so you can see both why it stopped and why the attempts failed. errors.Is(err, context.DeadlineExceeded) is true.
  • When ctx is done before the first call, fn is never called and the error is ctx’s alone.

Retry checks ctx before every call, and passes it to fn, which should return early once it is done.

Retry calls fn again as soon as it fails. That suits a function that waits by itself, such as one that blocks on a lock or a connection, but most retries against a service should back off. Wait at the start of every attempt but the first, and stop waiting when the context is done:

examples/retry/main.go
// Command retry fetches a URL, retrying with a growing pause between attempts.
//
// go run ./examples/retry https://example.com
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"github.com/vardius/gollback/v2"
)
// getWithBackoff makes up to attempts requests. Before each retry it waits, doubling the pause every time,
// and stops waiting as soon as ctx is done.
func getWithBackoff(ctx context.Context, client *http.Client, url string, attempts int, pause time.Duration) (string, error) {
var last error // the previous request's
return gollback.Retry(ctx, attempts, func(ctx context.Context) (string, error) {
if last != nil {
select {
case <-time.After(pause):
pause *= 2
case <-ctx.Done():
// Retry joins ctx's error with this one, so the reason for retrying isn't lost.
return "", last
}
}
body, err := get(ctx, client, url)
last = err
return body, err
})
}
func get(ctx context.Context, client *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s: %s", url, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("%s: %w", url, err)
}
return string(body), nil
}
func main() {
log.SetFlags(0)
if len(os.Args) != 2 {
log.Fatal("usage: retry <url>")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := getWithBackoff(ctx, http.DefaultClient, os.Args[1], 5, 200*time.Millisecond)
if err != nil {
log.Fatal(err)
}
fmt.Print(body)
}