Help us shape the future of Go! Take the 2026 Go Developer Survey

retrytimeouts

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Apr 7, 2026 License: Artistic-2.0 Imports: 7 Imported by: 0

README

retrytimeouts

Package retrytimeouts provides retry logic with dynamic per-attempt timeout calculation.

Overview

This package provides an avast/retry-go style callback API with support for adjustable timeouts during each retry. It uses a "global" context to control the overall retry budget and creates "attempt" contexts with dynamic timeouts for each try. This also comes with a sane set of defaults as a good baseline for most kinds of retry operations.

A combination of features solves for most problems that would arise from retry operations:

  • Maximum attempts - Forces the algorithm to give up if repeated attempts don't yield success.
  • Maximum duration - Forces the algorithm to give up if no successes happen within a certain time frame.
  • Exponential backoff - A sqrt(2) exponential delay keeps single retries from waiting too long, while spreading out repeated retries that may fail too quickly and run out of max attempts. This also decreases the congestion that happens with repeated attempts.
  • Jitter - Adding random jitter to the retry delays solves for the Thundering Herd problem.
  • Adjustable timeouts - Providing an adjustable timeout after each request solves the opposite problem of exponential backoffs: slower, unresponsive errors that gobble up all of the max duration time in one go. Each new timeout is a certain percentage of the time left.

Basic Usage

The primary entry point is the Do function, which returns a Result:

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

result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    // attemptCtx has a timeout derived from remaining time budget
    return doWork(attemptCtx)
})
if result.Err != nil {
    log.Printf("Failed after %d attempts in %v: %v",
        result.Attempt, result.Elapsed, result.Err)
}

The Result contains retry statistics (Attempt, MaxAttempts, Elapsed, MaxDuration) plus the final Err. The attemptCtx automatically gets a timeout based on remaining time. If you have 30s total and use 50% (default), the first attempt gets ~15s, the second gets ~7.5s, and so on.

With Options

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        return doWork(attemptCtx)
    },
    retrytimeouts.Attempts(10),
    retrytimeouts.BaseDelay(2*time.Second),
    retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
        log.Printf("Attempt %d/%d failed after %v: %v",
            info.Attempt, info.MaxAttempts, info.Elapsed, info.Err)
    }),
    retrytimeouts.AdjustTimeoutPercent(30),  // More aggressive timeout shrinking
)

Returning Data

Use DoWithData for functions that return values:

result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
    return fetchData(attemptCtx)
})
if result.Err != nil {
    return "", result.Err
}
return result.Data, nil

HTTP Example

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

var body []byte
result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        req, _ := http.NewRequestWithContext(attemptCtx, "GET", url, nil)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        if resp.StatusCode != 200 {
            return fmt.Errorf("bad status: %d", resp.StatusCode)
        }

        body, err = io.ReadAll(resp.Body)
        return err
    },
    retrytimeouts.Attempts(5),
)
if result.Err != nil {
    log.Printf("HTTP failed after %d attempts: %v", result.Attempt, result.Err)
}

Database Example

For database operations, the attempt context timeout automatically propagates to queries:

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        // Context timeout automatically applies to the query
        return db.QueryRowContext(attemptCtx, query).Scan(&result)
    },
    retrytimeouts.AdjustTimeoutPercent(30),  // Aggressive for fast-fail
    retrytimeouts.MinAdjustTimeout(2*time.Second),
)
if result.Err != nil {
    log.Printf("DB query failed after %d attempts in %v", result.Attempt, result.Elapsed)
}

Available Options

Retry behavior:

- Attempts(n) - Maximum retry attempts (default: 8, 0 for unlimited)
- BaseDelay(d) - Initial delay between retries (default: √2 seconds)
- MaxDelay(d) - Maximum delay cap
- DelayTypeExponential() / DelayTypeFibonacci() / DelayTypeConstant()
- JitterPercent(pct) - Delay jitter (default: 10%)
- DelayIncludesAttempt(b) - Subtract attempt duration from delay (default: true)
- OnRetry(fn) - Callback after each failed attempt (receives AttemptInfo)
- RetryIf(fn) - Condition to continue retrying

Timeout calculation:

- AdjustTimeoutPercent(pct) - Percentage of remaining time for each timeout (default: 50)
- MinAdjustTimeout(d) - Minimum timeout value (default: 5s)
- TimeoutJitterPercent(pct) - Timeout jitter (default: 10%)

Result Type

Both Do and DoWithData return a Result[T] containing:

