Skip to content

chmenegatti/goretry

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

12 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

goretry logo

πŸš€ goretry

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

CI Go Reference Go Report Card Coverage License: MIT


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()"])
Loading

πŸ”¬ 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.

About

goretry was built from the ground up to solve the headaches of retrying operations in Go. It embraces modern Go paradigms like Generics and context.Context, shipping with enterprise-grade defaults like Exponential Backoff and Full Jitter to prevent melting your servers under load.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages