Skip to content

Commit

Permalink
Promote the use of Timeout() over Temporary()
Browse files Browse the repository at this point in the history
  • Loading branch information
justinrixx committed Sep 22, 2023
1 parent 2f8a88f commit 284cd52
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 9 deletions.
1 change: 1 addition & 0 deletions context_post17.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//go:build go1.7
// +build go1.7

package rehttp
Expand Down
1 change: 1 addition & 0 deletions context_pre17.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//go:build !go1.7
// +build !go1.7

package rehttp
Expand Down
31 changes: 22 additions & 9 deletions rehttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
//
// The package offers common delay strategies as ready-made functions that
// return a DelayFn:
// - ConstDelay(delay time.Duration) DelayFn
// - ExpJitterDelay(base, max time.Duration) DelayFn
// - ConstDelay(delay time.Duration) DelayFn
// - ExpJitterDelay(base, max time.Duration) DelayFn
//
// It also provides common retry helpers that return a RetryFn:
// - RetryIsErr(func(error) bool) RetryFn
// - RetryHTTPMethods(methods ...string) RetryFn
// - RetryMaxRetries(max int) RetryFn
// - RetryStatuses(statuses ...int) RetryFn
// - RetryStatusInterval(fromStatus, toStatus int) RetryFn
// - RetryTemporaryErr() RetryFn
// - RetryIsErr(func(error) bool) RetryFn
// - RetryHTTPMethods(methods ...string) RetryFn
// - RetryMaxRetries(max int) RetryFn
// - RetryStatuses(statuses ...int) RetryFn
// - RetryStatusInterval(fromStatus, toStatus int) RetryFn
// - RetryTimeoutErr() RetryFn
// - RetryTemporaryErr() RetryFn
//
// Those can be combined with RetryAny or RetryAll as needed. RetryAny
// enables retries if any of the RetryFn return true, while RetryAll
Expand Down Expand Up @@ -173,6 +174,8 @@ func RetryIsErr(fn func(error) bool) RetryFn {
// is a temporary error. A temporary error is one that implements
// the Temporary() bool method. Most errors from the net package implement
// this.
// This interface was deprecated in go 1.18. Favor RetryTimeoutErr.
// https://github.com/golang/go/issues/45729
func RetryTemporaryErr() RetryFn {
return RetryIsErr(func(err error) bool {
if terr, ok := err.(temporaryer); ok {
Expand All @@ -182,6 +185,16 @@ func RetryTemporaryErr() RetryFn {
})
}

// RetryTimeoutErr returns a RetryFn that retries if the Attempt's error
// is a timeout error. Before go 1.13, a timeout error is one that implements
// the Timeout() bool method. Most errors from the net package implement this.
// After go 1.13, a timeout error is one that implemts the net.Error interface
// which includes both Timeout() and Temporary() to make it less likely to
// falsely identify errors that occurred outside of the net package.
func RetryTimeoutErr() RetryFn {
return RetryIsErr(isTimeoutErr)
}

// RetryStatusInterval returns a RetryFn that retries if the response's
// status code is in the provided half-closed interval [fromStatus, toStatus)
// (that is, it retries if fromStatus <= Response.StatusCode < toStatus, so
Expand Down Expand Up @@ -213,7 +226,7 @@ func RetryStatuses(statuses ...int) RetryFn {

// RetryHTTPMethods returns a RetryFn that retries if the request's
// HTTP method is one of the provided methods. It is meant to be used
// in conjunction with another RetryFn such as RetryTemporaryErr combined
// in conjunction with another RetryFn such as RetryTimeoutErr combined
// using RetryAll, otherwise this function will retry any successful
// request made with one of the provided methods.
func RetryHTTPMethods(methods ...string) RetryFn {
Expand Down
25 changes: 25 additions & 0 deletions rehttp_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rehttp
import (
"bytes"
"io"
"net"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -135,3 +136,27 @@ func TestMockClientRetryWithBody(t *testing.T) {
assert.Equal(t, 2, mock.Calls())
assert.Equal(t, []string{"hello", "hello"}, mock.Bodies())
}

func TestMockClientRetryTimeout(t *testing.T) {
retFn := func(att int, req *http.Request) (*http.Response, error) {
return nil, &net.OpError{
Err: timeoutErr{},
}
}
mock := &mockRoundTripper{t: t, retFn: retFn}

tr := NewTransport(mock, RetryAll(RetryMaxRetries(1), RetryTimeoutErr()), ConstDelay(0))

client := &http.Client{
Transport: tr,
}
_, err := client.Get("http://example.com")
if assert.NotNil(t, err) {
uerr, ok := err.(*url.Error)
require.True(t, ok)
assert.Equal(t, &net.OpError{
Err: timeoutErr{},
}, uerr.Err)
}
assert.Equal(t, 2, mock.Calls())
}
31 changes: 31 additions & 0 deletions rehttp_retryfn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package rehttp

import (
"io"
"net"
"net/http"
"testing"
"time"
Expand Down Expand Up @@ -98,6 +99,15 @@ type tempErr struct{}
func (t tempErr) Error() string { return "temp error" }
func (t tempErr) Temporary() bool { return true }

type timeoutErr struct{}

func (t timeoutErr) Error() string { return "temp error" }
func (t timeoutErr) Timeout() bool { return true }

var timeoutNetErr = net.OpError{
Err: timeoutErr{},
}

func TestRetryTemporaryErr(t *testing.T) {
cases := []struct {
retries int
Expand All @@ -119,6 +129,27 @@ func TestRetryTemporaryErr(t *testing.T) {
}
}

func TestRetryTimeoutErr(t *testing.T) {
cases := []struct {
retries int
err error
att int
want bool
}{
{retries: 1, err: nil, att: 0, want: false},
{retries: 1, err: nil, att: 1, want: false},
{retries: 1, err: io.EOF, att: 0, want: false},
{retries: 1, err: &timeoutNetErr, att: 0, want: true},
{retries: 1, err: &timeoutNetErr, att: 1, want: false},
}

for i, tc := range cases {
fn := RetryAll(RetryMaxRetries(tc.retries), RetryTimeoutErr())
got := fn(Attempt{Index: tc.att, Error: tc.err})
assert.Equal(t, tc.want, got, "%d", i)
}
}

func TestRetryAll(t *testing.T) {
max := RetryMaxRetries(2)
status := RetryStatusInterval(500, 600)
Expand Down
18 changes: 18 additions & 0 deletions timeouterr_post113.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build go1.13
// +build go1.13

package rehttp

import (
"errors"
"net"
)

func isTimeoutErr(err error) bool {
var neterr net.Error
if errors.As(err, &neterr) && neterr.Timeout() {
return true
}

return false
}
15 changes: 15 additions & 0 deletions timeouterr_pre113.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !go1.13
// +build !go1.13

package rehttp

type timeouter interface {
Timeout() bool
}

func isTimeoutErr(err error) bool {
if terr, ok := err.(timeouter); ok {
return terr.Timeout()
}
return false
}
42 changes: 42 additions & 0 deletions timeouterr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package rehttp

import (
"errors"
"net"
"testing"

"golang.org/x/net/context"
)

func Test_isTimeoutErr(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "context timeouts should be retryable",
err: context.DeadlineExceeded,
want: true,
},
{
name: "net op errors are true",
err: &net.OpError{
Err: timeoutErr{},
},
want: true,
},
{
name: "non-network related errors should not be retryable",
err: errors.New("fake error"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTimeoutErr(tt.err); got != tt.want {
t.Errorf("isTimeoutErr() = %v, want %v", got, tt.want)
}
})
}
}

0 comments on commit 284cd52

Please sign in to comment.