- Attempt - Total attempts made (1-indexed)
- MaxAttempts - Maximum allowed (0 = unlimited)
- AttemptDuration - How long the most recent attempt took
- Elapsed - Time since first attempt
- MaxDuration - Total budget from context deadline
- Err - Final error (nil on success)
- Data - The returned value (DoWithData), or struct{} (Do)

OnRetry callbacks receive AttemptInfo (same fields, without Data).

Fast-fail for responsive services:

retrytimeouts.Attempts(5),
retrytimeouts.AdjustTimeoutPercent(30),
retrytimeouts.MinAdjustTimeout(2*time.Second),

Conservative for flaky networks:

retrytimeouts.Attempts(10),
retrytimeouts.AdjustTimeoutPercent(60),
retrytimeouts.MinAdjustTimeout(10*time.Second),

Thread Safety

The Do function is safe for concurrent use - each call maintains its own state. Multiple goroutines can call Do simultaneously with the same options.

Package retrytimeouts provides retry logic with dynamic per-attempt timeout calculation, preventing slow failures from exhausting the entire retry time budget.

These combination of features solves for most problems that would arise from retry operations:

- Maximum attempts - Forces the algorithm to give up if repeated attempts don't yield success.
- Dynamic timeouts - Prevents slow failures from exhausting the entire retry time budget.
- Jitter - Prevents thundering herd problems.
- Adjustable timeouts - Providing an adjustable timeout after each request solves the
  opposite problem of exponential backoffs: slower, unresponsive errors that gobble up all
  of the max duration time in one go.  Each new timeout is a certain percentage of the time
  left.

Inspired by our Algorithm::Backoff::RetryTimeouts Perl module and Avast's retry-go API.

Functions

func IsRecoverable

func IsRecoverable(err error) bool

IsRecoverable returns true if the error was NOT wrapped with Unrecoverable(). Use this for blocklist-style retry logic where unmarked errors retry by default.

Example:

result := retrytimeouts.Do(ctx, fn, retrytimeouts.RetryIf(retrytimeouts.IsRecoverable))
func IsRetryable

func IsRetryable(err error) bool

IsRetryable returns true if the error was wrapped with Retryable(). Use this for allowlist-style retry logic where only marked errors retry.

Example:

result := retrytimeouts.Do(ctx, fn, retrytimeouts.RetryIf(retrytimeouts.IsRetryable))
func IsUnrecoverable

func IsUnrecoverable(err error) bool

IsUnrecoverable returns true if the error was wrapped with Unrecoverable(). This is the inverse of IsRecoverable, provided for clarity in conditional logic.

func Retryable

func Retryable(err error) error

Retryable wraps an error to mark it as transient (safe to retry). Use with IsRetryable for allowlist-style retry logic.

Example (marking MySQL transient errors):

if strings.Contains(err.Error(), "Deadlock found") {
    return retrytimeouts.Retryable(err)
}
func Unrecoverable

func Unrecoverable(err error) error

Unrecoverable wraps an error to mark it as permanent (never retry). Use with IsRecoverable for blocklist-style retry logic.

Example (marking permanent errors):

if strings.Contains(err.Error(), "Access denied") {
    return retrytimeouts.Unrecoverable(err)
}

Types

type AttemptInfo

type AttemptInfo struct { ... }

AttemptInfo contains statistics about retry attempts. This is used in OnRetry callbacks and embedded in Result.

type OnRetryFunc

type OnRetryFunc func(info AttemptInfo)

OnRetryFunc is called after each failed retry attempt. The AttemptInfo parameter contains attempt number, elapsed time, and the error.

type Option

type Option func(*config)

Option configures retry behavior.

func AdjustTimeoutPercent

func AdjustTimeoutPercent(pct int) Option

AdjustTimeoutPercent sets the percentage of remaining time to use for each attempt's timeout. For example, 50 means use 50% of remaining time. Valid range: 0 to 100. Default: 50

func Attempts

func Attempts(n uint64) Option

Attempts sets the maximum number of total attempts (not retries). For example, Attempts(3) means: 1 initial attempt + up to 2 retries = 3 total. Setting to 0 will retry indefinitely (until context cancellation or success). Default: 8

func BaseDelay

func BaseDelay(d time.Duration) Option

BaseDelay sets the initial delay between retries. Default: √2 seconds (~1.414s)

func DelayIncludesAttempt

func DelayIncludesAttempt(b bool) Option

