try

package module
v1.0.2 Latest Latest
Warning

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

Go to latest
Published: May 8, 2026 License: MIT Imports: 6 Imported by: 0

README

try

A small, generic Go library for retrying fallible operations with exponential backoff and pluggable jitter strategies.

Features

  • Generic — works with any return type via Do[T]
  • Exponential backoff with pluggable jitter — Full Jitter (default) or Equal Jitter
  • Permanent errors — stop retrying immediately for non-recoverable failures
  • IsPermanent(err) — inspect whether an error originated from a permanent failure
  • Per-error budgetsWithAttemptsForError(n, err) caps retries for a specific error independently of the global limit
  • Per-attempt timeoutWithTimeout(d) cancels a single slow attempt without affecting the overall retry budget
  • Error aggregationWithAllErrors() collects every attempt error; inspect the full history via errors.Is / errors.As
  • Custom delay functionWithDelayFunc replaces the built-in backoff with any schedule: fixed, linear, or error-dependent
  • Jitter window capWithMaxJitter(d) constrains spread independently of the backoff cap
  • RetryAfterer interface — errors can specify their own wait duration (e.g. HTTP 429)
  • Custom predicates — decide per-error whether to retry
  • Testable — injectable Clock interface for time-travel in unit tests
  • Infinite retryWithInfiniteRetry() retries until success or context cancellation
  • Context-aware — honours cancellation and deadline at every wait point

Installation

go get github.com/nodivbyzero/try

Quick Start

val, err := try.Do(ctx, func(ctx context.Context) (string, error) {
    return callExternalAPI(ctx)
})

Do retries up to 5 times by default, with exponential backoff capped at 30 seconds.

Default retry behaviour: 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 will be retried unless you opt out. For production use, always supply a WithRetryIf predicate to avoid wasting attempts on non-transient failures.

Infinite Retry

Use WithInfiniteRetry to retry until the function succeeds, a Permanent error is returned, or the context is cancelled. Always pair it with a context deadline:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

val, err := try.Do(ctx, fetchUser,
    try.WithInfiniteRetry(),
    try.WithOnRetry(func(info try.RetryInfo) {
        slog.Warn("retrying", "attempt", info.Attempt, "error", info.Err)
    }),
)
// err will be context.DeadlineExceeded (wrapping the last op error) if
// the function never succeeds within the timeout.

Options

Each option is a functional setter for a field on Config:

Option Config Field Default Description
WithAttempts(n int) MaxAttempts 5 Total attempts including the first call
WithInfiniteRetry() MaxAttempts Retry until success, Permanent error, or context cancellation
WithInitialDelay(d time.Duration) InitialDelay 200ms Starting backoff; doubles each attempt up to MaxDelay
WithMaxDelay(d time.Duration) MaxDelay 30s Upper bound on any single wait regardless of backoff growth
WithJitter(s JitterStrategy) Jitter FullJitter Jitter strategy: FullJitter or EqualJitter
WithDelayFunc(fn func(int, error) time.Duration) DelayFunc nil Replace built-in backoff entirely; RetryAfterer still takes precedence
WithMaxJitter(d time.Duration) MaxJitter disabled Cap jitter window independently of backoff cap
WithRetryIf(fn func(error) bool) Predicate retry all Return false to stop retrying for a given error
WithAttemptsForError(n int, err error) ErrorBudgets Cap retries for a specific error; multiple calls accumulate
WithTimeout(d time.Duration) AttemptTimeout disabled Per-attempt deadline; cancelled attempts are retried
WithAllErrors() AllErrors false Aggregate all attempt errors into *AttemptErrors
WithOnRetry(fn func(RetryInfo)) OnRetry nil Callback fired before each wait — use for logging or metrics
WithClock(clk Clock) Clock time.After Injectable clock for time-travel in tests
val, err := try.Do(ctx, fetchUser,
    try.WithAttempts(10),                  // Config.MaxAttempts  = 10
    try.WithInitialDelay(500*time.Millisecond),   // Config.InitialDelay = 500ms
    try.WithMaxDelay(2*time.Minute),       // Config.MaxDelay     = 2m
    try.WithJitter(try.EqualJitter),       // Config.Jitter       = EqualJitter
    try.WithRetryIf(func(err error) bool {
        return isTransient(err)            // Config.Predicate
    }),
    try.WithOnRetry(func(info try.RetryInfo) {
        slog.Warn("retrying",
            "attempt", info.Attempt,
            "delay",   info.Delay,
            "error",   info.Err,
        )
    }),
)

