Update go dependencies (#1972)

This commit is contained in:
Dmitry Verkhoturov
2025-12-03 19:47:01 -06:00
committed by GitHub
parent b451142790
commit 564e8ff316
241 changed files with 18667 additions and 2955 deletions
+9 -8
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"github.com/go-pkgz/auth/v2/avatar"
"github.com/go-pkgz/auth/v2/token"
)
@@ -71,15 +72,15 @@ func (p Service) Handler(w http.ResponseWriter, r *http.Request) {
// setAvatar saves avatar and puts proxied URL to u.Picture
func setAvatar(ava AvatarSaver, u token.User, client *http.Client) (token.User, error) {
if ava != nil {
avatarURL, e := ava.Put(u, client)
if e != nil {
return u, fmt.Errorf("failed to save avatar for: %w", e)
}
u.Picture = avatarURL
return u, nil
if ava == nil || ava == (*avatar.Proxy)(nil) {
return u, nil // empty AvatarSaver ok, just skipped
}
return u, nil // empty AvatarSaver ok, just skipped
avatarURL, e := ava.Put(u, client)
if e != nil {
return u, fmt.Errorf("failed to save avatar for: %w", e)
}
u.Picture = avatarURL
return u, nil
}
func randToken() (string, error) {
+35 -39
View File
@@ -1,57 +1,53 @@
version: "2"
run:
timeout: 5m
output:
format: tab
skip-dirs:
- vendor
linters-settings:
govet:
check-shadowing: true
maligned:
suggest-new: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
concurrency: 4
linters:
default: none
enable:
- dupl
- exportloopref
- gas
- gochecknoinits
- gocritic
- gocyclo
- gosimple
- gosec
- govet
- ineffassign
- megacheck
- misspell
- nakedret
- prealloc
- revive
- stylecheck
- typecheck
- staticcheck
- unconvert
- unparam
- unused
fast: false
disable-all: true
settings:
goconst:
min-len: 2
min-occurrences: 2
gocritic:
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
enabled-tags:
- performance
- style
- experimental
govet:
enable-all: true
disable:
- fieldalignment
lll:
line-length: 140
misspell:
locale: US
issues:
exclude-use-default: false
exclusions:
generated: lax
paths:
- vendor
- third_party
- builtin
- examples
+1 -1
View File
@@ -51,7 +51,7 @@ func (a *loginAuth) Start(server *smtp.ServerInfo) (proto string, toServer []byt
return "LOGIN", []byte(a.user), nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) (toServer []byte, err error) {
func (a *loginAuth) Next(_ []byte, more bool) (toServer []byte, err error) {
if more {
return []byte(a.password), nil
}
+25 -12
View File
@@ -13,10 +13,12 @@ import (
"mime/quotedprintable"
"net"
"net/http"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -30,10 +32,10 @@ type Sender struct {
logger Logger
host string // SMTP host
port int // SMTP port
contentType string // Content type, optional. Will trigger MIME and Content-Type headers
contentType string // content type, optional. Will trigger MIME and Content-Type headers
tls bool // TLS auth
starttls bool // StartTLS
insecureSkipVerify bool // Insecure Skip Verify
starttls bool // startTLS
insecureSkipVerify bool // insecure Skip Verify
smtpUserName string // username
smtpPassword string // password
authMethod authMethod // auth method
@@ -44,12 +46,12 @@ type Sender struct {
// Params contains all user-defined parameters to send emails
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
From string // from email field
To []string // from email field
Subject string // email subject
UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
InReplyTo string // Identifier for email group (category), used for email grouping
Attachments []string // Attachments path
InReplyTo string // identifier for email group (category), used for email grouping
Attachments []string // attachments path
InlineImages []string // InlineImages images path
}
@@ -130,12 +132,12 @@ func (em *Sender) Send(text string, params Params) error {
}
}
if err := client.Mail(params.From); err != nil {
if err := client.Mail(extractEmailAddress(params.From)); err != nil {
return fmt.Errorf("bad from address %q: %w", params.From, err)
}
for _, rcpt := range params.To {
if err := client.Rcpt(rcpt); err != nil {
if err := client.Rcpt(extractEmailAddress(rcpt)); err != nil {
return fmt.Errorf("bad to address %q: %w", params.To, err)
}
}
@@ -165,13 +167,24 @@ func (em *Sender) Send(text string, params Params) error {
return nil
}
// extractEmailAddress extracts the email address from a string that may contain a display name.
// For example, it converts `"John Doe" <john@example.com>` to `john@example.com`.
// If parsing fails, it returns the original string unchanged.
func extractEmailAddress(from string) string {
addr, err := mail.ParseAddress(strings.TrimSpace(from))
if err != nil {
return from
}
return addr.Address
}
func (em *Sender) String() string {
return fmt.Sprintf("smtp://%s:%d, auth:%v, tls:%v, starttls:%v, insecureSkipVerify:%v, timeout:%v, content-type:%q, charset:%q",
em.host, em.port, em.smtpUserName != "", em.tls, em.starttls, em.insecureSkipVerify, em.timeOut, em.contentType, em.contentCharset)
}
func (em *Sender) client() (c *smtp.Client, err error) {
srvAddress := fmt.Sprintf("%s:%d", em.host, em.port)
srvAddress := net.JoinHostPort(em.host, strconv.Itoa(em.port))
// #nosec G402
tlsConf := &tls.Config{
InsecureSkipVerify: em.insecureSkipVerify, // #nosec G402
@@ -366,4 +379,4 @@ func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition s
type nopLogger struct{}
func (nopLogger) Logf(format string, args ...interface{}) {}
func (nopLogger) Logf(_ string, _ ...interface{}) {}
+17 -21
View File
@@ -94,13 +94,12 @@ func (c *cacheImpl[K, V]) Set(key K, value V, ttl time.Duration) {
// Returns false if there was no eviction: the item was already in the cache,
// or the size was not exceeded.
func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted bool) {
c.Lock()
defer c.Unlock()
now := time.Now()
if ttl == 0 {
ttl = c.ttl
}
now := time.Now()
c.Lock()
defer c.Unlock()
// Check for existing item
if ent, ok := c.items[key]; ok {
c.evictList.MoveToFront(ent)
@@ -117,7 +116,10 @@ func (c *cacheImpl[K, V]) addWithTTL(key K, value V, ttl time.Duration) (evicted
// Remove the oldest entry if it is expired, only in case of non-default TTL.
if c.ttl != noEvictionTTL || ttl != noEvictionTTL {
c.removeOldestIfExpired()
ent := c.evictList.Back()
if ent != nil && now.After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(ent)
}
}
evict := c.maxKeys > 0 && len(c.items) > c.maxKeys
@@ -197,15 +199,14 @@ func (c *cacheImpl[K, V]) Keys() []K {
// Values returns a slice of the values in the cache, from oldest to newest.
// Expired entries are filtered out.
func (c *cacheImpl[K, V]) Values() []V {
c.Lock()
defer c.Unlock()
values := make([]V, 0, len(c.items))
now := time.Now()
c.Lock()
defer c.Unlock()
for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() {
if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) {
continue
if !now.After(ent.Value.(*cacheItem[K, V]).expiresAt) {
values = append(values, ent.Value.(*cacheItem[K, V]).value)
}
values = append(values, ent.Value.(*cacheItem[K, V]).value)
}
return values
}
@@ -291,11 +292,14 @@ func (c *cacheImpl[K, V]) GetOldest() (key K, value V, ok bool) {
// DeleteExpired clears cache of expired items
func (c *cacheImpl[K, V]) DeleteExpired() {
now := time.Now()
c.Lock()
defer c.Unlock()
for _, key := range c.keys() {
if time.Now().After(c.items[key].Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(c.items[key])
var nextEnt *list.Element
for ent := c.evictList.Back(); ent != nil; ent = nextEnt {
nextEnt = ent.Prev()
if now.After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(ent)
}
}
}
@@ -344,14 +348,6 @@ func (c *cacheImpl[K, V]) removeOldest() {
}
}
// removeOldest removes the oldest item from the cache in case it's already expired. Has to be called with lock!
func (c *cacheImpl[K, V]) removeOldestIfExpired() {
ent := c.evictList.Back()
if ent != nil && time.Now().After(ent.Value.(*cacheItem[K, V]).expiresAt) {
c.removeElement(ent)
}
}
// removeElement is used to remove a given list element from the cache. Has to be called with lock!
func (c *cacheImpl[K, V]) removeElement(e *list.Element) {
c.evictList.Remove(e)
+61 -55
View File
@@ -1,62 +1,68 @@
run:
timeout: 5m
linters-settings:
govet:
check-shadowing: true
goconst:
min-len: 2
min-occurrences: 2
misspell:
locale: US
lll:
line-length: 140
gocritic:
enabled-tags:
- performance
- style
- experimental
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain
version: "2"
linters:
default: none
enable:
- revive
- govet
- unconvert
- staticcheck
- gosec
- unused
- gocyclo
- bodyclose
- copyloopvar
- dupl
- misspell
- unparam
- typecheck
- ineffassign
- stylecheck
- gochecknoinits
- exportloopref
- gocognit
- gocritic
- gosec
- govet
- ineffassign
- misspell
- nakedret
- gosimple
- nolintlint
- prealloc
- whitespace
fast: false
disable-all: true
issues:
exclude-rules:
- text: "at least one file in a package should have a package comment"
linters:
- stylecheck
- path: _test\.go
linters:
- gosec
- dupl
exclude-use-default: false
exclude-dirs:
- vendor
- revive
- staticcheck
- testifylint
- unconvert
- unparam
- unused
settings:
goconst:
min-len: 2
min-occurrences: 2
revive:
enable-all-rules: true
rules:
- name: unused-receiver
disabled: true
- name: line-length-limit
disabled: true
- name: add-constant
disabled: true
- name: cognitive-complexity
disabled: true
- name: function-length
disabled: true
- name: cyclomatic
disabled: true
- name: nested-structs
disabled: true
gocritic:
disabled-checks:
- hugeParam
enabled-tags:
- performance
- style
- experimental
govet:
enable:
- shadow
lll:
line-length: 140
misspell:
locale: US
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+11 -11
View File
@@ -13,17 +13,17 @@ import (
// SMTPParams contain settings for smtp server connection
type SMTPParams struct {
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
StartTLS bool // StartTLS auth
InsecureSkipVerify bool // skip certificate verification
ContentType string // Content type
Charset string // Character set
LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com
Username string // username
Password string // password
TimeOut time.Duration // TCP connection timeout
Host string // SMTP host
Port int // SMTP port
TLS bool // TLS auth
StartTLS bool // StartTLS auth
InsecureSkipVerify bool // skip certificate verification
ContentType string // Content type
Charset string // Character set
LoginAuth bool // LOGIN auth method instead of default PLAIN, needed for Office 365 and outlook.com
Username string // username
Password string // password
TimeOut time.Duration // TCP connection timeout
}
// Email notifications client
+3 -3
View File
@@ -93,9 +93,9 @@ func (s *Slack) findChannelIDByName(name string) (string, error) {
return "", err
}
for _, channel := range channels {
if channel.Name == name {
return channel.ID, nil
for i := range channels {
if channels[i].Name == name {
return channels[i].ID, nil
}
}
+7 -7
View File
@@ -170,23 +170,23 @@ func adjustHTMLTags(htmlText string) string {
switch token.Data {
case "h1", "h2", "h3":
if token.Type == html.StartTagToken {
buff.WriteString("<b>")
_, _ = buff.WriteString("<b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b>")
_, _ = buff.WriteString("</b>")
}
case "h4", "h5", "h6":
if token.Type == html.StartTagToken {
buff.WriteString("<i><b>")
_, _ = buff.WriteString("<i><b>")
}
if token.Type == html.EndTagToken {
buff.WriteString("</b></i>")
_, _ = buff.WriteString("</b></i>")
}
default:
buff.WriteString(token.String())
_, _ = buff.WriteString(token.String())
}
default:
buff.WriteString(token.String())
_, _ = buff.WriteString(token.String())
}
}
}
@@ -435,7 +435,7 @@ func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) {
}
// Request makes a request to the Telegram API and return the result
func (t *Telegram) Request(ctx context.Context, method string, b []byte, data interface{}) error {
func (t *Telegram) Request(ctx context.Context, method string, b []byte, data any) error {
return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error {
url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method)
+12
View File
@@ -0,0 +1,12 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
+84
View File
@@ -0,0 +1,84 @@
version: "2"
run:
concurrency: 4
linters:
default: none
enable:
- contextcheck
- copyloopvar
- decorder
- errorlint
- exptostd
- gochecknoglobals
- gochecknoinits
- gocritic
- gosec
- govet
- ineffassign
- nakedret
- nilerr
- prealloc
- predeclared
- revive
- staticcheck
- testifylint
- thelper
- unconvert
- unparam
- unused
- nestif
- wrapcheck
settings:
goconst:
min-len: 2
min-occurrences: 2
gocritic:
disabled-checks:
- wrapperFunc
enabled-tags:
- performance
- style
- experimental
gocyclo:
min-complexity: 15
govet:
enable-all: true
disable:
- fieldalignment
lll:
line-length: 140
misspell:
locale: US
exclusions:
generated: lax
rules:
- linters:
- gosec
text: 'G114: Use of net/http serve function that has no support for setting timeouts'
- linters:
- revive
- unparam
path: _test\.go$
text: unused-parameter
- linters:
- prealloc
path: _test\.go$
text: Consider pre-allocating
- linters:
- gosec
- intrange
path: _test\.go$
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+259
View File
@@ -0,0 +1,259 @@
# Repeater
[![Build Status](https://github.com/go-pkgz/repeater/workflows/build/badge.svg)](https://github.com/go-pkgz/repeater/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/go-pkgz/repeater)](https://goreportcard.com/report/github.com/go-pkgz/repeater) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/repeater/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/repeater?branch=master)
Package repeater implements a functional mechanism to repeat operations with different retry strategies.
## Install and update
`go get -u github.com/go-pkgz/repeater`
## Usage
### Basic Example with Exponential Backoff
```go
// create repeater with exponential backoff
r := repeater.NewBackoff(5, time.Second) // 5 attempts starting with 1s delay
err := r.Do(ctx, func() error {
// do something that may fail
return nil
})
```
### Fixed Delay with Critical Error
```go
// create repeater with fixed delay
r := repeater.NewFixed(3, 100*time.Millisecond)
criticalErr := errors.New("critical error")
err := r.Do(ctx, func() error {
// do something that may fail
return fmt.Errorf("temp error")
}, criticalErr) // will stop immediately if criticalErr returned
```
### Custom Backoff Strategy
```go
r := repeater.NewBackoff(5, time.Second,
repeater.WithMaxDelay(10*time.Second),
repeater.WithBackoffType(repeater.BackoffLinear),
repeater.WithJitter(0.1),
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := r.Do(ctx, func() error {
// do something that may fail
return nil
})
```
### Stop on Any Error
```go
r := repeater.NewFixed(3, time.Millisecond)
err := r.Do(ctx, func() error {
return errors.New("some error")
}, repeater.ErrAny) // will stop on any error
```
## Strategies
The package provides several retry strategies:
1. **Fixed Delay** - each retry happens after a fixed time interval
2. **Backoff** - delay between retries increases according to the chosen algorithm:
- Constant - same delay between attempts
- Linear - delay increases linearly
- Exponential - delay doubles with each attempt
Backoff strategy can be customized with:
- Maximum delay cap
- Jitter to prevent thundering herd
- Different backoff types (constant/linear/exponential)
### Custom Strategies
You can implement your own retry strategy by implementing the Strategy interface:
```go
type Strategy interface {
// NextDelay returns delay for the next attempt
// attempt starts from 1
NextDelay(attempt int) time.Duration
}
```
Example of a custom strategy that increases delay by a custom factor:
```go
// CustomStrategy implements Strategy with custom factor-based delays
type CustomStrategy struct {
Initial time.Duration
Factor float64
}
func (s CustomStrategy) NextDelay(attempt int) time.Duration {
if attempt <= 0 {
return 0
}
delay := time.Duration(float64(s.Initial) * math.Pow(s.Factor, float64(attempt-1)))
return delay
}
// Usage
strategy := &CustomStrategy{Initial: time.Second, Factor: 1.5}
r := repeater.NewWithStrategy(5, strategy)
err := r.Do(ctx, func() error {
// attempts will be delayed by: 1s, 1.5s, 2.25s, 3.37s, 5.06s
return nil
})
```
## Options
For backoff strategy, several options are available:
```go
WithMaxDelay(time.Duration) // set maximum delay between retries
WithBackoffType(BackoffType) // set backoff type (constant/linear/exponential)
WithJitter(float64) // add randomness to delays (0-1.0)
```
## Error Handling
- Stops on context cancellation
- Can stop on specific errors (pass them as additional parameters to Do)
- Special `ErrAny` to stop on any error
- Returns last error if all attempts fail
- Custom error classification via `SetErrorClassifier`
### Error Classification
You can provide a custom error classifier function to dynamically determine if an error should trigger a retry or stop immediately. This is particularly useful for API clients where different error types require different handling:
```go
// Define what errors are retryable
isRetryable := func(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
// Retryable patterns
if strings.Contains(errStr, "429") ||
strings.Contains(errStr, "rate limit") ||
strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "503") {
return true
}
// Non-retryable patterns
if strings.Contains(errStr, "401") ||
strings.Contains(errStr, "authentication") ||
strings.Contains(errStr, "token limit") {
return false
}
return true // default to retry
}
// Use with any repeater strategy
r := repeater.NewBackoff(5, time.Second)
r.SetErrorClassifier(isRetryable)
err := r.Do(ctx, func() error {
// API call that might fail
return apiClient.Call()
})
```
When an error classifier is set:
- After each error, the classifier function is called
- If it returns `false`, the operation stops immediately
- If it returns `true`, the retry logic continues
- The classifier takes precedence over the critical errors list
This feature works with all repeater strategies (NewFixed, NewBackoff, NewWithStrategy).
## Execution Statistics
The repeater tracks execution statistics that can be accessed after calling `Do()`:
```go
r := repeater.NewFixed(5, 100*time.Millisecond)
err := r.Do(ctx, func() error {
// operation that might fail
return someOperation()
})
// Get execution statistics
stats := r.Stats()
fmt.Printf("Attempts: %d\n", stats.Attempts)
fmt.Printf("Success: %v\n", stats.Success)
fmt.Printf("Total Duration: %v\n", stats.TotalDuration)
fmt.Printf("Work Duration: %v\n", stats.WorkDuration)
fmt.Printf("Delay Duration: %v\n", stats.DelayDuration)
if stats.LastError != nil {
fmt.Printf("Last Error: %v\n", stats.LastError)
}
```
### Available Statistics
The `Stats` struct provides the following information:
- `Attempts` - Number of attempts made (including successful ones)
- `Success` - Whether the operation eventually succeeded
- `TotalDuration` - Total elapsed time from start to finish
- `WorkDuration` - Time spent executing the function (excluding delays)
- `DelayDuration` - Time spent in delays between attempts
- `LastError` - Last error encountered (nil if succeeded)
- `StartedAt` - When the repeater started
- `FinishedAt` - When the repeater finished
### Usage Example
```go
r := repeater.NewBackoff(3, time.Second)
start := time.Now()
err := r.Do(ctx, func() error {
// Simulate work that takes time
time.Sleep(200 * time.Millisecond)
// Randomly fail
if rand.Float32() < 0.7 {
return errors.New("temporary error")
}
return nil
})
stats := r.Stats()
// Log detailed statistics
log.Printf("Operation completed in %v with %d attempts",
stats.TotalDuration, stats.Attempts)
log.Printf("Time spent working: %v", stats.WorkDuration)
log.Printf("Time spent waiting: %v", stats.DelayDuration)
if err != nil {
log.Printf("Failed after %d attempts: %v", stats.Attempts, err)
} else {
log.Printf("Succeeded after %d attempts", stats.Attempts)
}
```
### Thread Safety
Note that the `Repeater` is not thread-safe. Each `Repeater` instance should not be used concurrently for different functions. Create separate `Repeater` instances for concurrent operations.
+167
View File
@@ -0,0 +1,167 @@
// Package repeater implements retry functionality with different strategies.
// It provides fixed delays and various backoff strategies (constant, linear, exponential) with jitter support.
// The package allows custom retry strategies and error-specific handling. Context-aware implementation
// supports cancellation and timeouts.
package repeater
import (
"context"
"errors"
"time"
)
// ErrAny is a special sentinel error that, when passed as a critical error to Do,
// makes it fail on any error from the function
var ErrAny = errors.New("any error")
// ErrorClassifier determines if an error should be retried.
// Returns true if the error should trigger a retry, false to stop immediately.
type ErrorClassifier func(error) bool
// Stats holds execution statistics for a repeater run
type Stats struct {
LastError error // last error encountered (nil if succeeded)
StartedAt time.Time // when the repeater started
FinishedAt time.Time // when the repeater finished
TotalDuration time.Duration // total elapsed time from start to finish
WorkDuration time.Duration // time spent executing the function (excluding delays)
DelayDuration time.Duration // time spent in delays between attempts
Attempts int // number of attempts made (including the successful one)
Success bool // whether the operation eventually succeeded
}
// Repeater holds configuration for retry operations.
// Note: Repeater is not thread-safe. Each Repeater instance should not be used
// concurrently for different functions. Create separate Repeater instances for
// concurrent operations.
type Repeater struct {
strategy Strategy
stats Stats
attempts int
classifier ErrorClassifier
}
// NewWithStrategy creates a repeater with a custom retry strategy
func NewWithStrategy(attempts int, strategy Strategy) *Repeater {
if attempts <= 0 {
attempts = 1
}
if strategy == nil {
strategy = NewFixedDelay(time.Second)
}
return &Repeater{
attempts: attempts,
strategy: strategy,
}
}
// NewBackoff creates a repeater with backoff strategy
// Default settings (can be overridden with options):
// - 30s max delay
// - exponential backoff
// - 10% jitter
func NewBackoff(attempts int, initial time.Duration, opts ...backoffOption) *Repeater {
return NewWithStrategy(attempts, newBackoff(initial, opts...))
}
// NewFixed creates a repeater with fixed delay strategy
func NewFixed(attempts int, delay time.Duration) *Repeater {
return NewWithStrategy(attempts, NewFixedDelay(delay))
}
// Do repeats fun until it succeeds or max attempts reached
// terminates immediately on context cancellation or if err matches any in termErrs.
// if errs contains ErrAny, terminates on any error.
func (r *Repeater) Do(ctx context.Context, fun func() error, termErrs ...error) error {
var lastErr error
// reset and initialize stats
r.stats = Stats{
StartedAt: time.Now(),
}
// finalizeStats updates the stats before returning
finalizeStats := func(attempts int, err error) {
r.stats.Attempts = attempts
r.stats.LastError = err
r.stats.FinishedAt = time.Now()
r.stats.TotalDuration = r.stats.FinishedAt.Sub(r.stats.StartedAt)
}
inErrors := func(err error) bool {
for _, e := range termErrs {
if errors.Is(e, ErrAny) {
return true
}
if errors.Is(err, e) {
return true
}
}
return false
}
for attempt := 0; attempt < r.attempts; attempt++ {
// check context before each attempt
if err := ctx.Err(); err != nil {
finalizeStats(attempt, err)
return err //nolint:wrapcheck // context errors are standard and don't need wrapping
}
workStart := time.Now()
var err error
if err = fun(); err == nil {
r.stats.WorkDuration += time.Since(workStart)
r.stats.Success = true
finalizeStats(attempt+1, nil)
return nil
}
r.stats.WorkDuration += time.Since(workStart)
lastErr = err
// if classifier is set, use it to determine if we should retry
if r.classifier != nil {
if !r.classifier(err) {
finalizeStats(attempt+1, err)
return err
}
} else if inErrors(err) {
// fall back to critical errors list if no classifier
finalizeStats(attempt+1, err)
return err
}
// don't sleep after the last attempt
if attempt < r.attempts-1 {
delay := r.strategy.NextDelay(attempt + 1)
if delay > 0 {
delayStart := time.Now()
select {
case <-ctx.Done():
r.stats.DelayDuration += time.Since(delayStart)
finalizeStats(attempt+1, ctx.Err())
return ctx.Err() //nolint:wrapcheck // context errors are standard and don't need wrapping
case <-time.After(delay):
r.stats.DelayDuration += time.Since(delayStart)
}
}
}
}
finalizeStats(r.attempts, lastErr)
return lastErr
}
// SetErrorClassifier sets a function to determine if errors are retryable.
// This can be used with any repeater strategy (NewFixed, NewBackoff, NewWithStrategy).
// When set, the classifier takes precedence over the critical errors list.
// Returns true to retry, false to stop immediately.
func (r *Repeater) SetErrorClassifier(classifier ErrorClassifier) {
r.classifier = classifier
}
// Stats returns the execution statistics from the last Do() call
func (r *Repeater) Stats() Stats {
return r.stats
}
+113
View File
@@ -0,0 +1,113 @@
package repeater
import (
"math/rand"
"time"
)
// Strategy defines how to calculate delays between retries
type Strategy interface {
// NextDelay returns delay for the next attempt, attempt starts from 1
NextDelay(attempt int) time.Duration
}
// FixedDelay implements fixed time delay between attempts
type FixedDelay struct {
Delay time.Duration
}
// NewFixedDelay creates a new FixedDelay strategy
func NewFixedDelay(delay time.Duration) FixedDelay {
return FixedDelay{Delay: delay}
}
// NextDelay returns fixed delay
func (s FixedDelay) NextDelay(_ int) time.Duration {
return s.Delay
}
// BackoffType represents the backoff strategy type
type BackoffType int
const (
// BackoffConstant keeps delays the same between attempts
BackoffConstant BackoffType = iota
// BackoffLinear increases delays linearly between attempts
BackoffLinear
// BackoffExponential increases delays exponentially between attempts
BackoffExponential
)
// backoff implements various backoff strategies with optional jitter
type backoff struct {
initial time.Duration
maxDelay time.Duration
btype BackoffType
jitter float64
}
type backoffOption func(*backoff)
// WithMaxDelay sets maximum delay for the backoff strategy
func WithMaxDelay(d time.Duration) backoffOption { //nolint:revive // unexported type is used in the same package
return func(b *backoff) {
b.maxDelay = d
}
}
// WithBackoffType sets backoff type for the strategy
func WithBackoffType(t BackoffType) backoffOption { //nolint:revive // unexported type is used in the same package
return func(b *backoff) {
b.btype = t
}
}
// WithJitter sets jitter factor for the backoff strategy
func WithJitter(factor float64) backoffOption { //nolint:revive // unexported type is used in the same package
return func(b *backoff) {
b.jitter = factor
}
}
func newBackoff(initial time.Duration, opts ...backoffOption) *backoff {
b := &backoff{
initial: initial,
maxDelay: 30 * time.Second,
btype: BackoffExponential,
jitter: 0.1,
}
for _, opt := range opts {
opt(b)
}
return b
}
// NextDelay returns delay for the next attempt
func (s backoff) NextDelay(attempt int) time.Duration {
if attempt <= 0 {
return 0
}
var delay time.Duration
switch s.btype {
case BackoffConstant:
delay = s.initial
case BackoffLinear:
delay = s.initial * time.Duration(attempt)
case BackoffExponential:
delay = s.initial * time.Duration(1<<(attempt-1))
}
if s.maxDelay > 0 && delay > s.maxDelay {
delay = s.maxDelay
}
if s.jitter > 0 {
jitter := float64(delay) * s.jitter
delay = time.Duration(float64(delay) + (rand.Float64()*jitter - jitter/2)) //nolint:gosec // no need for secure random here
}
return delay
}