DelayIncludesAttempt controls whether attempt duration is subtracted from the delay. When true (default), if an attempt takes 10s and the delay is 4s, no sleep occurs because the attempt already consumed more time than the delay period. Or only a partial sleep occurs, if the attempt took less time than the planned delay. When false, the full delay is always applied after each attempt. Default: true

func DelayTypeConstant

func DelayTypeConstant() Option

DelayTypeConstant sets constant backoff. Delays remain constant at the initial delay value.

func DelayTypeExponential

func DelayTypeExponential() Option

DelayTypeExponential sets exponential backoff (default). Delays grow exponentially: delay, delay2, delay4, etc.

func DelayTypeFibonacci

func DelayTypeFibonacci() Option

DelayTypeFibonacci sets fibonacci backoff. Delays follow the fibonacci sequence: 1, 1, 2, 3, 5, 8, 13, etc.

func JitterPercent

func JitterPercent(pct uint64) Option

JitterPercent sets the percentage of randomness to add to delays. For example, 10 means ±10% jitter. Default: 10

func LastErrorOnly

func LastErrorOnly(b bool) Option

LastErrorOnly returns only the final error instead of wrapping all errors. Default: true

func MaxDelay

func MaxDelay(d time.Duration) Option

MaxDelay sets the maximum delay between retries (delay cap). Default: no cap

func MinAdjustTimeout

func MinAdjustTimeout(d time.Duration) Option

MinAdjustTimeout sets the minimum timeout value for any attempt. This ensures at least one attempt even if the deadline is near. Default: 5 seconds

func OnRetry

func OnRetry(fn OnRetryFunc) Option

OnRetry registers a callback function called after each failed attempt. Useful for logging, metrics, or custom retry logic.

Example:

retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
    log.Printf("Attempt %d/%d failed after %v: %v",
        info.Attempt, info.MaxAttempts, info.Elapsed, info.Err)
})
func RetryIf

func RetryIf(fn RetryIfFunc) Option

RetryIf sets a condition function that determines whether to retry. The function receives the error from the failed attempt and returns true to continue retrying, false to stop immediately.

Example:

retrytimeouts.RetryIf(func(err error) bool {
    // Don't retry on authentication errors
    return !errors.Is(err, ErrAuthFailed)
})
func TimeoutJitterPercent

func TimeoutJitterPercent(pct int) Option

TimeoutJitterPercent adds randomness to timeout values (±percentage). For example, 10 means ±10% jitter on timeout calculations. Set to 0 to disable timeout jitter. Default: 10

func UntilSucceeded

func UntilSucceeded() Option

UntilSucceeded retries indefinitely until the function succeeds. Equivalent to Attempts(0). Requires a context with timeout/cancellation to avoid infinite loops.

type Result

type Result[T any] struct { ... }

Result contains the outcome of a retry operation, including statistics and optionally returned data. Check Err for success (nil) or failure.

func Do

func Do(ctx context.Context, fn RetryableFunc, opts ...Option) Result[struct{ ... }]

Do executes the retryable function with automatic retry and dynamic timeout calculation. This is the primary entry point for retry operations.

The context (ctx) serves as the "global" context controlling the overall retry budget. It must have a deadline set via context.WithTimeout or context.WithDeadline.

The function (fn) receives an "attempt" context for each try, with a timeout derived from the remaining time budget. This attempt context is automatically cancelled when the timeout expires or when the global context is cancelled.

Returns a Result containing retry statistics and the final error (nil on success).

Example:

ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()

result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    return doWork(attemptCtx)
})
if result.Err != nil {
    slog.Error("Operation failed",
        "attempts", result.Attempt,
        "elapsed", result.Elapsed,
        "error", result.Err,
    )
}

Example with options:

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        return doWork(attemptCtx)
    },
    retrytimeouts.Attempts(10),
    retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
        log.Printf("Attempt %d/%d: %v", info.Attempt, info.MaxAttempts, info.Err)
    }),
)
func DoWithData

func DoWithData[T any](ctx context.Context, fn RetryableFuncWithData[T], opts ...Option) Result[T]

DoWithData executes the retryable function with retry and returns both data and result info. This is the generic version of Do that preserves the return value.

Example:

ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()

result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
    return fetchData(attemptCtx)
})
if result.Err != nil {
    return "", result.Err
}
return result.Data, nil
type RetryIfFunc

type RetryIfFunc func(err error) bool

RetryIfFunc determines whether to retry based on the error. Return true to continue retrying, false to stop immediately.

