Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customized retrier #50

Merged
merged 36 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
dde35e5
initial retryer attempt
deankarn Mar 16, 2024
db223df
explicit
deankarn Mar 16, 2024
ab7615a
clarity rename
deankarn Mar 16, 2024
856310c
clarity rename 2
deankarn Mar 16, 2024
206687a
Add retry tests + refactor
deankarn Mar 18, 2024
bf6265b
support interface Retryable and IsRetryable
deankarn Mar 18, 2024
6fddc25
expose IsRetryable function
deankarn Mar 18, 2024
a9d26ba
Pass Context to retry fn
deankarn Mar 18, 2024
64557ba
Add default backoff
deankarn Mar 18, 2024
01a216e
Add Timeout option
deankarn Mar 18, 2024
406a64f
Add docs
deankarn Mar 18, 2024
4c6faca
initial HTTP retryer
deankarn Mar 24, 2024
032f82c
Update to pass context to all functions
deankarn Mar 24, 2024
458681f
non generic retrier more useful
deankarn Mar 24, 2024
82d63e2
update decode fn name
deankarn Mar 24, 2024
45dbe10
adding tests
deankarn Mar 24, 2024
be555eb
handle nil fn's
deankarn Mar 24, 2024
e904c70
Add non retryable early opt-out support
deankarn Mar 24, 2024
26966d7
Enhance Backoof fn + Add asciiext
deankarn Mar 25, 2024
7908a72
Add deprecation warnings and documentation
deankarn Mar 25, 2024
f59ef28
Add missed new addition
deankarn Mar 25, 2024
2351be7
Add build directives
deankarn Mar 25, 2024
adf96d9
support only GO version
deankarn Mar 25, 2024
6e8b778
Add HasRetryAfter helper
deankarn Mar 25, 2024
1d39a3c
update changelog
deankarn Mar 25, 2024
2a85746
Add more tests
deankarn Mar 25, 2024
02faf3a
one last check
deankarn Mar 25, 2024
533fa00
change ErrStatusCode
deankarn Mar 25, 2024
d296ddd
remove unused
deankarn Mar 25, 2024
e6f2e43
ensure to drain
deankarn Mar 25, 2024
ae65512
change maxMemory to maxxBytes for clarity
deankarn Mar 25, 2024
6f72ee5
remove TODO
deankarn Mar 25, 2024
9e5a1b6
Add examples
deankarn Mar 25, 2024
07683f0
update example with fallible logic
deankarn Mar 25, 2024
fde39d3
fix MaxAttemptsNonRetryableReset
deankarn Mar 27, 2024
c1a0c20
comments
deankarn Mar 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.29.0] - 2024-03-24
### Added
- `asciiext` package for ASCII related functions.
- `errorsext.Retrier` configurable retry helper for any fallible operation.
- `httpext.Retrier` configurable retry helper for HTTP requests and parsing of responses.
- `httpext.DecodeResponseAny` non-generic helper for decoding HTTP responses.
- `httpext.HasRetryAfter` helper for checking if a response has a `Retry-After` header and returning duration to wait.

## [5.28.1] - 2024-02-14
### Fixed
- Additional supported types, cast to `sql.Valuer` supported types, they need to be returned to the driver for evaluation.
Expand Down Expand Up @@ -120,7 +128,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `timext.NanoTime` for fast low level monotonic time with nanosecond precision.

[Unreleased]: https://github.com/go-playground/pkg/compare/v5.28.1...HEAD
[Unreleased]: https://github.com/go-playground/pkg/compare/v5.29.0...HEAD
[5.29.0]: https://github.com/go-playground/pkg/compare/v5.28.1..v5.29.0
[5.28.1]: https://github.com/go-playground/pkg/compare/v5.28.0..v5.28.1
[5.28.0]: https://github.com/go-playground/pkg/compare/v5.27.0..v5.28.0
[5.27.0]: https://github.com/go-playground/pkg/compare/v5.26.0..v5.27.0
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pkg

