retry

package module
v1.1.1 Latest Latest
Warning

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

Go to latest
Published: Feb 28, 2026 License: MIT Imports: 4 Imported by: 0

README ΒΆ

goretry logo

πŸš€ goretry

A minimal, composable, production-ready retry library for Go.

CI Go Reference Go Report Card Coverage


goretry provides a clean fluent API for retrying operations in Go. No external dependencies. Race-free. Generics-native.


✨ Features

Feature Detail
🎯 Context first ctx.Done() is checked between every attempt β€” no blocking sleeps
πŸ”— Fluent API retry.New().Attempts(5).Backoff(ExponentialJitter(200ms)).Do(ctx, fn)
🧬 Generics DoValue[T] returns typed values β€” no closures outside the retry loop
πŸ›‘ Permanent errors retry.Permanent(err) stops the loop before wasting more attempts
πŸ” Conditional retry RetryIf(func(error) bool) β€” only retry errors you want retried
⏱️ MaxDelay Cap any backoff strategy at a maximum wait duration
πŸ“ˆ 4 backoff strategies Constant, Linear, Exponential, ExponentialJitter β€” or bring your own
πŸͺ OnRetry hook Plug in logging or metrics per attempt
⚑ Zero allocations ~2.6 ns/op, 0 allocs on the success path
πŸͺΆ Zero dependencies Pure stdlib. Ships in <200 LOC

πŸ“¦ Installation

go get github.com/chmenegatti/go-retry

Requires Go 1.18+


πŸ’» Quick Start

import retry "github.com/chmenegatti/go-retry"

err := retry.New().
    Attempts(5).
    Backoff(retry.ExponentialJitter(100 * time.Millisecond)).
    Do(ctx, func() error {
        return callUnstableService()
    })

πŸ“– Usage

Returning a value

user, err := retry.DoValue(ctx,
    retry.New().Attempts(3),
    func() (*User, error) {
        return db.FindUser(id)
    },
)

Permanent errors β€” stop immediately from inside fn

err := retry.New().Attempts(5).Do(ctx, func() error {
    resp, err := http.Get(url)
    if err != nil {
        return err // transient β€” will retry
    }
    if resp.StatusCode == 401 {
        return retry.Permanent(ErrUnauthorized) // fatal β€” stop now
    }
    return nil
})

// Original error is always preserved
errors.Is(err, ErrUnauthorized) // true

Conditional retry with RetryIf

err := retry.New().
    Attempts(5).
    RetryIf(func(err error) bool {
        // Only retry network timeouts, not application errors
        var netErr net.Error
        return errors.As(err, &netErr) && netErr.Timeout()
    }).
    Do(ctx, fn)

Capped exponential backoff

retry.New().
    Attempts(10).
    Backoff(retry.Exponential(100 * time.Millisecond)).
    MaxDelay(5 * time.Second). // never wait longer than 5s
    Do(ctx, fn)

Logging with OnRetry

retry.New().
    Attempts(5).
    OnRetry(func(attempt int, err error) {
        log.Printf("attempt %d failed: %v", attempt, err)
        metrics.Inc("retry.attempt")
    }).
    Do(ctx, fn)

Real-world examples

🌐 HTTP request with server-error retry
body, err := retry.DoValue(ctx,
    retry.New().
        Attempts(4).
        Backoff(retry.ExponentialJitter(500*time.Millisecond)).
        MaxDelay(10*time.Second).
        RetryIf(func(err error) bool {
            var apiErr *APIError
            if errors.As(err, &apiErr) {
                return apiErr.Code >= 500 // only retry server errors
            }
            return true
        }),
    func() ([]byte, error) {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        if resp.StatusCode == 401 {
            return nil, retry.Permanent(ErrUnauthorized)
        }
        if resp.StatusCode >= 400 {
            return nil, &APIError{Code: resp.StatusCode}
        }
        return io.ReadAll(resp.Body)
    },
)
πŸ—„οΈ Database operation
err := retry.New().
    Attempts(3).
    Backoff(retry.Constant(500 * time.Millisecond)).
    RetryIf(func(err error) bool {
        return isDeadlock(err) // only retry transient DB conflicts
    }).
    Do(ctx, func() error {
        return db.ExecContext(ctx, "UPDATE orders SET status=? WHERE id=?", "shipped", id)
    })
