Documentation
¶
Overview ¶
Package try provides a generic, context-aware retry loop with exponential backoff and pluggable jitter strategies.
Basic usage ¶
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
return callExternalAPI(ctx)
})
Do retries up to 5 times by default, waiting between attempts using exponential backoff capped at 30 seconds.
Options ¶
Behaviour is configured through functional options:
try.Do(ctx, fn,
try.WithAttempts(10),
try.WithInitialDelay(500*time.Millisecond),
try.WithMaxDelay(2*time.Minute),
try.WithJitter(try.EqualJitter),
try.WithRetryIf(isTransient),
try.WithOnRetry(func(info try.RetryInfo) {
slog.Warn("retrying",
"attempt", info.Attempt,
"delay", info.Delay,
"error", info.Err,
)
}),
)
WithMaxDelay overrides the default 30s cap on any single wait, which is useful when integrating with slow services or enforcing strict SLAs.
WithOnRetry registers a callback invoked before each wait, receiving a RetryInfo with the 1-based attempt number, the error, and the delay about to be taken. The callback is not called on the final attempt since no retry will follow. Use it for structured logging, metrics, or tracing.
Default retry behaviour ¶
By default Do retries on every error except context.Canceled, context.DeadlineExceeded, and errors wrapped with Permanent. This means validation errors, auth failures, and malformed-payload errors are retried unless explicitly excluded. For production use, always supply a WithRetryIf predicate to restrict retries to genuinely transient failures:
try.WithRetryIf(func(err error) bool {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr.StatusCode >= 500 // never retry 4xx
}
return true
})
Infinite retry ¶
Use WithInfiniteRetry to retry until the function succeeds, a Permanent error is returned, or the context is cancelled. Always pair this with a context deadline to prevent runaway retries:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() try.Do(ctx, fn, try.WithInfiniteRetry())
Custom delay function ¶
WithDelayFunc replaces the built-in exponential backoff entirely with a caller-supplied function. It receives the 1-based attempt number that just failed and the error it returned:
// Fixed 500ms delay:
try.WithDelayFunc(func(attempt int, err error) time.Duration {
return 500 * time.Millisecond
})
// Linear backoff: 1s, 2s, 3s, …
try.WithDelayFunc(func(attempt int, err error) time.Duration {
return time.Duration(attempt) * time.Second
})
// Error-dependent delay:
try.WithDelayFunc(func(attempt int, err error) time.Duration {
if errors.Is(err, ErrThrottled) {
return 10 * time.Second
}
return time.Duration(attempt) * 200 * time.Millisecond
})
RetryAfterer on the error still takes precedence over WithDelayFunc.
Error aggregation ¶
By default Do returns only the last attempt's error. Use WithAllErrors to collect every attempt error into an AttemptErrors value. Because AttemptErrors implements Unwrap() []error, the full history is searchable with errors.Is and errors.As:
_, err := try.Do(ctx, fn, try.WithAttempts(3), try.WithAllErrors())
var ae *try.AttemptErrors
if errors.As(err, &ae) {
for i, e := range ae.Unwrap() {
log.Printf("attempt %d: %v", i+1, e)
}
}
// errors.Is still works across the full history:
if errors.Is(err, ErrRateLimit) { ... }
Per-attempt timeout ¶
WithTimeout sets a deadline on each individual call to fn, independent of the parent context deadline which governs the entire retry operation. If fn exceeds the timeout its context is cancelled and the attempt is retried:
try.Do(ctx, fn,
try.WithAttempts(5),
try.WithTimeout(500*time.Millisecond), // each attempt gets 500ms
)
The parent context deadline still governs the overall operation. A slow fn that hits the per-attempt timeout receives context.DeadlineExceeded on its child context; the parent context remains live and the retry loop continues.
Per-error attempt budgets ¶
WithAttemptsForError sets an independent retry cap for a specific error. When that error is returned and its budget is exhausted, the loop stops immediately — even if the global WithAttempts budget has remaining attempts. Multiple calls accumulate independent budgets:
try.Do(ctx, fn,
try.WithAttempts(10),
try.WithAttemptsForError(2, ErrRateLimit), // stop after 2 rate-limit hits
try.WithAttemptsForError(3, ErrUnavailable), // stop after 3 unavailable hits
)
Matching uses errors.Is, so sentinel errors and wrapped errors both work.
Stopping immediately ¶
Wrap an error with Permanent to stop the loop without exhausting all attempts. The underlying error is unwrapped, so errors.Is and errors.As work normally on the value returned by Do:
return 0, try.Permanent(err)
Use IsPermanent to inspect whether an error came from a permanent failure without unwrapping it manually:
if try.IsPermanent(err) {
// do not retry at a higher level
}
Retry-After support ¶
If an error implements RetryAfterer, its RetryAfter duration is used instead of the computed backoff, making it straightforward to honour HTTP 429 Retry-After headers:
func (e *RateLimitError) RetryAfter() time.Duration { return e.RetryIn }
Jitter strategies ¶
Two strategies are available via WithJitter:
FullJitter (default): delay is drawn uniformly from [1ms, cap). Maximally spreads concurrent retriers; recommended for most cases.
EqualJitter: delay is cap/2 + rand[0, cap/2). Guarantees at least half the exponential backoff; useful when a minimum wait time is required.
Testing ¶
Inject a custom Clock via WithClock to control time in unit tests without real sleeps.
Index ¶
- func Do[T any](ctx context.Context, fn func(ctx context.Context) (T, error), opts ...Option) (T, error)
- func IsPermanent(err error) bool
- func Permanent(err error) error
- type AttemptErrors
- type Clock
- type Config
- type JitterStrategy
- type Option
- func WithAllErrors() Option
- func WithAttempts(n int) Option
- func WithAttemptsForError(n int, target error) Option
- func WithClock(clk Clock) Option
- func WithDelayFunc(fn func(attempt int, err error) time.Duration) Option
- func WithInfiniteRetry() Option
- func WithInitialDelay(d time.Duration) Option
- func WithJitter(s JitterStrategy) Option
- func WithMaxDelay(d time.Duration) Option
- func WithMaxJitter(d time.Duration) Option
- func WithOnRetry(fn func(RetryInfo)) Option
- func WithRetryIf(p func(error) bool) Option
- func WithTimeout(d time.Duration) Option
- type RetryAfterer
- type RetryInfo
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Do ¶
func Do[T any](ctx context.Context, fn func(ctx context.Context) (T, error), opts ...Option) (T, error)
Do is the generic entry point for retrying a function.
Example ¶
ExampleDo shows the minimal usage: a function that succeeds on the first call requires no options at all.
package main
import (
"context"
"fmt"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
return "hello", nil
})
fmt.Println(val, err)
}
Output: hello <nil>
Example (AllErrors) ¶
ExampleDo_allErrors shows WithAllErrors, which collects every attempt error into *AttemptErrors. errors.Is and errors.As traverse the full history.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
sentinel := errors.New("quota exceeded")
attempt := 0
_, err := try.Do(ctx, func(ctx context.Context) (int, error) {
attempt++
if attempt == 2 {
return 0, sentinel // one specific error among several
}
return 0, errors.New("generic failure")
},
try.WithAttempts(3),
try.WithAllErrors(),
try.WithInitialDelay(time.Millisecond),
)
var ae *try.AttemptErrors
fmt.Println(errors.As(err, &ae)) // *AttemptErrors accessible
fmt.Println(errors.Is(err, sentinel)) // sentinel reachable anywhere in history
}
Output: true true
Example (AttemptsForError) ¶
ExampleDo_attemptsForError shows WithAttemptsForError, which caps retries for a specific error independently of the global attempt budget.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
var ErrRateLimit = errors.New("rate limited")
calls := 0
_, err := try.Do(ctx, func(ctx context.Context) (int, error) {
calls++
return 0, ErrRateLimit
},
try.WithAttempts(10), // global budget: 10
try.WithAttemptsForError(2, ErrRateLimit), // but stop after 2 rate-limit hits
try.WithInitialDelay(time.Millisecond),
)
fmt.Println(calls, errors.Is(err, ErrRateLimit))
}
Output: 2 true
Example (DelayFunc) ¶
ExampleDo_delayFunc shows WithDelayFunc replacing the built-in backoff with a custom linear schedule.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
var observedAttempts []int
attempt := 0
try.Do(ctx, func(ctx context.Context) (int, error) { //nolint:errcheck
attempt++
if attempt < 3 {
return 0, errors.New("fail")
}
return 0, nil
},
try.WithAttempts(5),
try.WithDelayFunc(func(a int, _ error) time.Duration {
observedAttempts = append(observedAttempts, a)
return time.Duration(a) * time.Millisecond // linear: 1ms, 2ms, …
}),
)
fmt.Println(observedAttempts)
}
Output: [1 2]
Example (EqualJitter) ¶
ExampleDo_equalJitter shows the EqualJitter strategy, which guarantees at least half the computed backoff — useful when a minimum wait time matters.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
attempt := 0
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
attempt++
if attempt < 3 {
return "", errors.New("service unavailable")
}
return "recovered", nil
},
try.WithAttempts(5),
try.WithInitialDelay(time.Millisecond),
try.WithMaxDelay(100*time.Millisecond),
try.WithJitter(try.EqualJitter),
)
fmt.Println(val, err)
}
Output: recovered <nil>
Example (InfiniteRetry) ¶
ExampleDo_infiniteRetry shows WithInfiniteRetry, which retries until the function succeeds, a Permanent error is returned, or the context is cancelled. Always pair it with a context deadline.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
attempt := 0
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
attempt++
if attempt < 4 {
return "", errors.New("not ready")
}
return "ready", nil
},
try.WithInfiniteRetry(),
try.WithInitialDelay(time.Millisecond),
)
fmt.Println(val, err)
}
Output: ready <nil>
Example (OnRetry) ¶
ExampleDo_onRetry demonstrates the OnRetry callback, which fires before each wait and receives the attempt number, error, and upcoming delay. It is not called on the final attempt since no retry will follow.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
attempt := 0
try.Do(ctx, func(ctx context.Context) (int, error) { //nolint:errcheck
attempt++
if attempt < 3 {
return 0, errors.New("unavailable")
}
return 0, nil
},
try.WithAttempts(5),
try.WithInitialDelay(time.Millisecond),
try.WithOnRetry(func(info try.RetryInfo) {
slog.Info("retrying", "attempt", info.Attempt, "error", info.Err)
}),
)
fmt.Println("completed after", attempt, "attempts")
}
Output: completed after 3 attempts
Example (PermanentError) ¶
ExampleDo_permanentError shows how to stop the retry loop immediately for errors that will never succeed on retry (auth failures, bad input, etc.). Permanent unwraps transparently so errors.Is / errors.As work normally.
package main
import (
"context"
"errors"
"fmt"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
calls := 0
_, err := try.Do(ctx, func(ctx context.Context) (string, error) {
calls++
return "", try.Permanent(errors.New("invalid API key"))
}, try.WithAttempts(5))
fmt.Println(calls, err)
}
Output: 1 invalid API key
Example (RetryAfter) ¶
ExampleDo_retryAfter shows how an error can specify its own wait duration via the RetryAfterer interface — useful for honouring HTTP 429 headers.
package main
import (
"context"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
// rateLimitErr implements RetryAfterer to specify a custom wait duration.
type rateLimitErr struct{ wait time.Duration }
func (e rateLimitErr) Error() string { return "rate limited" }
func (e rateLimitErr) RetryAfter() time.Duration { return e.wait }
func main() {
ctx := context.Background()
attempt := 0
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
attempt++
if attempt == 1 {
return "", rateLimitErr{wait: 5 * time.Millisecond}
}
return "ok", nil
}, try.WithAttempts(3))
fmt.Println(val, err)
}
Output: ok <nil>
Example (TransientFailure) ¶
ExampleDo_transientFailure demonstrates retrying a flaky operation. WithRetryIf restricts retries to known transient errors — a best practice for production code so that validation and auth failures fail fast.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
ctx := context.Background()
attempt := 0
val, err := try.Do(ctx, func(ctx context.Context) (int, error) {
attempt++
if attempt < 3 {
return 0, errors.New("connection reset by peer")
}
return 42, nil
},
try.WithAttempts(5),
try.WithInitialDelay(time.Millisecond),
try.WithRetryIf(func(err error) bool {
return err.Error() == "connection reset by peer"
}),
)
fmt.Println(val, err)
}
Output: 42 <nil>
Example (WithTimeout) ¶
ExampleDo_withTimeout shows WithTimeout, which sets a per-attempt deadline independent of the overall context deadline.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/nodivbyzero/try"
)
func main() {
// Overall budget: 5s. Each attempt gets at most 100ms.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
attempt := 0
val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
attempt++
if attempt < 3 {
// Simulate a slow response that exceeds the per-attempt timeout.
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(10 * time.Second):
return "", errors.New("slow")
}
}
return "fast enough", nil
},
try.WithAttempts(5),
try.WithTimeout(10*time.Millisecond),
try.WithInitialDelay(time.Millisecond),
try.WithRetryIf(func(err error) bool {
return errors.Is(err, context.DeadlineExceeded)
}),
)
fmt.Println(val, err)
}
Output: fast enough <nil>
func IsPermanent ¶ added in v1.0.2
IsPermanent reports whether err was wrapped with Permanent. Useful for inspecting errors after Do returns, without unwrapping manually.
Example ¶
ExampleIsPermanent shows that IsPermanent reports whether an error was wrapped with Permanent, even through additional wrapping layers.
package main
import (
"errors"
"fmt"
"github.com/nodivbyzero/try"
)
func main() {
base := errors.New("fatal")
perm := try.Permanent(base)
wrapped := fmt.Errorf("outer: %w", perm)
fmt.Println(try.IsPermanent(perm))
fmt.Println(try.IsPermanent(wrapped))
fmt.Println(try.IsPermanent(base))
}
Output: true true false
func Permanent ¶
Permanent wraps an error to signal the loop should stop immediately.
Example ¶
ExamplePermanent shows that Permanent unwraps cleanly, so the original error remains inspectable via errors.Is after the loop exits.
package main
import (
"errors"
"fmt"
"github.com/nodivbyzero/try"
)
func main() {
sentinel := errors.New("fatal")
wrapped := try.Permanent(sentinel)
fmt.Println(errors.Is(wrapped, sentinel))
}
Output: true
Types ¶
type AttemptErrors ¶ added in v1.0.2
type AttemptErrors struct {
// contains filtered or unexported fields
}
AttemptErrors is the joined error type returned when WithAllErrors is set. It implements Unwrap() []error for Go 1.20+ multi-error unwrapping, so errors.Is and errors.As traverse every attempt's error.
func (*AttemptErrors) Error ¶ added in v1.0.2
func (e *AttemptErrors) Error() string
func (*AttemptErrors) Unwrap ¶ added in v1.0.2
func (e *AttemptErrors) Unwrap() []error
Unwrap returns all attempt errors for errors.Is / errors.As traversal.
type Config ¶
type Config struct {
MaxAttempts int
InitialDelay time.Duration
MaxDelay time.Duration
Clock Clock
Predicate func(error) bool
Jitter JitterStrategy
// OnRetry is called before each wait. It is not called on the final
// attempt since no retry will occur.
OnRetry func(RetryInfo)
// ErrorBudgets holds per-error attempt limits set by WithAttemptsForError.
// Each entry is checked independently; the first exhausted budget stops retries
// for that error, and counts against the global MaxAttempts budget too.
ErrorBudgets []errorBudget
// AttemptTimeout limits how long a single call to fn may run.
// Zero means no per-attempt timeout (default). Distinct from the parent
// context deadline, which governs the entire retry operation.
AttemptTimeout time.Duration
// AllErrors enables error aggregation. When true, Do collects every
// attempt error and returns them joined via errors.Join so that
// errors.Is / errors.As can inspect the full history.
AllErrors bool
// DelayFunc overrides the built-in backoff algorithm entirely.
// When set, it is called instead of calculateNextDelay. RetryAfterer
// on the error is still respected before DelayFunc is consulted.
DelayFunc func(attempt int, err error) time.Duration
// MaxJitter caps the jitter window independently of the backoff cap.
// Zero means no independent jitter cap — the full backoff cap is used
// as the jitter window (default behaviour).
MaxJitter time.Duration
}
Config holds the internal state for the retry operation.
type JitterStrategy ¶
type JitterStrategy int
JitterStrategy controls how randomness is applied to the backoff delay.
const ( // FullJitter draws the delay uniformly from [1ms, cap), minimising // correlated retries at the cost of potentially very short waits. // This is the default and is recommended for most use cases. FullJitter JitterStrategy = iota // EqualJitter uses cap/2 + rand[0, cap/2), guaranteeing at least half // the exponential backoff while still spreading load across retriers. EqualJitter )
type Option ¶
type Option func(*Config)
Option defines functional configuration for the retry.
func WithAllErrors ¶ added in v1.0.2
func WithAllErrors() Option
WithAllErrors enables error aggregation. When set, Do collects every attempt error and returns them as *AttemptErrors, which implements Unwrap() []error so that errors.Is and errors.As traverse the full history. Without this option only the last error is returned.
func WithAttempts ¶
WithAttempts sets the maximum number of attempts, including the first call. n must be >= 1; values less than 1 are clamped to 1 (a single attempt with no retries). To retry without an upper bound use WithInfiniteRetry instead.
func WithAttemptsForError ¶ added in v1.0.2
WithAttemptsForError sets a maximum retry count for a specific error value. When err is returned and its per-error budget is exhausted, the loop stops immediately — even if the global MaxAttempts budget has remaining attempts. Multiple calls accumulate independent budgets for different error values. Matching uses errors.Is, so sentinel errors and wrapped errors both work.
func WithDelayFunc ¶ added in v1.0.2
WithDelayFunc replaces the built-in exponential backoff algorithm with a custom function. fn receives the 1-based attempt number that just failed and the error it returned, and returns the duration to wait before the next attempt. RetryAfterer on the error still takes precedence over fn.
Common uses: fixed delay, linear backoff, or domain-specific schedules.
// Fixed 500ms delay regardless of attempt number:
try.WithDelayFunc(func(attempt int, err error) time.Duration {
return 500 * time.Millisecond
})
// Linear backoff: 1s, 2s, 3s, ...
try.WithDelayFunc(func(attempt int, err error) time.Duration {
return time.Duration(attempt) * time.Second
})
func WithInfiniteRetry ¶ added in v1.0.1
func WithInfiniteRetry() Option
WithInfiniteRetry removes the attempt cap entirely. Do will retry until the function succeeds, a Permanent error is returned, or the context is cancelled. Always pair this with a context deadline to prevent runaway retries.
func WithInitialDelay ¶
func WithJitter ¶
func WithJitter(s JitterStrategy) Option
func WithMaxDelay ¶
func WithMaxJitter ¶ added in v1.0.2
WithMaxJitter caps the jitter window independently of the backoff cap. Useful when you want long base delays with a small spread — for example, a 30s base delay with at most 500ms of jitter to avoid pile-ups without adding significant extra wait time.
Without WithMaxJitter, the jitter window equals the full backoff cap, so a 30s cap produces delays anywhere in [0, 30s) with FullJitter. With WithMaxJitter(500ms), delays are drawn from [0, 500ms) regardless of how large the backoff cap has grown.
WithMaxJitter has no effect when WithDelayFunc is set, since the custom function owns the delay calculation entirely.
func WithOnRetry ¶
WithOnRetry registers fn to be called before each wait. It is not called on the final attempt since no retry will occur.
func WithRetryIf ¶
func WithTimeout ¶ added in v1.0.2
WithTimeout sets a deadline on each individual call to fn, distinct from the parent context deadline which governs the entire retry operation. If fn exceeds the timeout, its context is cancelled and the attempt is retried (subject to the usual retry rules). Zero disables per-attempt timeouts, which is the default.
type RetryAfterer ¶
RetryAfterer allows errors to specify a custom wait duration.