Skip to content

Commit

Permalink
Create client level retry functionality (#9)
Browse files Browse the repository at this point in the history
Co-authored-by: Dean Oren <[email protected]>
Co-authored-by: Johannes Riecken <[email protected]>
  • Loading branch information
3 people authored Oct 4, 2022
1 parent e6f186b commit 63c57dc
Show file tree
Hide file tree
Showing 13 changed files with 629 additions and 1 deletion.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ TEST?=$$(go list ./... | grep -v 'vendor')
test:
@go test $(TEST) || exit 1
@echo $(TEST) | xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4

quality:
@goreportcard-cli -v ./...
27 changes: 26 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/SchwarzIT/community-stackit-go-client/pkg/api/v2/membership"
resourceManager "github.com/SchwarzIT/community-stackit-go-client/pkg/api/v2/resource-manager"
"github.com/SchwarzIT/community-stackit-go-client/pkg/consts"
"github.com/SchwarzIT/community-stackit-go-client/pkg/retry"
"github.com/SchwarzIT/community-stackit-go-client/pkg/validate"
"golang.org/x/oauth2"
)
Expand All @@ -33,6 +34,7 @@ import (
type Client struct {
client *http.Client
config *Config
retry *retry.Retry

// Productive services - services that are ready to be used in production
ProductiveServices
Expand All @@ -52,6 +54,14 @@ func New(ctx context.Context, cfg *Config) (*Client, error) {
return c.init(ctx), nil
}

// WithRetry sets retry.Retry in a shallow copy of the given client
// and returns the new copy
func (c *Client) WithRetry(r *retry.Retry) *Client {
nc := *c
nc.retry = r
return &nc
}

// Service management

// ProductiveServices is the struct representing all productive services
Expand Down Expand Up @@ -137,8 +147,23 @@ func (c *Client) Request(ctx context.Context, method, path string, body []byte)
return req, nil
}

// Do performs the request and decodes the response if given interface != nil
// Do performs the request, including retry if set
// To set retry, use WithRetry() which returns a shalow copy of the client
func (c *Client) Do(req *http.Request, v interface{}, errorHandlers ...func(*http.Response) error) (*http.Response, error) {
if c.retry == nil {
return c.do(req, v, errorHandlers...)
}
return c.doWithRetry(req, v, errorHandlers...)
}

func (c *Client) doWithRetry(req *http.Request, v interface{}, errorHandlers ...func(*http.Response) error) (*http.Response, error) {
return c.retry.Do(req, func(r *http.Request) (*http.Response, error) {
return c.do(r, v, errorHandlers...)
})
}

// Do performs the request and decodes the response if given interface != nil
func (c *Client) do(req *http.Request, v interface{}, errorHandlers ...func(*http.Response) error) (*http.Response, error) {
resp, err := c.client.Do(req)
if err != nil {
return nil, err
Expand Down
19 changes: 19 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"

"github.com/SchwarzIT/community-stackit-go-client/pkg/consts"
"github.com/SchwarzIT/community-stackit-go-client/pkg/retry"
)

func TestNew(t *testing.T) {
Expand Down Expand Up @@ -274,3 +275,21 @@ func TestClient_SetToken(t *testing.T) {
})
}
}

func TestClient_DoWithRetryNonRetryableError(t *testing.T) {
c, mux, teardown, err := MockServer()
defer teardown()
if err != nil {
t.Errorf("error from mock.AuthServer: %s", err.Error())
}

mux.HandleFunc("/err", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
})

req, _ := c.Request(context.Background(), http.MethodGet, "/err", nil)
if _, err := c.WithRetry(retry.New()).Do(req, nil); err == nil {
t.Error("expected do request to return error but got nil instead")
}
}
54 changes: 54 additions & 0 deletions pkg/retry/is_retryable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package retry

import (
"net/http"
"strings"
)

const (
// timeout configuration constants
CONFIG_IS_RETRYABLE = "IsRetryable"

// requesrt errors
ERR_NO_SUCH_HOST = "dial tcp: lookup"
)

// IsRetryable is the config struct
type IsRetryable struct {
fnList []func(err error) bool
}

// SetIsRetryable sets functions that determin if an error can be retried or not
func (c *Retry) SetIsRetryable(f ...func(err error) bool) *Retry {
return c.withConfig(&IsRetryable{
fnList: f,
})
}