⚑ gRPC transient failure
resp, err := retry.DoValue(ctx,
    retry.New().
        Attempts(5).
        Backoff(retry.ExponentialJitter(100*time.Millisecond)).
        RetryIf(func(err error) bool {
            code := status.Code(err)
            return code == codes.Unavailable || code == codes.DeadlineExceeded
        }),
    func() (*pb.Response, error) {
        return client.Call(ctx, req)
    },
)

πŸ“ Backoff Strategies

type Backoff func(attempt int) time.Duration // bring your own!
Strategy Formula Use case
Constant(d) d Simple polling, queue consumers
Linear(base) attempt Γ— base Gradual ramp-up
Exponential(base) base Γ— 2^(attempt-1) Standard retry without jitter
ExponentialJitter(base) [d/2, d] random Recommended β€” avoids thundering herd

βš™οΈ API Reference

Method Default Description
New() β€” 3 attempts, 100ms constant backoff
.Attempts(n) 3 Total call count (including first)
.Backoff(b) Constant(100ms) Delay strategy between attempts
.MaxDelay(d) none Hard cap applied on top of any backoff
.RetryIf(fn) retry all Predicate β€” return false to stop retrying
.OnRetry(fn) none Hook before each sleep (not on final failure)
.Do(ctx, fn) β€” Execute and return error
DoValue[T](ctx, r, fn) β€” Execute and return (T, error)
Permanent(err) β€” Wrap error to abort retry loop immediately
IsPermanent(err) β€” Check if any error in the chain is permanent

🧠 Execution Flow

flowchart TD
    Start(["Do / DoValue"]) --> Exec["Execute fn()"]
    Exec --> Ok{"err == nil?"}
    Ok -->|"Yes"| ReturnNil(["return nil"])
    Ok -->|"No"| Perm{"Permanent(err)?"}
    Perm -->|"Yes"| ReturnPerm(["return err immediately"])
    Perm -->|"No"| Cond{"RetryIf(err)?"}
    Cond -->|"false"| ReturnCond(["return err immediately"])
    Cond -->|"true"| Last{"Last attempt?"}
    Last -->|"Yes"| ReturnErr(["return err"])
    Last -->|"No"| Hook["OnRetry hook (if set)"]
    Hook --> Sleep["Wait: min(backoff(n), MaxDelay)"]
    Sleep -->|"Timer fired"| Exec
    Sleep -->|"ctx.Done()"| ReturnCtx(["return ctx.Err()"])

πŸ”¬ Comparison

Library API Style Generics Permanent RetryIf MaxDelay Dependencies
goretry (this) Fluent/chain βœ… βœ… βœ… βœ… 0
cenkalti/backoff Functional ❌ βœ… βœ… βœ… 1
avast/retry-go Functional opts ❌ βœ… βœ… βœ… 0

πŸ—οΈ Design Philosophy

  • Simplicity over features: every method on Retry is essential and independently useful.
  • No global state: calling New() always returns a fresh independent instance.
  • Context always wins: the library never blocks on a timer longer than necessary.
  • Errors are values: Permanent is just an error wrapper β€” no special types to import.
  • Zero allocations on the hot path: successful calls don't allocate.

πŸ§ͺ Testing & Benchmarks

go test -race -cover ./...
# ok  github.com/chmenegatti/go-retry  coverage: 98.4% of statements

go test -bench=. -benchmem ./...
# BenchmarkRetrySuccess-12   443903150   2.626 ns/op   0 B/op   0 allocs/op
# BenchmarkRetryFailure-12           5   201000864 ns/op  502 B/op   6 allocs/op

βœ… 0 allocations on the success path β€” the library is invisible when not needed.


🀝 Contributing

  1. Fork and create a branch: git checkout -b feat/my-feature
  2. Run tests: go test -race ./...
  3. Commit: git commit -am 'feat: ...'
  4. Open a Pull Request

πŸ“„ License

MIT β€” see LICENSE.

Documentation ΒΆ

Overview ΒΆ

Package retry provides a simple and composable retry mechanism for Go.

Package retry provides a minimal, composable retry mechanism for Go. All features are additive and the API is backward compatible.

Index ΒΆ

Examples ΒΆ

Constants ΒΆ

This section is empty.