type RetryableFunc

type RetryableFunc func(ctx context.Context) error

RetryableFunc is the function signature for retry operations. The function receives a context with the calculated timeout already applied. Return nil on success, or an error to trigger a retry.

The context deadline dynamically adjusts based on remaining time budget, preventing slow failures from exhausting the entire retry duration.

Example:

err := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    return doWork(attemptCtx)  // Use attemptCtx for the operation
})
type RetryableFuncWithData

type RetryableFuncWithData[T any] func(ctx context.Context) (T, error)

RetryableFuncWithData is the function signature for retry operations that return data. The function receives a context with the calculated timeout already applied.

Examples

Example demonstrates basic usage with the Do function.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
		attemptCount++
		fmt.Printf("Attempt %d\n", attemptCount)

		// Simulate success on second attempt
		if attemptCount >= 2 {
			return nil
		}

		return errors.New("temporary error")
	})

	if result.Err == nil {
		fmt.Println("Success")
	}

}

Output:

Attempt 1
Attempt 2
Success
DelayTypes

Example_delayTypes demonstrates different backoff algorithms.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	// Exponential backoff (default)
	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeExponential(),
	)
	if result.Err == nil {
		fmt.Println("Exponential: success")
	}

	// Constant backoff
	result = retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeConstant(),
		retrytimeouts.BaseDelay(1*time.Second),
	)
	if result.Err == nil {
		fmt.Println("Constant: success")
	}

	// Fibonacci backoff
	result = retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeFibonacci(),
	)
	if result.Err == nil {
		fmt.Println("Fibonacci: success")
	}

}

Output:

Exponential: success
Constant: success
Fibonacci: success
OnRetry

Example_onRetry demonstrates the OnRetry callback for logging.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			attemptCount++
			if attemptCount < 3 {
				return fmt.Errorf("error %d", attemptCount)
			}
			return nil
		},
		retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
			fmt.Printf("Attempt %d failed: %v\n", info.Attempt, info.Err)
		}),
	)

	if result.Err == nil {
		fmt.Println("Eventually succeeded")
	}

}

Output:

Attempt 1 failed: error 1
Attempt 2 failed: error 2
Eventually succeeded
RetryIf

Example_retryIf shows conditional retry logic.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	var ErrPermanent = errors.New("permanent error")
	attemptCount := 0

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			attemptCount++
			if attemptCount == 1 {
				return errors.New("temporary error")
			}
			return ErrPermanent
		},
		retrytimeouts.RetryIf(func(err error) bool {
			return !errors.Is(err, ErrPermanent)
		}),
	)

	fmt.Printf("Stopped on permanent error: %v\n", errors.Is(result.Err, ErrPermanent))

}

Output:

Stopped on permanent error: true
WithData

Example_withData demonstrates using DoWithData for functions that return values.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
		attemptCount++

		if attemptCount >= 2 {
			return "success data", nil
		}

		return "", errors.New("temporary error")
	})

	if result.Err == nil {
		fmt.Printf("Got result: %s\n", result.Data)
	}

}

Output:

Got result: success data
WithOptions

Example_withOptions shows how to customize retry behavior.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			// attemptCtx has timeout derived from remaining time
			deadline, _ := attemptCtx.Deadline()
			fmt.Printf("Has deadline: %v\n", !deadline.IsZero())
			return nil
		},
		retrytimeouts.Attempts(5),
		retrytimeouts.BaseDelay(100*time.Millisecond),
		retrytimeouts.AdjustTimeoutPercent(40),
	)

	if result.Err == nil {
		fmt.Println("Configured successfully")
	}

}

Output:

Has deadline: true
Configured successfully

Readme created from Go doc with goreadme

Documentation

Overview

Package retrytimeouts provides retry logic with dynamic per-attempt timeout calculation.

Overview

This package provides an avast/retry-go style callback API with support for adjustable timeouts during each retry. It uses a "global" context to control the overall retry budget and creates "attempt" contexts with dynamic timeouts for each try. This also comes with a sane set of defaults as a good baseline for most kinds of retry operations.

A combination of features solves for most problems that would arise from retry operations:

