Skip to content

Commit

Permalink
Customized retrier (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
deankarn authored Mar 27, 2024
1 parent ce7aa8a commit 917a81e
Show file tree
Hide file tree
Showing 13 changed files with 1,033 additions and 9 deletions.
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

0 comments on commit 917a81e

Please sign in to comment.