Variables ΒΆ

This section is empty.

Functions ΒΆ

func DoValue ΒΆ

func DoValue[T any](ctx context.Context, r *Retry, fn func() (T, error)) (T, error)

DoValue is a generic helper that executes fn using the provided Retry configuration and returns both the result and any error. This avoids capturing result variables outside the closure when retrying operations that produce a value.

Example:

user, err := retry.DoValue(ctx, retry.New().Attempts(3), func() (*User, error) {
    return db.FindUser(id)
})
Example ΒΆ

ExampleDoValue demonstrates returning a typed result from the retry loop.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	retry "github.com/chmenegatti/goretry"
)

func main() {
	ctx := context.Background()

	attempts := 0
	result, err := retry.DoValue(ctx,
		retry.New().Attempts(3).Backoff(retry.Constant(time.Millisecond)),
		func() (string, error) {
			attempts++
			if attempts < 3 {
				return "", errors.New("not ready yet")
			}
			return "ok", nil
		},
	)

	if err != nil {
		log.Printf("failed: %v", err)
		return
	}
	fmt.Println(result)
}
Output:
ok

func IsPermanent ΒΆ

func IsPermanent(err error) bool

IsPermanent reports whether err (or any error in its chain) was wrapped with Permanent.

func Permanent ΒΆ

func Permanent(err error) error

Permanent wraps err so that the retry loop stops immediately without further attempts. The original error is preserved and can be retrieved with errors.Unwrap or errors.Is/As.

Example:

Do(ctx, func() error {
    resp, err := http.Get(url)
    if err != nil {
        return err // transient β€” will retry
    }
    if resp.StatusCode == 401 {
        return retry.Permanent(ErrUnauthorized) // fatal β€” stop immediately
    }
    return nil
})
Example ΒΆ

ExamplePermanent shows how to instantly stop retrying from inside fn.

package main

import (
	"context"
	"errors"
	"fmt"

	retry "github.com/chmenegatti/goretry"
)

func main() {
	ctx := context.Background()

	var ErrBadRequest = errors.New("400 bad request")

	err := retry.New().Attempts(5).Do(ctx, func() error {
		// Wrapping with Permanent signals "don't retry this".
		return retry.Permanent(ErrBadRequest)
	})

	// Unwrap to get the original error.
	fmt.Println(errors.Is(err, ErrBadRequest))
}
Output:
true

Types ΒΆ

type Backoff ΒΆ

type Backoff func(attempt int) time.Duration

Backoff is a function that, given an attempt number (1-indexed), returns the duration to wait before the next retry.

func Constant ΒΆ

func Constant(d time.Duration) Backoff

Constant returns a Backoff strategy that always waits the same duration between attempts, regardless of the attempt number.

Example:

r := retry.New().Backoff(retry.Constant(2 * time.Second))

func Exponential ΒΆ

func Exponential(base time.Duration) Backoff

Exponential returns a Backoff strategy where the wait duration doubles with each attempt: delay = base * 2^(attempt-1).

Example:

r := retry.New().Backoff(retry.Exponential(100 * time.Millisecond))
// attempt 1 β†’ 100ms, attempt 2 β†’ 200ms, attempt 3 β†’ 400ms

func ExponentialJitter ΒΆ

func ExponentialJitter(base time.Duration) Backoff

ExponentialJitter returns a Backoff strategy similar to Exponential, but adds a random jitter in the range [d/2, d] to avoid the thundering herd problem when many clients retry simultaneously.

Example:

r := retry.New().Backoff(retry.ExponentialJitter(100 * time.Millisecond))

func Linear ΒΆ

func Linear(base time.Duration) Backoff

Linear returns a Backoff strategy where the wait duration grows linearly with the attempt number: delay = attempt * base.

Example:

r := retry.New().Backoff(retry.Linear(500 * time.Millisecond))
// attempt 1 β†’ 500ms, attempt 2 β†’ 1s, attempt 3 β†’ 1.5s

type Retry ΒΆ

type Retry struct {
	// contains filtered or unexported fields
}

Retry holds the configuration for a retry operation. Use New() to construct a Retry and chain builder methods before calling Do.

func New ΒΆ

func New() *Retry

New creates a new Retry with sensible defaults: 3 total attempts with a 100ms constant backoff.