* **Maximum attempts** - Forces the algorithm to give up if repeated attempts don't yield success. * **Maximum duration** - Forces the algorithm to give up if no successes happen within a certain time frame. * **Exponential backoff** - A `sqrt(2)` exponential delay keeps single retries from waiting too long, while spreading out repeated retries that may fail too quickly and run out of max attempts. This also decreases the congestion that happens with repeated attempts. * **Jitter** - Adding random jitter to the retry delays solves for the Thundering Herd problem. * **Adjustable timeouts** - Providing an adjustable timeout after each request solves the opposite problem of exponential backoffs: slower, unresponsive errors that gobble up all of the max duration time in one go. Each new timeout is a certain percentage of the time left.

Basic Usage

The primary entry point is the Do function, which returns a Result:

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

result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    // attemptCtx has a timeout derived from remaining time budget
    return doWork(attemptCtx)
})
if result.Err != nil {
    log.Printf("Failed after %d attempts in %v: %v",
        result.Attempt, result.Elapsed, result.Err)
}

The Result contains retry statistics (Attempt, MaxAttempts, Elapsed, MaxDuration) plus the final Err. The attemptCtx automatically gets a timeout based on remaining time. If you have 30s total and use 50% (default), the first attempt gets ~15s, the second gets ~7.5s, and so on.

With Options

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        return doWork(attemptCtx)
    },
    retrytimeouts.Attempts(10),
    retrytimeouts.BaseDelay(2*time.Second),
    retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
        log.Printf("Attempt %d/%d failed after %v: %v",
            info.Attempt, info.MaxAttempts, info.Elapsed, info.Err)
    }),
    retrytimeouts.AdjustTimeoutPercent(30),  // More aggressive timeout shrinking
)

Returning Data

Use DoWithData for functions that return values:

result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
    return fetchData(attemptCtx)
})
if result.Err != nil {
    return "", result.Err
}
return result.Data, nil

HTTP Example

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

var body []byte
result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        req, _ := http.NewRequestWithContext(attemptCtx, "GET", url, nil)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        if resp.StatusCode != 200 {
            return fmt.Errorf("bad status: %d", resp.StatusCode)
        }

        body, err = io.ReadAll(resp.Body)
        return err
    },
    retrytimeouts.Attempts(5),
)
if result.Err != nil {
    log.Printf("HTTP failed after %d attempts: %v", result.Attempt, result.Err)
}

Database Example

For database operations, the attempt context timeout automatically propagates to queries:

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        // Context timeout automatically applies to the query
        return db.QueryRowContext(attemptCtx, query).Scan(&result)
    },
    retrytimeouts.AdjustTimeoutPercent(30),  // Aggressive for fast-fail
    retrytimeouts.MinAdjustTimeout(2*time.Second),
)
if result.Err != nil {
    log.Printf("DB query failed after %d attempts in %v", result.Attempt, result.Elapsed)
}

Available Options

Retry behavior:

  • Attempts(n) - Maximum retry attempts (default: 8, 0 for unlimited)
  • BaseDelay(d) - Initial delay between retries (default: √2 seconds)
  • MaxDelay(d) - Maximum delay cap
  • DelayTypeExponential() / DelayTypeFibonacci() / DelayTypeConstant()
  • JitterPercent(pct) - Delay jitter (default: 10%)
  • DelayIncludesAttempt(b) - Subtract attempt duration from delay (default: true)
  • OnRetry(fn) - Callback after each failed attempt (receives AttemptInfo)
  • RetryIf(fn) - Condition to continue retrying

Timeout calculation:

  • AdjustTimeoutPercent(pct) - Percentage of remaining time for each timeout (default: 50)
  • MinAdjustTimeout(d) - Minimum timeout value (default: 5s)
  • TimeoutJitterPercent(pct) - Timeout jitter (default: 10%)

Result Type

Both Do and DoWithData return a Result[T] containing:

  • Attempt - Total attempts made (1-indexed)
  • MaxAttempts - Maximum allowed (0 = unlimited)
  • AttemptDuration - How long the most recent attempt took
  • Elapsed - Time since first attempt
  • MaxDuration - Total budget from context deadline
  • Err - Final error (nil on success)
  • Data - The returned value (DoWithData), or struct{} (Do)

OnRetry callbacks receive AttemptInfo (same fields, without Data).

Fast-fail for responsive services:

retrytimeouts.Attempts(5),
retrytimeouts.AdjustTimeoutPercent(30),
retrytimeouts.MinAdjustTimeout(2*time.Second),

Conservative for flaky networks:

retrytimeouts.Attempts(10),
retrytimeouts.AdjustTimeoutPercent(60),
retrytimeouts.MinAdjustTimeout(10*time.Second),

Thread Safety