![Project status](https://img.shields.io/badge/version-5.28.0-green.svg)
![Project status](https://img.shields.io/badge/version-5.29.0-green.svg)
[![Lint & Test](https://github.com/go-playground/pkg/actions/workflows/go.yml/badge.svg)](https://github.com/go-playground/pkg/actions/workflows/go.yml)
[![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master)
[![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://pkg.go.dev/mod/github.com/go-playground/pkg/v5)
Expand All @@ -23,7 +23,7 @@ This is a place to put common reusable code that is not quite a library but exte
- Generic Mutex and RWMutex.
- Bytes helper placeholders units eg. MB, MiB, GB, ...
- Detachable context.
- Error retryable helper functions.
- Retrier for helping with any fallible operation.
- Proper RFC3339Nano definition.
- unsafe []byte->string & string->[]byte helper functions.
- HTTP helper functions and constant placeholders.
Expand Down
59 changes: 59 additions & 0 deletions _examples/net/http/retrier/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"time"

appext "github.com/go-playground/pkg/v5/app"
errorsext "github.com/go-playground/pkg/v5/errors"
httpext "github.com/go-playground/pkg/v5/net/http"
. "github.com/go-playground/pkg/v5/values/result"
)

// customize as desired to meet your needs including custom retryable status codes, errors etc.
var retrier = httpext.NewRetryer()

func main() {
ctx := appext.Context().Build()

type Test struct {
Date time.Time
}
var count int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if count < 2 {
count++
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
return
}
_ = httpext.JSON(w, http.StatusOK, Test{Date: time.Now().UTC()})
}))
defer server.Close()

// fetch response
fn := func(ctx context.Context) Result[*http.Request, error] {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
return Err[*http.Request, error](err)
}
return Ok[*http.Request, error](req)
}

var result Test
err := retrier.Do(ctx, fn, &result, http.StatusOK)
if err != nil {
panic(err)
}
fmt.Printf("Response: %+v\n", result)

// `Retrier` configuration is copy and so the base `Retrier` can be used and even customized for one-off requests.
// eg for this request we change the max attempts from the default configuration.
err = retrier.MaxAttempts(errorsext.MaxAttempts, 2).Do(ctx, fn, &result, http.StatusOK)
if err != nil {
panic(err)
}
fmt.Printf("Response: %+v\n", result)
}
21 changes: 21 additions & 0 deletions ascii/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package asciiext

// IsAlphanumeric returns true if the byte is an ASCII letter or digit.
func IsAlphanumeric(c byte) bool {
return IsLower(c) || IsUpper(c) || IsDigit(c)
}

// IsUpper returns true if the byte is an ASCII uppercase letter.
func IsUpper(c byte) bool {
return c >= 'A' && c <= 'Z'
}

// IsLower returns true if the byte is an ASCII lowercase letter.
func IsLower(c byte) bool {
return c >= 'a' && c <= 'z'
}

// IsDigit returns true if the byte is an ASCII digit.
func IsDigit(c byte) bool {
return c >= '0' && c <= '9'
}
3 changes: 3 additions & 0 deletions errors/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package errorsext

import (
"context"

optionext "github.com/go-playground/pkg/v5/values/option"
resultext "github.com/go-playground/pkg/v5/values/result"
)
Expand All @@ -21,6 +22,8 @@ type IsRetryableFn[E any] func(err E) (reason string, isRetryable bool)
type OnRetryFn[E any] func(ctx context.Context, originalErr E, reason string, attempt int) optionext.Option[E]

// DoRetryable will execute the provided functions code and automatically retry using the provided retry function.
//
// Deprecated: use `errorsext.Retrier` instead which corrects design issues with the current implementation.
func DoRetryable[T, E any](ctx context.Context, isRetryFn IsRetryableFn[E], onRetryFn OnRetryFn[E], fn RetryableFn[T, E]) resultext.Result[T, E] {
var attempt int
for {
Expand Down
179 changes: 179 additions & 0 deletions errors/retrier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//go:build go1.18
// +build go1.18

package errorsext

import (
"context"
"time"

. "github.com/go-playground/pkg/v5/values/result"
)

// MaxAttemptsMode is used to set the mode for the maximum number of attempts.
//
// eg. Should the max attempts apply to all errors, just ones not determined to be retryable, reset on retryable errors, etc.
type MaxAttemptsMode uint8

const (
// MaxAttemptsNonRetryableReset will apply the max attempts to all errors not determined to be retryable, but will
// reset the attempts if a retryable error is encountered after a non-retryable error.
MaxAttemptsNonRetryableReset MaxAttemptsMode = iota

// MaxAttemptsNonRetryable will apply the max attempts to all errors not determined to be retryable.
MaxAttemptsNonRetryable

// MaxAttempts will apply the max attempts to all errors, even those determined to be retryable.
MaxAttempts

// MaxAttemptsUnlimited will not apply a maximum number of attempts.
MaxAttemptsUnlimited
)

// BackoffFn is a function used to apply a backoff strategy to the retryable function.
//
// It accepts `E` in cases where the amount of time to backoff is dynamic, for example when and http request fails
// with a 429 status code, the `Retry-After` header can be used to determine how long to backoff. It is not required
// to use or handle `E` and can be ignored if desired.
type BackoffFn[E any] func(ctx context.Context, attempt int, e E)

// IsRetryableFn2 is called to determine if the type E is retryable.
type IsRetryableFn2[E any] func(ctx context.Context, e E) (isRetryable bool)

// EarlyReturnFn is the function that can be used to bypass all retry logic, no matter the MaxAttemptsMode, for when the
// type of `E` will never succeed and should not be retried.
//
// eg. If retrying an HTTP request and getting 400 Bad Request, it's unlikely to ever succeed and should not be retried.
type EarlyReturnFn[E any] func(ctx context.Context, e E) (earlyReturn bool)

// Retryer is used to retry any fallible operation.
type Retryer[T, E any] struct {
isRetryableFn IsRetryableFn2[E]
isEarlyReturnFn EarlyReturnFn[E]
maxAttemptsMode MaxAttemptsMode
maxAttempts uint8
bo BackoffFn[E]
timeout time.Duration
}

// NewRetryer returns a new `Retryer` with sane default values.
//
// The default values are:
// - `MaxAttemptsMode` is `MaxAttemptsNonRetryableReset`.
// - `MaxAttempts` is 5.
// - `Timeout` is 0 no context timeout.
// - `IsRetryableFn` will always return false as `E` is unknown until defined.
// - `BackoffFn` will sleep for 200ms. It's recommended to use exponential backoff for production.
// - `EarlyReturnFn` will be None.
func NewRetryer[T, E any]() Retryer[T, E] {
return Retryer[T, E]{
isRetryableFn: func(_ context.Context, _ E) bool { return false },
maxAttemptsMode: MaxAttemptsNonRetryableReset,
maxAttempts: 5,
bo: func(ctx context.Context, attempt int, _ E) {
t := time.NewTimer(time.Millisecond * 200)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
},
}
}

// IsRetryableFn sets the `IsRetryableFn` for the `Retryer`.
func (r Retryer[T, E]) IsRetryableFn(fn IsRetryableFn2[E]) Retryer[T, E] {
if fn == nil {
fn = func(_ context.Context, _ E) bool { return false }
}
r.isRetryableFn = fn
return r
}

// IsEarlyReturnFn sets the `EarlyReturnFn` for the `Retryer`.
//
// NOTE: If the `EarlyReturnFn` and `IsRetryableFn` are both set and a conflicting `IsRetryableFn` will take precedence.
func (r Retryer[T, E]) IsEarlyReturnFn(fn EarlyReturnFn[E]) Retryer[T, E] {
r.isEarlyReturnFn = fn
return r
}

// MaxAttempts sets the maximum number of attempts for the `Retryer`.
//
// NOTE: Max attempts is optional and if not set will retry indefinitely on retryable errors.
func (r Retryer[T, E]) MaxAttempts(mode MaxAttemptsMode, maxAttempts uint8) Retryer[T, E] {
r.maxAttemptsMode, r.maxAttempts = mode, maxAttempts
return r
}

// Backoff sets the backoff function for the `Retryer`.
func (r Retryer[T, E]) Backoff(fn BackoffFn[E]) Retryer[T, E] {
if fn == nil {
fn = func(_ context.Context, _ int, _ E) {}
}
r.bo = fn
return r
}

// Timeout sets the timeout for the `Retryer`. This is the timeout per `RetyableFn` attempt and not the entirety
// of the `Retryer` execution.
//
// A timeout of 0 will disable the timeout and is the default.
func (r Retryer[T, E]) Timeout(timeout time.Duration) Retryer[T, E] {
r.timeout = timeout
return r
}

// Do will execute the provided functions code and automatically retry using the provided retry function.
func (r Retryer[T, E]) Do(ctx context.Context, fn RetryableFn[T, E]) Result[T, E] {
var attempt int
remaining := r.maxAttempts
for {
var result Result[T, E]
if r.timeout == 0 {
result = fn(ctx)
} else {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
result = fn(ctx)
cancel()
}
if result.IsErr() {
err := result.Err()
isRetryable := r.isRetryableFn(ctx, err)
if !isRetryable && r.isEarlyReturnFn != nil && r.isEarlyReturnFn(ctx, err) {
return result
}

switch r.maxAttemptsMode {
case MaxAttemptsUnlimited:
goto RETRY
case MaxAttemptsNonRetryableReset:
if isRetryable {
remaining = r.maxAttempts
goto RETRY
} else if remaining > 0 {
remaining--
}
case MaxAttemptsNonRetryable:
if isRetryable {
goto RETRY
} else if remaining > 0 {
remaining--
}
case MaxAttempts:
if remaining > 0 {
remaining--
}
}
if remaining == 0 {
return result
}

RETRY:
r.bo(ctx, attempt, err)
attempt++
continue
}
return result
}
}
Loading
Loading