retry: encapsulate & return the last seen error in a RetryExhaustedError

This commit is contained in:
Adrien Delorme 2019-04-24 14:03:36 +02:00
parent e69d95eb37
commit 9f1136db77
3 changed files with 88 additions and 7 deletions

View File

@ -25,7 +25,16 @@ type Config struct {
ShouldRetry func(error) bool ShouldRetry func(error) bool
} }
var RetryExhaustedError error = fmt.Errorf("Function never succeeded in Retry") type RetryExhaustedError struct {
Err error
}
func (err *RetryExhaustedError) Error() string {
if err == nil || err.Err == nil {
return "<nil>"
}
return fmt.Sprintf("retry count exhausted. Last err: %s", err.Err)
}
// Run fn until context is cancelled up until StartTimeout time has passed. // Run fn until context is cancelled up until StartTimeout time has passed.
func (cfg Config) Run(ctx context.Context, fn func(context.Context) error) error { func (cfg Config) Run(ctx context.Context, fn func(context.Context) error) error {
@ -42,10 +51,10 @@ func (cfg Config) Run(ctx context.Context, fn func(context.Context) error) error
startTimeout = time.After(cfg.StartTimeout) startTimeout = time.After(cfg.StartTimeout)
} }
for try := 0; ; try++ {
var err error var err error
for try := 0; ; try++ {
if cfg.Tries != 0 && try == cfg.Tries { if cfg.Tries != 0 && try == cfg.Tries {
return RetryExhaustedError return &RetryExhaustedError{err}
} }
if err = fn(ctx); err == nil { if err = fn(ctx); err == nil {
return nil return nil

View File

@ -5,6 +5,8 @@ import (
"errors" "errors"
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
) )
func success(context.Context) error { return nil } func success(context.Context) error { return nil }
@ -18,12 +20,23 @@ var failErr = errors.New("woops !")
func fail(context.Context) error { return failErr } func fail(context.Context) error { return failErr }
type failOnce bool
func (ran *failOnce) Run(context.Context) error {
if !*ran {
*ran = true
return failErr
}
return nil
}
func TestConfig_Run(t *testing.T) { func TestConfig_Run(t *testing.T) {
cancelledCtx, cancel := context.WithCancel(context.Background()) cancelledCtx, cancel := context.WithCancel(context.Background())
cancel() cancel()
type fields struct { type fields struct {
StartTimeout time.Duration StartTimeout time.Duration
RetryDelay func() time.Duration RetryDelay func() time.Duration
Tries int
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
@ -36,26 +49,37 @@ func TestConfig_Run(t *testing.T) {
wantErr error wantErr error
}{ }{
{"success", {"success",
fields{StartTimeout: time.Second, RetryDelay: nil}, fields{StartTimeout: time.Second},
args{context.Background(), success}, args{context.Background(), success},
nil}, nil},
{"context cancelled", {"context cancelled",
fields{StartTimeout: time.Second, RetryDelay: nil}, fields{StartTimeout: time.Second},
args{cancelledCtx, wait}, args{cancelledCtx, wait},
context.Canceled}, context.Canceled},
{"timeout", {"timeout",
fields{StartTimeout: 20 * time.Millisecond, RetryDelay: func() time.Duration { return 10 * time.Millisecond }}, fields{StartTimeout: 20 * time.Millisecond, RetryDelay: func() time.Duration { return 10 * time.Millisecond }},
args{cancelledCtx, fail}, args{cancelledCtx, fail},
failErr}, failErr},
{"success after one failure",
fields{Tries: 2, RetryDelay: func() time.Duration { return 0 }},
args{context.Background(), new(failOnce).Run},
nil},
{"fail after one failure",
fields{Tries: 1, RetryDelay: func() time.Duration { return 0 }},
args{context.Background(), new(failOnce).Run},
&RetryExhaustedError{failErr},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := Config{ cfg := Config{
StartTimeout: tt.fields.StartTimeout, StartTimeout: tt.fields.StartTimeout,
RetryDelay: tt.fields.RetryDelay, RetryDelay: tt.fields.RetryDelay,
Tries: tt.fields.Tries,
} }
if err := cfg.Run(tt.args.ctx, tt.args.fn); err != tt.wantErr { err := cfg.Run(tt.args.ctx, tt.args.fn)
t.Fatalf("Config.Run() error = %v, wantErr %v", err, tt.wantErr) if diff := cmp.Diff(err, tt.wantErr, DeepAllowUnexported(RetryExhaustedError{}, errors.New(""))); diff != "" {
t.Fatalf("Config.Run() unexpected error: %s", diff)
} }
}) })
} }

View File

@ -0,0 +1,48 @@
package retry
import (
"reflect"
"github.com/google/go-cmp/cmp"
)
func DeepAllowUnexported(vs ...interface{}) cmp.Option {
m := make(map[reflect.Type]struct{})
for _, v := range vs {
structTypes(reflect.ValueOf(v), m)
}
var typs []interface{}
for t := range m {
typs = append(typs, reflect.New(t).Elem().Interface())
}
return cmp.AllowUnexported(typs...)
}
func structTypes(v reflect.Value, m map[reflect.Type]struct{}) {
if !v.IsValid() {
return
}
switch v.Kind() {
case reflect.Ptr:
if !v.IsNil() {
structTypes(v.Elem(), m)
}
case reflect.Interface:
if !v.IsNil() {
structTypes(v.Elem(), m)
}
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
structTypes(v.Index(i), m)
}
case reflect.Map:
for _, k := range v.MapKeys() {
structTypes(v.MapIndex(k), m)
}
case reflect.Struct:
m[v.Type()] = struct{}{}
for i := 0; i < v.NumField(); i++ {
structTypes(v.Field(i), m)
}
}
}