Stopping Immediately: Permanent

Wrap an error with try.Permanent to stop the retry loop without waiting for remaining attempts:

val, err := try.Do(ctx, func(ctx context.Context) (*User, error) {
    u, err := db.Find(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, try.Permanent(err) // no point retrying
    }
    return u, err
})

The underlying error is unwrapped, so errors.Is / errors.As work normally on the returned error.

Use IsPermanent to check whether an error came from a permanent failure at any call site — without unwrapping manually:

val, err := try.Do(ctx, fn)
if try.IsPermanent(err) {
    // non-recoverable — do not retry at a higher level
    return err
}

IsPermanent works through additional wrapping layers, so fmt.Errorf("%w", permanentErr) is correctly detected.

Respecting Retry-After: RetryAfterer

If your error type knows how long the caller should wait (e.g. a rate-limit response), implement the RetryAfterer interface and try will use that duration instead of the computed backoff:

type RateLimitError struct {
    RetryIn time.Duration
}

func (e RateLimitError) Error() string             { return "rate limited" }
func (e RateLimitError) RetryAfter() time.Duration { return e.RetryIn }

The duration is still capped at MaxDelay.

Backoff Algorithm

The exponential cap for attempt n is min(MaxDelay, InitialDelay × 2^(n−1)). The jitter strategy then derives the actual wait from that cap:

Strategy Formula Behaviour
FullJitter (default) rand[0, cap) Maximally spreads retriers; may produce very short waits
EqualJitter cap/2 + rand[0, cap/2) Guarantees at least half the backoff; softer lower bound

Both strategies enforce a 1ms minimum floor. The Full Jitter approach is recommended by AWS for avoiding thundering herd; Equal Jitter is preferable when a minimum wait time matters.

Jitter Window Cap

By default the jitter window equals the full backoff cap, so FullJitter with a 30s cap draws delays from the entire [0, 30s) range. Use WithMaxJitter to constrain the spread independently — useful for services where you want long base delays but minimal pile-up variance:

// Base delay grows to 30s, but jitter is capped at 500ms.
// Delays will be in [0, 500ms) regardless of how large the backoff has grown.
try.Do(ctx, fn,
    try.WithInitialDelay(1*time.Second),
    try.WithMaxDelay(30*time.Second),
    try.WithMaxJitter(500*time.Millisecond),
)

When MaxJitter is larger than the current backoff cap it has no effect — the backoff cap is always the effective ceiling.

Custom Delay Function

WithDelayFunc replaces the built-in exponential backoff with any schedule you need. The function receives the 1-based attempt number and the error that caused the failure:

// Fixed delay — no backoff at all.
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: long wait for throttle errors, short for others.
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
})

WithDelayFunc takes precedence over WithInitialDelay, WithMaxDelay, and WithJitter. 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 *AttemptErrors, which implements Unwrap() []error for Go 1.20+ multi-error unwrapping. This lets you inspect the full failure history 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() {
        slog.Warn("attempt failed", "attempt", i+1, "error", e)
    }
}

// errors.Is traverses all attempt errors, not just the last one.
if errors.Is(err, ErrRateLimit) {
    // at least one attempt was rate-limited
}

WithAllErrors is opt-in — the default behaviour (last error only) is unchanged and has no allocation overhead.

Per-Attempt Timeout

WithTimeout sets a deadline on each individual call to fn, distinct from the parent context deadline which governs the entire retry operation. If fn blocks longer than the timeout its context is cancelled and the attempt is retried:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // overall budget
defer cancel()

val, err := try.Do(ctx, callSlowService,
    try.WithAttempts(5),
    try.WithTimeout(2*time.Second), // each attempt gets 2s
    try.WithRetryIf(func(err error) bool {
        // Retry per-attempt timeouts; stop on other errors.
        return errors.Is(err, context.DeadlineExceeded)
    }),
)