The Do function is safe for concurrent use - each call maintains its own state. Multiple goroutines can call Do simultaneously with the same options.

Package retrytimeouts provides retry logic with dynamic per-attempt timeout calculation, preventing slow failures from exhausting the entire retry time budget.

These combination of features solves for most problems that would arise from retry operations:

  • Maximum attempts - Forces the algorithm to give up if repeated attempts don't yield success.
  • Dynamic timeouts - Prevents slow failures from exhausting the entire retry time budget.
  • Jitter - Prevents thundering herd problems.
  • Adjustable timeouts - Providing an adjustable timeout after each request solves the opposite problem of exponential backoffs: slower, unresponsive errors that gobble up all of the max duration time in one go. Each new timeout is a certain percentage of the time left.

Inspired by our Algorithm::Backoff::RetryTimeouts Perl module and Avast's retry-go API.

Example

Example demonstrates basic usage with the Do function.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
		attemptCount++
		fmt.Printf("Attempt %d\n", attemptCount)

		// Simulate success on second attempt
		if attemptCount >= 2 {
			return nil
		}

		return errors.New("temporary error")
	})

	if result.Err == nil {
		fmt.Println("Success")
	}

}
Output:
Attempt 1
Attempt 2
Success
Example (DelayTypes)

Example_delayTypes demonstrates different backoff algorithms.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	// Exponential backoff (default)
	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeExponential(),
	)
	if result.Err == nil {
		fmt.Println("Exponential: success")
	}

	// Constant backoff
	result = retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeConstant(),
		retrytimeouts.BaseDelay(1*time.Second),
	)
	if result.Err == nil {
		fmt.Println("Constant: success")
	}

	// Fibonacci backoff
	result = retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			return nil
		},
		retrytimeouts.DelayTypeFibonacci(),
	)
	if result.Err == nil {
		fmt.Println("Fibonacci: success")
	}

}
Output:
Exponential: success
Constant: success
Fibonacci: success
Example (OnRetry)

Example_onRetry demonstrates the OnRetry callback for logging.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			attemptCount++
			if attemptCount < 3 {
				return fmt.Errorf("error %d", attemptCount)
			}
			return nil
		},
		retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
			fmt.Printf("Attempt %d failed: %v\n", info.Attempt, info.Err)
		}),
	)

	if result.Err == nil {
		fmt.Println("Eventually succeeded")
	}

}
Output:
Attempt 1 failed: error 1
Attempt 2 failed: error 2
Eventually succeeded
Example (RetryIf)

Example_retryIf shows conditional retry logic.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	var ErrPermanent = errors.New("permanent error")
	attemptCount := 0

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			attemptCount++
			if attemptCount == 1 {
				return errors.New("temporary error")
			}
			return ErrPermanent
		},
		retrytimeouts.RetryIf(func(err error) bool {
			return !errors.Is(err, ErrPermanent)
		}),
	)

	fmt.Printf("Stopped on permanent error: %v\n", errors.Is(result.Err, ErrPermanent))

}
Output:
Stopped on permanent error: true
Example (WithData)

Example_withData demonstrates using DoWithData for functions that return values.

package main

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

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	attemptCount := 0

	result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
		attemptCount++

		if attemptCount >= 2 {
			return "success data", nil
		}

		return "", errors.New("temporary error")
	})

	if result.Err == nil {
		fmt.Printf("Got result: %s\n", result.Data)
	}

}
Output:
Got result: success data
Example (WithOptions)

Example_withOptions shows how to customize retry behavior.

package main

import (
	"context"
	"fmt"
	"time"

	retrytimeouts "github.com/GrantStreetGroup/go-retry-timeouts"
)

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

	result := retrytimeouts.Do(ctx,
		func(attemptCtx context.Context) error {
			// attemptCtx has timeout derived from remaining time
			deadline, _ := attemptCtx.Deadline()
			fmt.Printf("Has deadline: %v\n", !deadline.IsZero())
			return nil
		},
		retrytimeouts.Attempts(5),
		retrytimeouts.BaseDelay(100*time.Millisecond),
		retrytimeouts.AdjustTimeoutPercent(40),
	)

	if result.Err == nil {
		fmt.Println("Configured successfully")
	}

}
Output:
Has deadline: true
Configured successfully

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrMaxAttemptsReached = errors.New("max attempts reached")

ErrMaxAttemptsReached is returned when the retry loop exhausts all configured attempts. Use errors.Is(err, ErrMaxAttemptsReached) to check for this condition.

Functions