var _ = Config(&IsRetryable{})

func (c *IsRetryable) String() string {
return CONFIG_IS_RETRYABLE
}

func (c *IsRetryable) Value() interface{} {
return c.fnList
}

// IsRetryableNoOp always retries
func IsRetryableNoOp(err error) bool {
return true
}

// IsRetryableDefault
func IsRetryableDefault(err error) bool {
if strings.Contains(err.Error(), http.StatusText(http.StatusBadRequest)) {
return strings.Contains(err.Error(), ERR_NO_SUCH_HOST)
}

if strings.Contains(err.Error(), http.StatusText(http.StatusUnauthorized)) {
return false
}

return true
}
88 changes: 88 additions & 0 deletions pkg/retry/is_retryable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package retry

import (
"errors"
"net/http"
"reflect"
"runtime"
"testing"
)

func GetFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}

func TestRetry_SetIsRetryable(t *testing.T) {
r := New()
r.IsRetryableFns = []func(err error) bool{IsRetryableNoOp}

type args struct {
f []func(err error) bool
}
tests := []struct {
name string
args args
want *Retry
}{
{"ok", args{[]func(err error) bool{IsRetryableNoOp}}, r},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := New()
got := c.SetIsRetryable(tt.args.f...)
if len(got.IsRetryableFns) != len(r.IsRetryableFns) {
t.Error("wrong lengths")
return
}
for k, v := range got.IsRetryableFns {
if GetFunctionName(r.IsRetryableFns[k]) != GetFunctionName(v) {
t.Errorf("%s != %s", GetFunctionName(r.IsRetryableFns[k]), GetFunctionName(v))
return
}
}
})
}
}

func TestIsRetryableNoOp(t *testing.T) {
type args struct {
err error
}
tests := []struct {
name string
args args
want bool
}{
{"always true", args{nil}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsRetryableNoOp(tt.args.err); got != tt.want {
t.Errorf("IsRetryableNoOp() = %v, want %v", got, tt.want)
}
})
}
}

func TestIsRetryableDefault(t *testing.T) {
type args struct {
err error
}
tests := []struct {
name string
args args
want bool
}{
{"bad request - no retry", args{errors.New(http.StatusText(http.StatusBadRequest))}, false},
{"bad request - no host - retry", args{errors.New(http.StatusText(http.StatusBadRequest) + ERR_NO_SUCH_HOST)}, true},
{"bad request - unauthorized", args{errors.New(http.StatusText(http.StatusUnauthorized))}, false},
{"other error", args{errors.New(http.StatusText(http.StatusConflict))}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsRetryableDefault(tt.args.err); got != tt.want {
t.Errorf("IsRetryableDefault() = %v, want %v", got, tt.want)
}
})
}
}
30 changes: 30 additions & 0 deletions pkg/retry/max_retries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package retry

const (
// name of the configuration
CONFIG_MAX_RETRIES = "MaxRetries"
)

// MaxRetries is the config struct
type MaxRetries struct {
Retries *int
}

// SetMaxRetries sets the maximum retries
func (c *Retry) SetMaxRetries(r int) *Retry {
return c.withConfig(&MaxRetries{
Retries: &r,
})
}

var _ = Config(&Timeout{})

// String returns the config name
func (c *MaxRetries) String() string {
return CONFIG_MAX_RETRIES
}

// Value return the value
func (c *MaxRetries) Value() interface{} {
return c.Retries
}
30 changes: 30 additions & 0 deletions pkg/retry/max_retries_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package retry

import (
"reflect"
"testing"
)

func TestRetry_SetMaxRetries(t *testing.T) {
r := New()
five := 5
r.MaxRetries = &five
type args struct {
r int
}
tests := []struct {
name string
args args
want *Retry
}{
{"ok", args{5}, r},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := New()
if got := c.SetMaxRetries(tt.args.r); !reflect.DeepEqual(*got.MaxRetries, *tt.want.MaxRetries) {
t.Errorf("Retry.SetMaxRetries() = %v, want %v", *got.MaxRetries, *tt.want.MaxRetries)
}
})
}
}
Loading

0 comments on commit 63c57dc

Please sign in to comment.