Example ΒΆ

ExampleNew demonstrates a basic retry with the default configuration.

package main

import (
	"context"
	"errors"
	"log"

	retry "github.com/chmenegatti/goretry"
)

func main() {
	ctx := context.Background()

	err := retry.New().
		Attempts(3).
		Do(ctx, func() error {
			// Simulate a transient failure.
			return errors.New("service unavailable")
		})

	if err != nil {
		log.Printf("all attempts failed: %v", err)
	}
}

func (*Retry) Attempts ΒΆ

func (r *Retry) Attempts(n int) *Retry

Attempts sets the total number of times fn will be called (including the first attempt). Values less than 1 are treated as 1.

Example:

retry.New().Attempts(5).Do(ctx, fn)

func (*Retry) Backoff ΒΆ

func (r *Retry) Backoff(b Backoff) *Retry

Backoff sets the strategy used to compute the wait duration between attempts. Use the built-in strategies (Constant, Linear, Exponential, ExponentialJitter) or provide a custom func(attempt int) time.Duration.

Example:

retry.New().Backoff(retry.ExponentialJitter(200 * time.Millisecond))

func (*Retry) Do ΒΆ

func (r *Retry) Do(ctx context.Context, fn func() error) error

Do executes fn up to the configured number of Attempts. Between each failed attempt, Do sleeps for the duration returned by the Backoff strategy (capped by MaxDelay if set), or until ctx is cancelled β€” whichever comes first.

Do returns nil on the first successful execution of fn. Do returns the last error from fn if all attempts fail. Do returns ctx.Err() if the context is cancelled while waiting. Do returns the error immediately (without further retries) if:

  • the error was wrapped with Permanent, or
  • a RetryIf predicate returns false for that error.

func (*Retry) MaxDelay ΒΆ

func (r *Retry) MaxDelay(d time.Duration) *Retry

MaxDelay caps the delay returned by the backoff strategy. Any computed delay exceeding MaxDelay will be clamped to MaxDelay. This is especially useful with exponential backoffs to prevent excessively long waits.

Example:

retry.New().
    Backoff(retry.Exponential(100*time.Millisecond)).
    MaxDelay(5 * time.Second)
Example ΒΆ

ExampleRetry_MaxDelay shows capping exponential backoff at a fixed ceiling.

package main

import (
	"context"
	"errors"
	"time"

	retry "github.com/chmenegatti/goretry"
)

func main() {
	ctx := context.Background()

	retry.New().
		Attempts(10).
		Backoff(retry.Exponential(100*time.Millisecond)).
		MaxDelay(2*time.Second).
		Do(ctx, func() error {
			return errors.New("fail")
		})
}

func (*Retry) OnRetry ΒΆ

func (r *Retry) OnRetry(fn func(int, error)) *Retry

OnRetry registers a callback invoked before each sleep between attempts. The callback receives the current attempt number (1-indexed) and the error from the last execution. It is NOT called after the final failing attempt.

Example:

retry.New().OnRetry(func(attempt int, err error) {
    log.Printf("attempt %d failed: %v", attempt, err)
})

func (*Retry) RetryIf ΒΆ

func (r *Retry) RetryIf(fn func(error) bool) *Retry

RetryIf sets a predicate that controls whether a given error should trigger a retry. If the predicate returns false, Do returns the error immediately without further attempts. If unset, all errors are retried up to Attempts.

Note: errors wrapped with Permanent always stop the loop, regardless of RetryIf.

Example:

retry.New().RetryIf(func(err error) bool {
    return errors.Is(err, io.ErrUnexpectedEOF)
})
Example ΒΆ

ExampleRetry_RetryIf shows how to skip retrying for known fatal errors.

package main

import (
	"context"
	"errors"
	"fmt"

	retry "github.com/chmenegatti/goretry"
)

func main() {
	ctx := context.Background()

	errFatal := errors.New("fatal: invalid credentials")

	err := retry.New().
		Attempts(5).
		RetryIf(func(err error) bool {
			// Only retry transient errors, not authentication failures.
			return !errors.Is(err, errFatal)
		}).
		Do(ctx, func() error {
			return errFatal // stopped after 1 attempt
		})

	fmt.Println(err)
}
Output:
fatal: invalid credentials

Jump to

Keyboard shortcuts

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