func IsRecoverable

func IsRecoverable(err error) bool

IsRecoverable returns true if the error was NOT wrapped with Unrecoverable(). Use this for blocklist-style retry logic where unmarked errors retry by default.

Example:

result := retrytimeouts.Do(ctx, fn, retrytimeouts.RetryIf(retrytimeouts.IsRecoverable))

func IsRetryable

func IsRetryable(err error) bool

IsRetryable returns true if the error was wrapped with Retryable(). Use this for allowlist-style retry logic where only marked errors retry.

Example:

result := retrytimeouts.Do(ctx, fn, retrytimeouts.RetryIf(retrytimeouts.IsRetryable))

func IsUnrecoverable

func IsUnrecoverable(err error) bool

IsUnrecoverable returns true if the error was wrapped with Unrecoverable(). This is the inverse of IsRecoverable, provided for clarity in conditional logic.

func Retryable

func Retryable(err error) error

Retryable wraps an error to mark it as transient (safe to retry). Use with IsRetryable for allowlist-style retry logic.

Example (marking MySQL transient errors):

if strings.Contains(err.Error(), "Deadlock found") {
    return retrytimeouts.Retryable(err)
}

func Unrecoverable

func Unrecoverable(err error) error

Unrecoverable wraps an error to mark it as permanent (never retry). Use with IsRecoverable for blocklist-style retry logic.

Example (marking permanent errors):

if strings.Contains(err.Error(), "Access denied") {
    return retrytimeouts.Unrecoverable(err)
}

Types

type AttemptInfo

type AttemptInfo struct {
	Attempt         uint64        // Total attempts made (1-indexed)
	MaxAttempts     uint64        // Maximum allowed (0 = unlimited)
	AttemptDuration time.Duration // Duration of the most recent attempt
	Elapsed         time.Duration // Time since first attempt
	MaxDuration     time.Duration // Total budget from context deadline
	Err             error         // Final error (nil on success)
}

AttemptInfo contains statistics about retry attempts. This is used in OnRetry callbacks and embedded in Result.

type OnRetryFunc

type OnRetryFunc func(info AttemptInfo)

OnRetryFunc is called after each failed retry attempt. The AttemptInfo parameter contains attempt number, elapsed time, and the error.

type Option

type Option func(*config)

Option configures retry behavior.

func AdjustTimeoutPercent

func AdjustTimeoutPercent(pct int) Option

AdjustTimeoutPercent sets the percentage of remaining time to use for each attempt's timeout. For example, 50 means use 50% of remaining time. Valid range: 0 to 100. Default: 50

func Attempts

func Attempts(n uint64) Option

Attempts sets the maximum number of total attempts (not retries). For example, Attempts(3) means: 1 initial attempt + up to 2 retries = 3 total. Setting to 0 will retry indefinitely (until context cancellation or success). Default: 8

func BaseDelay

func BaseDelay(d time.Duration) Option

BaseDelay sets the initial delay between retries. Default: √2 seconds (~1.414s)

func DelayIncludesAttempt

func DelayIncludesAttempt(b bool) Option

DelayIncludesAttempt controls whether attempt duration is subtracted from the delay. When true (default), if an attempt takes 10s and the delay is 4s, no sleep occurs because the attempt already consumed more time than the delay period. Or only a partial sleep occurs, if the attempt took less time than the planned delay. When false, the full delay is always applied after each attempt. Default: true

func DelayTypeConstant

func DelayTypeConstant() Option

DelayTypeConstant sets constant backoff. Delays remain constant at the initial delay value.

func DelayTypeExponential

func DelayTypeExponential() Option

DelayTypeExponential sets exponential backoff (default). Delays grow exponentially: delay, delay*2, delay*4, etc.

func DelayTypeFibonacci

func DelayTypeFibonacci() Option

DelayTypeFibonacci sets fibonacci backoff. Delays follow the fibonacci sequence: 1, 1, 2, 3, 5, 8, 13, etc.

func JitterPercent

func JitterPercent(pct uint64) Option

JitterPercent sets the percentage of randomness to add to delays. For example, 10 means ±10% jitter. Default: 10

func LastErrorOnly

func LastErrorOnly(b bool) Option

LastErrorOnly returns only the final error instead of wrapping all errors. Default: true

func MaxDelay

func MaxDelay(d time.Duration) Option

MaxDelay sets the maximum delay between retries (delay cap). Default: no cap

func MinAdjustTimeout

func MinAdjustTimeout(d time.Duration) Option

