rebound

package
v0.7.9 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 8 Imported by: 0

README

Rebound

Rebound is a small, stateless Go retry primitive for context-aware operations. Each Do call owns its configuration, retry budget, and backoff state. There are no global policies to share, mutate, or leak across requests.

Use it for operations that are safe to repeat, such as a model catalog fetch or an HTTP GET. Do not use it for an action with possible remote side effects unless the remote operation is explicitly idempotent.

go get github.com/psyb0t/rebound

Usage

Do receives the caller's context, a context-aware operation, and zero or more per-call options:

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"time"

	"github.com/psyb0t/ctxerrors"
	"github.com/psyb0t/ctxerrors/commerr"
	"github.com/psyb0t/rebound"
)

const reportEndpoint = "https://reports.example.com/current"

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	client := &http.Client{Timeout: 5 * time.Second}
	report, err := fetchReport(ctx, client, reportEndpoint)
	if err != nil {
		slog.Error("fetch report", "err", err)

		return
	}

	fmt.Println(string(report))
}

func fetchReport(
	ctx context.Context,
	client *http.Client,
	endpoint string,
) ([]byte, error) {
	var report []byte

	err := rebound.Do(
		ctx,
		func(ctx context.Context) error {
			request, err := http.NewRequestWithContext(
				ctx,
				http.MethodGet,
				endpoint,
				nil,
			)
			if err != nil {
				return ctxerrors.Wrap(err, "create report request")
			}

			response, err := client.Do(request)
			if err != nil {
				return ctxerrors.Wrap(err, "request report")
			}

			body, readErr := io.ReadAll(response.Body)
			closeErr := response.Body.Close()
			if readErr != nil {
				return ctxerrors.Wrap(readErr, "read report response")
			}
			if closeErr != nil {
				return ctxerrors.Wrap(closeErr, "close report response")
			}

			if response.StatusCode == http.StatusTooManyRequests {
				return ctxerrors.Wrap(
					commerr.ErrRateLimited,
					"report service rate limited the request",
				)
			}
			if response.StatusCode == http.StatusUnauthorized ||
				response.StatusCode == http.StatusForbidden {
				return ctxerrors.Wrap(
					commerr.ErrNotAuthenticated,
					"report request was not authorized",
				)
			}
			if response.StatusCode >= http.StatusBadRequest &&
				response.StatusCode < http.StatusInternalServerError {
				return ctxerrors.Wrap(
					commerr.ErrInvalidArgument,
					"report request was rejected",
				)
			}
			if response.StatusCode >= http.StatusInternalServerError {
				return ctxerrors.Wrap(
					commerr.ErrUnavailable,
					"report service is unavailable",
				)
			}

			report = body

			return nil
		},
		rebound.WithMaxAttempts(4),
		rebound.WithInitialDelay(200*time.Millisecond),
		rebound.WithDelayMultiplier(1.4),
		rebound.WithMaxDelay(2*time.Second),
		rebound.WithMaxElapsed(10*time.Second),
		rebound.WithJitter(true),
		rebound.WithNonRetryables(
			commerr.ErrInvalidArgument,
			commerr.ErrNotAuthenticated,
		),
		rebound.WithPreAttemptHandler(func(attempt rebound.Attempt) {
			slog.DebugContext(
				ctx,
				"starting report attempt",
				"attempt", attempt.Number,
			)
		}),
		rebound.WithPostAttemptHandler(func(attempt rebound.Attempt) {
			slog.DebugContext(
				ctx,
				"report attempt completed",
				"attempt", attempt.Number,
				"will_retry", attempt.RetryDelay > 0,
			)
		}),
	)
	if err == nil {
		return report, nil
	}

	var exhausted *rebound.ExhaustedError
	if errors.As(err, &exhausted) {
		return nil, ctxerrors.Wrapf(
			err,
			"fetch report exhausted after %d attempts",
			exhausted.Attempts,
		)
	}

	return nil, err
}

The closure captures report from fetchReport's enclosing scope. On success, Do returns nil and the caller uses that value normally. Rebound intentionally does not offer a separate value-returning API: the closure remains the one place that owns the operation's state and cleanup.

Behavior

The operation runs immediately. A failed retryable attempt waits before the next invocation. Rebound stops when any of the following happens:

  • The operation returns nil.
  • The operation returns an error matched by WithNonRetryables.
  • The operation returns or wraps context.Canceled or context.DeadlineExceeded.
  • The caller's context ends.
  • The maximum attempt count or elapsed-time budget is exhausted.

Rebound uses errors.Is, so wrapped sentinel errors classify the same as the sentinels themselves. Context cancellation and deadlines are never retried, even if the caller did not register them as non-retryable.

Defaults

With no options, one Do invocation uses:

Setting Default
Maximum attempts 3 total invocations, including the first
Initial delay 250 ms
Maximum delay 5 s
Maximum elapsed time 30 s, including operation time and waits
Jitter Disabled

The base retry delays use the configured multiplier: the first retry waits the initial delay, then each later retry multiplies that delay until the maximum delay. The default multiplier is 2.

Options

All options affect only the Do call that receives them.

