-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
1,033 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.