MinAdjustTimeout sets the minimum timeout value for any attempt. This ensures at least one attempt even if the deadline is near. Default: 5 seconds

func OnRetry

func OnRetry(fn OnRetryFunc) Option

OnRetry registers a callback function called after each failed attempt. Useful for logging, metrics, or custom retry logic.

Example:

retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
    log.Printf("Attempt %d/%d failed after %v: %v",
        info.Attempt, info.MaxAttempts, info.Elapsed, info.Err)
})

func RetryIf

func RetryIf(fn RetryIfFunc) Option

RetryIf sets a condition function that determines whether to retry. The function receives the error from the failed attempt and returns true to continue retrying, false to stop immediately.

Example:

retrytimeouts.RetryIf(func(err error) bool {
    // Don't retry on authentication errors
    return !errors.Is(err, ErrAuthFailed)
})

func TimeoutJitterPercent

func TimeoutJitterPercent(pct int) Option

TimeoutJitterPercent adds randomness to timeout values (±percentage). For example, 10 means ±10% jitter on timeout calculations. Set to 0 to disable timeout jitter. Default: 10

func UntilSucceeded

func UntilSucceeded() Option

UntilSucceeded retries indefinitely until the function succeeds. Equivalent to Attempts(0). Requires a context with timeout/cancellation to avoid infinite loops.

type Result

type Result[T any] struct {
	AttemptInfo
	Data T
}

Result contains the outcome of a retry operation, including statistics and optionally returned data. Check Err for success (nil) or failure.

func Do

func Do(ctx context.Context, fn RetryableFunc, opts ...Option) Result[struct{}]

Do executes the retryable function with automatic retry and dynamic timeout calculation. This is the primary entry point for retry operations.

The context (ctx) serves as the "global" context controlling the overall retry budget. It must have a deadline set via context.WithTimeout or context.WithDeadline.

The function (fn) receives an "attempt" context for each try, with a timeout derived from the remaining time budget. This attempt context is automatically cancelled when the timeout expires or when the global context is cancelled.

Returns a Result containing retry statistics and the final error (nil on success).

Example:

ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()

result := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    return doWork(attemptCtx)
})
if result.Err != nil {
    slog.Error("Operation failed",
        "attempts", result.Attempt,
        "elapsed", result.Elapsed,
        "error", result.Err,
    )
}

Example with options:

result := retrytimeouts.Do(ctx,
    func(attemptCtx context.Context) error {
        return doWork(attemptCtx)
    },
    retrytimeouts.Attempts(10),
    retrytimeouts.OnRetry(func(info retrytimeouts.AttemptInfo) {
        log.Printf("Attempt %d/%d: %v", info.Attempt, info.MaxAttempts, info.Err)
    }),
)

func DoWithData

func DoWithData[T any](ctx context.Context, fn RetryableFuncWithData[T], opts ...Option) Result[T]

DoWithData executes the retryable function with retry and returns both data and result info. This is the generic version of Do that preserves the return value.

Example:

ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()

result := retrytimeouts.DoWithData(ctx, func(attemptCtx context.Context) (string, error) {
    return fetchData(attemptCtx)
})
if result.Err != nil {
    return "", result.Err
}
return result.Data, nil

type RetryIfFunc

type RetryIfFunc func(err error) bool

RetryIfFunc determines whether to retry based on the error. Return true to continue retrying, false to stop immediately.

type RetryableFunc

type RetryableFunc func(ctx context.Context) error

RetryableFunc is the function signature for retry operations. The function receives a context with the calculated timeout already applied. Return nil on success, or an error to trigger a retry.

The context deadline dynamically adjusts based on remaining time budget, preventing slow failures from exhausting the entire retry duration.

Example:

err := retrytimeouts.Do(ctx, func(attemptCtx context.Context) error {
    return doWork(attemptCtx)  // Use attemptCtx for the operation
})

type RetryableFuncWithData

type RetryableFuncWithData[T any] func(ctx context.Context) (T, error)

RetryableFuncWithData is the function signature for retry operations that return data. The function receives a context with the calculated timeout already applied.

Directories

Path Synopsis
examples
http_request command
Example demonstrating HTTP requests with dynamic timeout adjustment.
Example demonstrating HTTP requests with dynamic timeout adjustment.
simple command
Simple example demonstrating basic usage of go-retry-timeouts.
Simple example demonstrating basic usage of go-retry-timeouts.

Jump to

Keyboard shortcuts

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