The parent context deadline still governs the overall operation — if the parent is cancelled mid-retry the loop stops immediately.

Per-Error Attempt Budgets

WithAttemptsForError sets an independent retry cap for a specific error value. When that error is returned and its budget is exhausted, the loop stops immediately — even if the global WithAttempts budget has remaining attempts.

var ErrRateLimit = errors.New("rate limited")
var ErrUnavailable = errors.New("service unavailable")

val, err := 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
)

Multiple WithAttemptsForError calls accumulate independent budgets. Matching uses errors.Is, so wrapped errors are detected correctly:

// This will match ErrRateLimit even through fmt.Errorf wrapping.
return 0, fmt.Errorf("upstream: %w", ErrRateLimit)

Best Practice: Filtering Retryable Errors

Because Do retries all errors by default, use WithRetryIf to restrict retries to genuinely transient failures in production code:

// HTTP example: only retry on 5xx or network errors, never on 4xx.
val, err := try.Do(ctx, fetchUser,
    try.WithRetryIf(func(err error) bool {
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            return httpErr.StatusCode >= 500
        }
        return true // retry network/timeout errors
    }),
)
// gRPC example: retry on Unavailable and DeadlineExceeded, not on
// InvalidArgument, NotFound, PermissionDenied, etc.
try.WithRetryIf(func(err error) bool {
    switch status.Code(err) {
    case codes.Unavailable, codes.ResourceExhausted:
        return true
    default:
        return false
    }
})

Errors that should never be retried: validation failures, authentication errors, not-found responses, and any error that will produce the same result on every attempt. Wrap these with Permanent or filter them out via WithRetryIf to fail fast and avoid unnecessary load on downstream services.

Examples

Runnable examples for all major features are in example_test.go and render on pkg.go.dev. They cover:

  • ExampleDo — minimal zero-config usage
  • ExampleDo_transientFailure — flaky call with WithRetryIf predicate
  • ExampleDo_permanentError — early exit with Permanent
  • ExampleDo_onRetry — structured logging via WithOnRetry
  • ExampleDo_retryAfter — honouring RetryAfterer on rate-limit errors
  • ExampleDo_equalJitterEqualJitter with WithMaxDelay
  • ExamplePermanenterrors.Is through the Permanent wrapper

Testing

Pass a testClock via WithClock to control time without real sleeps:

type testClock struct {
    ch chan time.Time
}
func (c *testClock) After(d time.Duration) <-chan time.Time { return c.ch }
func (c *testClock) Now() time.Time                         { return time.Now() }

clk := &testClock{ch: make(chan time.Time)}
go func() {
    _, _ = try.Do(ctx, alwaysFails, try.WithClock(clk), try.WithAttempts(3))
}()
clk.ch <- time.Now() // advance past first wait instantly

License

MIT

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

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

func IsPermanent(err error) bool

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

func Permanent(err error) error

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 Clock

type Clock interface {
	After(d time.Duration) <-chan time.Time
	Now() time.Time
}

Clock interface allows for "time-travel" in unit tests.

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

func WithAttempts(n int) Option

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

func WithAttemptsForError(n int, target error) Option

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 WithClock

func WithClock(clk Clock) Option

func WithDelayFunc added in v1.0.2

func WithDelayFunc(fn func(attempt int, err error) time.Duration) Option

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 WithInitialDelay(d time.Duration) Option

func WithJitter

func WithJitter(s JitterStrategy) Option

func WithMaxDelay

func WithMaxDelay(d time.Duration) Option

func WithMaxJitter added in v1.0.2

func WithMaxJitter(d time.Duration) Option

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

func WithOnRetry(fn func(RetryInfo)) Option

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 WithRetryIf(p func(error) bool) Option

func WithTimeout added in v1.0.2

func WithTimeout(d time.Duration) Option

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

type RetryAfterer interface {
	RetryAfter() time.Duration
}

RetryAfterer allows errors to specify a custom wait duration.

type RetryInfo

type RetryInfo struct {
	Attempt int           // 1-based attempt number that just failed
	Err     error         // error returned by the attempt
	Delay   time.Duration // how long Do will wait before the next attempt
}

RetryInfo carries context about a failed attempt, passed to the OnRetry callback.

Jump to

Keyboard shortcuts

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