Option Effect
WithMaxAttempts(n) Sets the total number of operation invocations. n must be positive; the initial invocation counts as attempt one.
WithInitialDelay(d) Sets the delay before the first retry. d must be positive.
WithDelayMultiplier(f) Sets the positive finite factor applied to each later delay. Values below 1 deliberately decrease delays; the default is 2.
WithMaxDelay(d) Caps exponential and server-requested retry delays. d must be positive and no smaller than the initial delay.
WithMaxElapsed(d) Caps all time spent in the call, including work and waits. d must be positive.
WithJitter(enabled) Enables equal jitter. A selected delay is randomized between 50% and 100% of its calculated value.
WithRetryAfter(extractor) Lets the operation provide a server-requested delay. When the extractor returns a positive duration and true, that duration replaces exponential backoff, still capped by WithMaxDelay.
WithNonRetryables(errs...) Registers error sentinels that must stop retrying. Each operation error is checked with errors.Is. Nil entries are ignored.
WithPreAttemptHandler(handler) Receives every attempt immediately before the operation runs. Number is set; Err and RetryDelay are zero values.
WithPostAttemptHandler(handler) Receives every completed operation attempt synchronously. RetryDelay is non-zero only when Rebound will retry.

WithRetryAfter is usually paired with a typed operation error that retains a parsed Retry-After value:

rebound.WithRetryAfter(func(err error) (time.Duration, bool) {
	var responseErr *ResponseError
	if !errors.As(err, &responseErr) || responseErr.RetryAfter <= 0 {
		return 0, false
	}

	return responseErr.RetryAfter, true
})

Errors

Invalid input to Do returns an error matching commerr.ErrInvalidArgument. Its wrapped message names the exact option and value, such as WithDelayMultiplier(NaN): must be positive and finite or WithMaxDelay(1ms): must be greater than or equal to WithInitialDelay(1s).

If retryable failures exhaust the budget, Do returns an *ExhaustedError. It matches both commerr.ErrExhausted and the final operation error:

if errors.Is(err, commerr.ErrExhausted) {
	var exhausted *rebound.ExhaustedError
	if errors.As(err, &exhausted) {
		// exhausted.Attempts is the number of invocations made.
		// exhausted.Cause is the final operation failure.
	}
}

An error classified as non-retryable is returned immediately, preserving its wrapping chain for errors.Is and errors.As.

Choosing the right operations

Good candidates are read-only requests, idempotent writes protected by a remote idempotency key, and retryable connection setup. Do not retry a tool invocation, payment, email send, or mutation solely because its response was lost: the remote system may already have completed it. Make that operation idempotent first, then configure the appropriate non-retryable sentinels.

Documentation

Overview

Package rebound retries context-aware operations with bounded exponential backoff. It is deliberately stateless: each Do call owns its configuration and retry state.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Do

func Do(
	ctx context.Context,
	operation func(context.Context) error,
	options ...Option,
) error

Do invokes operation until it succeeds, becomes non-retryable, the caller's context ends, or Rebound exhausts its per-call retry budget.

Types

type Attempt

type Attempt struct {
	Number     int
	Err        error
	RetryDelay time.Duration
}

Attempt describes one completed operation attempt. RetryDelay is non-zero only when Rebound will make another attempt after that delay.

type AttemptHandler

type AttemptHandler func(Attempt)

AttemptHandler observes an operation attempt. Pre-attempt handlers receive its number before invocation; post-attempt handlers additionally receive the operation error and any scheduled retry delay.

type ExhaustedError

type ExhaustedError struct {
	Attempts int
	Cause    error
}

ExhaustedError reports that an operation remained retryable until Rebound's attempt or elapsed-time budget ended.

func (*ExhaustedError) Error

func (e *ExhaustedError) Error() string

func (*ExhaustedError) Unwrap

func (e *ExhaustedError) Unwrap() []error

Unwrap exposes both the shared exhaustion sentinel and the final operation failure, so errors.Is works for either.

type Option

type Option func(*config)

Option configures one Do invocation. Options never mutate a shared retry policy: Do builds and validates a fresh configuration for every call.

func WithDelayMultiplier

func WithDelayMultiplier(multiplier float64) Option

WithDelayMultiplier sets the factor applied after each retryable failure. It must be positive and finite; values below one deliberately decrease subsequent retry delays.

func WithInitialDelay

func WithInitialDelay(delay time.Duration) Option

WithInitialDelay sets the delay before Rebound's first retry.

func WithJitter

func WithJitter(enabled bool) Option

WithJitter enables or disables equal jitter on each retry delay.

func WithMaxAttempts

func WithMaxAttempts(attempts int) Option

WithMaxAttempts sets the total number of operation invocations, including the first attempt.

func WithMaxDelay

func WithMaxDelay(delay time.Duration) Option

WithMaxDelay bounds exponential and retry-after delays.

func WithMaxElapsed

func WithMaxElapsed(elapsed time.Duration) Option

WithMaxElapsed sets the total time Rebound may spend retrying, including operation time and waits.

func WithNonRetryables

func WithNonRetryables(errs ...error) Option

WithNonRetryables registers sentinels that stop this Do invocation when errors.Is finds one anywhere in an operation error's wrapping chain.

func WithPostAttemptHandler

func WithPostAttemptHandler(handler AttemptHandler) Option

WithPostAttemptHandler receives each completed attempt, including a successful or terminal one. RetryDelay is non-zero only when Rebound will invoke the operation again.

func WithPreAttemptHandler

func WithPreAttemptHandler(handler AttemptHandler) Option

WithPreAttemptHandler receives each attempt immediately before the operation is invoked. It is synchronous so setup, logs, and metrics preserve order.

func WithRetryAfter

func WithRetryAfter(extract RetryAfter) Option

WithRetryAfter registers a per-call extractor for server-requested delays.

type RetryAfter

type RetryAfter func(error) (time.Duration, bool)

RetryAfter extracts a server-requested retry delay from an operation error. Returning false leaves the exponential backoff delay unchanged.

Jump to

Keyboard shortcuts

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