Repository: gavv/httpexpect
Pull request #179 (2022-11-30 00:46:48)

Add unit test of retry support
Issue: https://github.com/gavv/httpexpect/issues/91

neverbeenthisweeb: TODO:

- Add unit test of `WithMaxRetries`
- Add unit test of `WithRetryDelay`
gavv: Thanks for PR. The approach looks good.
gavv: FYI: I've created [discord chat](https://discord.gg/5SCPCuCWA9) for httpexpect. It can be used when we need more interactive discussion.
neverbeenthisweeb: ok got it @gavv 

this PR is ready now
gavv: Thanks, tests look good.

Can we make a few small improvements?

1. For each policy, it'd be nice to cover the case when there is no network and http error.

2. It makes sense to cover POST requests with body, and check that every time request is passed to client, it contains expected body. Because httpexpect has special logic to achieve this, and ideally we should explicitly test it.

    We could cover them separately or just change all requests to POST, up to you.

4. I have an idea how we can better test retry delays. We can add a new unexported field to httpexpect.Request:

    ```
    sleepFn func(d Duration)
    ```

    and modify newRequest to set this field to time.Sleep. Request.retryRequest() should use this function instead of time.Sleep. And in retry delay tests we can override this function with our mock, which will record calls made to it.

    (BTW, we have an issue related to this sleep call: https://github.com/gavv/httpexpect/issues/163; after we fix it, we'll have to update this mock as well, but that is not a problem I think)
neverbeenthisweeb: > For each policy, it'd be nice to cover the case when there is no network and http error.

ok will do the update

> It makes sense to cover POST requests with body, and check that every time request is passed to client, it contains expected body. Because httpexpect has special logic to achieve this, and ideally we should explicitly test it.

i see. agree. btw could you point out where (in which file) the special logic is?

> I have an idea how we can better test retry delays. We can add a new unexported field to httpexpect.Request:

i see. i'm ok with that. will do the update too
neverbeenthisweeb: @gavv  btw for the `sleepFn`, should we include something like `.WithSleepFn` too? or we can just do `request.sleepFn = func (d time.Duration) { ... }`
neverbeenthisweeb: > (BTW, we have an issue related to this sleep call: https://github.com/gavv/httpexpect/issues/163; after we fix it, we'll have to update this mock as well, but that is not a problem I think)

CMIIW i think the update more into adding test related to context cancellation isn't it?

if so, i think the adjustment will not be that big
gavv: > @gavv btw for the sleepFn, should we include something like .WithSleepFn too?

Nope. Better let's keep this thing internal, so that only tests can override sleep function. I think this is too low-level detail to be exposed to user.

>  or we can just do request.sleepFn = func (d time.Duration) { ... }

Yep.

> CMIIW i think the update more into adding test related to context cancellation isn't it?

#163 will change the way how we sleep from time.Sleep to something like time.After. So the signature of sleepFn will change and retry tests should be adjusted. But yes, this will be a small change.
gavv: > btw could you point out where (in which file) the special logic is?

At the beginning of retryRequest we wrap Body with bodyWrapper. Then, before each retry, it calls its Rewind method to make the body readable again.
neverbeenthisweeb: hi @gavv ,

after implementing this commit (https://github.com/gavv/httpexpect/pull/179/commits/e8ea1ca7665641674781289b1f5d671c5a52f142)

i keep getting this error when running the tests

```
$ make test

--- FAIL: TestExpectBuilders (0.01s)
    expect_test.go:81: 
        	Error Trace:	expect_test.go:81
        	Error:      	Not equal: 
        	            	expected: &httpexpect.Request{config:httpexpect.Config{TestName:"", BaseURL:"", RequestFactory:httpexpect.DefaultRequestFactory{}, Client:(*httpexpect.mockClient)(0xc0000d46e0), WebsocketDialer:(*websocket.Dialer)(0xc00022e980), Context:context.Context(nil), Reporter:(*httpexpect.AssertReporter)(0xc0005873a8), Formatter:(*httpexpect.DefaultFormatter)(0xc0003bc980), AssertionHandler:(*httpexpect.DefaultAssertionHandler)(0xc0003bfa40), Printers:[]httpexpect.Printer(nil), Environment:(*httpexpect.Environment)(nil)}, chain:(*httpexpect.chain)(0xc00022ec00), redirectPolicy:0, maxRedirects:-1, retryPolicy:2, maxRetries:0, minRetryDelay:50000000, maxRetryDelay:5000000000, sleepFn:(func(time.Duration))(0x467700), timeout:0, httpReq:(*http.Request)(0xc0003c0000), path:"/url", query:url.Values(nil), form:url.Values(nil), formbuf:(*bytes.Buffer)(nil), multipart:(*multipart.Writer)(nil), bodySetter:"", typeSetter:"", forceType:false, wsUpgrade:false, transforms:[]func(*http.Request)(nil), matchers:[]func(*httpexpect.Response)(nil)}
        	            	actual  : &httpexpect.Request{config:httpexpect.Config{TestName:"", BaseURL:"", RequestFactory:httpexpect.DefaultRequestFactory{}, Client:(*httpexpect.mockClient)(0xc0000d46e0), WebsocketDialer:(*websocket.Dialer)(0xc00022e980), Context:context.Context(nil), Reporter:(*httpexpect.AssertReporter)(0xc0005873a8), Formatter:(*httpexpect.DefaultFormatter)(0xc0003bc980), AssertionHandler:(*httpexpect.DefaultAssertionHandler)(0xc0003bfa40), Printers:[]httpexpect.Printer(nil), Environment:(*httpexpect.Environment)(nil)}, chain:(*httpexpect.chain)(0xc00022ec80), redirectPolicy:0, maxRedirects:-1, retryPolicy:2, maxRetries:0, minRetryDelay:50000000, maxRetryDelay:5000000000, sleepFn:(func(time.Duration))(0x467700), timeout:0, httpReq:(*http.Request)(0xc0003c0100), path:"/url", query:url.Values(nil), form:url.Values(nil), formbuf:(*bytes.Buffer)(nil), multipart:(*multipart.Writer)(nil), bodySetter:"", typeSetter:"", forceType:false, wsUpgrade:false, transforms:[]func(*http.Request)(nil), matchers:[]func(*httpexpect.Response)(nil)}
        	            	
        	            	Diff:
        	Test:       	TestExpectBuilders
FAIL
exit status 1
FAIL	github.com/gavv/httpexpect/v2	6.967s
make: *** [Makefile:26: test] Error 1
```

i am clueless about this assertion error

mind to help? thank you
gavv: Good catch!

It's interesting. We see accidental combination of several factors:

* TestExpectBuilders had a bug: the last line should check for equality with `r2`, not `r1`

* TestExpectBuilders, as well as some other tests, had another bug: they used incorrectly assertion for comparing pointers; this test should use assert.Same, which succeeds if two pointers are equal; but it instead used assert.Equal, which succeeds if two pointers points to values which are equal

* because of second bug, this first bug remained unidentified: pointers were different, but the values to which they point were equal (both were mostly empty requests with same method and url), and tests passed

* after you added func member to request, the first bug showed itself; it happened because testify can't compare func fields (due to limitations of Go) and thus it considered the two requests non-equal

The proper fix is the following:

* replace r1 with r2 in the last line
* use assert.Same instead of assert.Equal in this test and everywhere else where we intend to compare pointers

I've implemented both fixes in 33581382c517ac7863c1bca416d2dc74aa0118a5 and pushed to master. Please rebase your branch on master and the bug should go away.
neverbeenthisweeb: @gavv got it

will update by this week

including the sleepFn implementation in unit test
coveralls: 
[![Coverage Status](https://coveralls.io/builds/55036595/badge)](https://coveralls.io/builds/55036595)

Coverage increased (+0.1%) to 92.555% when pulling **2a8e1aa62c5bc111e275997693152c0c8d14ccc4 on neverbeenthisweeb:91-retry-unit-test** into **cd6431ecb6799eb0e8d1edaed4b72dee4cddcfc2 on gavv:master**.

neverbeenthisweeb: @gavv i've added the `sleepFn` to the unit test

please LMK if there is anything

thank you

Patch:
diff --git a/request.go b/request.go
index 4e29d7c..5715d1b 100644
--- a/request.go
+++ b/request.go
@@ -38,6 +38,7 @@ type Request struct {
 	maxRetries    int
 	minRetryDelay time.Duration
 	maxRetryDelay time.Duration
+	sleepFn       func(d time.Duration)
 
 	timeout time.Duration
 
@@ -122,6 +123,7 @@ func newRequest(
 		maxRetries:    0,
 		minRetryDelay: time.Millisecond * 50,
 		maxRetryDelay: time.Second * 5,
+		sleepFn:       time.Sleep,
 	}
 
 	r.initPath(path, pathargs...)
@@ -1920,7 +1922,7 @@ func (r *Request) retryRequest(reqFunc func() (*http.Response, error)) (
 			resp.Body.Close()
 		}
 
-		time.Sleep(delay)
+		r.sleepFn(delay)
 
 		delay *= 2
 		if delay > r.maxRetryDelay {


Test patch:
diff --git a/assertion_test.go b/assertion_test.go
index 6b0c09c..5d108aa 100644
--- a/assertion_test.go
+++ b/assertion_test.go
@@ -1,9 +1,11 @@
 package httpexpect
 
 import (
+	"errors"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestAssertionHandler(t *testing.T) {
@@ -139,6 +141,239 @@ func TestAssertionHandler(t *testing.T) {
 	})
 }
 
+func TestAssertionValidation(t *testing.T) {
+	tests := []struct {
+		testName          string
+		errorContainsText string
+		input             AssertionFailure
+	}{
+		{
+			testName:          "bad Type",
+			errorContainsText: "AssertionType",
+			input: AssertionFailure{
+				Type: AssertionType(9999),
+				Errors: []error{
+					errors.New("test"),
+				},
+			},
+		},
+		{
+			testName:          "nil Errors",
+			errorContainsText: "Errors",
+			input: AssertionFailure{
+				Type:   AssertOperation,
+				Errors: nil,
+			},
+		},
+		{
+			testName:          "empty Errros",
+			errorContainsText: "Errors",
+			input: AssertionFailure{
+				Type:   AssertOperation,
+				Errors: []error{},
+			},
+		},
+		{
+			testName:          "denied Actual",
+			errorContainsText: "Actual",
+			input: AssertionFailure{
+				Type: AssertOperation,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "denied Expected",
+			errorContainsText: "Expected",
+			input: AssertionFailure{
+				Type: AssertOperation,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "required Actual and denied Expected",
+			errorContainsText: "",
+			input: AssertionFailure{
+				Type: AssertType,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "missing Actual and denied Expected",
+			errorContainsText: "",
+			input: AssertionFailure{
+				Type: AssertType,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   nil,
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "missing Actual",
+			errorContainsText: "Actual",
+			input: AssertionFailure{
+				Type: AssertEqual,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   nil,
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "missing Expected",
+			errorContainsText: "Expected",
+			input: AssertionFailure{
+				Type: AssertEqual,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: nil,
+			},
+		},
+		{
+			testName:          "missing Actual and Expected",
+			errorContainsText: "",
+			input: AssertionFailure{
+				Type: AssertEqual,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   nil,
+				Expected: nil,
+			},
+		},
+		{
+			testName:          "Range is nil",
+			errorContainsText: "AssertionRange",
+			input: AssertionFailure{
+				Type: AssertInRange,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "Range has wrong type",
+			errorContainsText: "AssertionRange",
+			input: AssertionFailure{
+				Type: AssertInRange,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{"test"},
+			},
+		},
+		{
+			testName:          "Range Min is nil",
+			errorContainsText: "Min",
+			input: AssertionFailure{
+				Type: AssertInRange,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{AssertionRange{nil, 123}},
+			},
+		},
+		{
+			testName:          "Range Max is nil",
+			errorContainsText: "Max",
+			input: AssertionFailure{
+				Type: AssertInRange,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{AssertionRange{123, nil}},
+			},
+		},
+		{
+			testName:          "Range Min and Max are nil",
+			errorContainsText: "",
+			input: AssertionFailure{
+				Type: AssertInRange,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{AssertionRange{nil, nil}},
+			},
+		},
+		{
+			testName:          "List is nil",
+			errorContainsText: "AssertionList",
+			input: AssertionFailure{
+				Type: AssertBelongs,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{},
+			},
+		},
+		{
+			testName:          "List has wrong type",
+			errorContainsText: "AssertionList",
+			input: AssertionFailure{
+				Type: AssertBelongs,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{"test"},
+			},
+		},
+		{
+			testName:          "List is typed nil",
+			errorContainsText: "AssertionList",
+			input: AssertionFailure{
+				Type: AssertBelongs,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{AssertionList(nil)},
+			},
+		},
+		{
+			testName:          "List is empty",
+			errorContainsText: "AssertionList",
+			input: AssertionFailure{
+				Type: AssertBelongs,
+				Errors: []error{
+					errors.New("test"),
+				},
+				Actual:   &AssertionValue{},
+				Expected: &AssertionValue{AssertionList{}},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			err := validateAssertion(&test.input)
+			require.Error(t, err)
+			require.Contains(t, err.Error(), test.errorContainsText)
+		})
+	}
+}
+
 func TestAssertionStrings(t *testing.T) {
 	t.Run("AssertionType", func(t *testing.T) {
 		for i := 0; i < 100; i++ {
diff --git a/mocks_test.go b/mocks_test.go
index d5be240..357ed3a 100644
--- a/mocks_test.go
+++ b/mocks_test.go
@@ -14,9 +14,15 @@ type mockClient struct {
 	req  *http.Request
 	resp http.Response
 	err  error
+	cb   func(req *http.Request) // callback in .Do
 }
 
 func (c *mockClient) Do(req *http.Request) (*http.Response, error) {
+	defer func() {
+		if c.cb != nil {
+			c.cb(req)
+		}
+	}()
 	c.req = req
 	if c.err == nil {
 		c.resp.Header = c.req.Header
@@ -258,3 +264,20 @@ func (wc *mockWebsocketConn) SetWriteDeadline(t time.Time) error {
 func (wc *mockWebsocketConn) Subprotocol() string {
 	return wc.subprotocol
 }
+
+type mockNetError struct {
+	isTimeout   bool
+	isTemporary bool
+}
+
+func (e *mockNetError) Error() string {
+	return "mock net error"
+}
+
+func (e *mockNetError) Timeout() bool {
+	return e.isTimeout
+}
+
+func (e *mockNetError) Temporary() bool {
+	return e.isTemporary
+}
diff --git a/request_test.go b/request_test.go
index 26f4986..5340680 100644
--- a/request_test.go
+++ b/request_test.go
@@ -2215,3 +2215,650 @@ func (mt *mockTransportRedirect) WithMaxRedirect(
 
 	return mt
 }
+
+func TestRequestRetry(t *testing.T) {
+	reporter := newMockReporter(t)
+
+	newNoErrClient := func(cb func(req *http.Request)) *mockClient {
+		return &mockClient{
+			resp: http.Response{
+				StatusCode: http.StatusOK,
+			},
+			cb: cb,
+		}
+	}
+
+	newTempNetErrClient := func(cb func(req *http.Request)) *mockClient {
+		return &mockClient{
+			err: &mockNetError{
+				isTemporary: true,
+			},
+			cb: cb,
+		}
+	}
+
+	newTempServerErrClient := func(cb func(req *http.Request)) *mockClient {
+		return &mockClient{
+			resp: http.Response{
+				StatusCode: http.StatusInternalServerError,
+			},
+			cb: cb,
+		}
+	}
+
+	newHTTPErrClient := func(cb func(req *http.Request)) *mockClient {
+		return &mockClient{
+			resp: http.Response{
+				StatusCode: http.StatusBadRequest,
+			},
+			cb: cb,
+		}
+	}
+
+	noopSleepFn := func(time.Duration) {}
+
+	t.Run("dont retry policy", func(t *testing.T) {
+		t.Run("no error", func(t *testing.T) {
+			callCount := 0
+
+			client := newNoErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(DontRetry)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("temporary network error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempNetErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(DontRetry).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertFailed(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("temporary server error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempServerErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(DontRetry).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusInternalServerError)
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("http error", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(DontRetry).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+	})
+
+	t.Run("retry temporary network errors policy", func(t *testing.T) {
+		t.Run("no error", func(t *testing.T) {
+			callCount := 0
+
+			client := newNoErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkErrors)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("temporary network error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempNetErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertFailed(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+
+		t.Run("temporary server error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempServerErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkErrors).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusInternalServerError)
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("http error", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkErrors).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+	})
+
+	t.Run("retry temporary network and server errors policy", func(t *testing.T) {
+		t.Run("no error", func(t *testing.T) {
+			callCount := 0
+
+			client := newNoErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkAndServerErrors)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("temporary network error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempNetErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkAndServerErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertFailed(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+
+		t.Run("temporary server error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempServerErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkAndServerErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusInternalServerError)
+			resp.chain.assertOK(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+
+		t.Run("http error", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryTemporaryNetworkAndServerErrors).
+				WithMaxRetries(1)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+	})
+
+	t.Run("retry all errors policy", func(t *testing.T) {
+		t.Run("no error", func(t *testing.T) {
+			callCount := 0
+
+			client := newNoErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertOK(t)
+
+			// Should not retry
+			assert.Equal(t, 1, callCount)
+		})
+
+		t.Run("temporary network error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempNetErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect()
+			resp.chain.assertFailed(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+
+		t.Run("temporary server error", func(t *testing.T) {
+			callCount := 0
+
+			client := newTempServerErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusInternalServerError)
+			resp.chain.assertOK(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+
+		t.Run("http error", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors).
+				WithMaxRetries(1).
+				WithRetryDelay(0, 0)
+			req.sleepFn = noopSleepFn
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should retry
+			assert.Equal(t, 2, callCount)
+		})
+	})
+
+	t.Run("max retries", func(t *testing.T) {
+		callCount := 0
+
+		client := newHTTPErrClient(func(req *http.Request) {
+			callCount++
+
+			b, err := ioutil.ReadAll(req.Body)
+			assert.NoError(t, err)
+			assert.Equal(t, "test body", string(b))
+		})
+
+		config := Config{
+			Client:   client,
+			Reporter: reporter,
+		}
+
+		req := NewRequest(config, http.MethodPost, "/url").
+			WithText("test body").
+			WithRetryPolicy(RetryAllErrors).
+			WithMaxRetries(3).
+			WithRetryDelay(0, 0)
+		req.sleepFn = noopSleepFn
+		req.chain.assertOK(t)
+
+		resp := req.Expect().
+			Status(http.StatusBadRequest)
+		resp.chain.assertOK(t)
+
+		// Should retry until max retries is reached
+		assert.Equal(t, 1+3, callCount)
+	})
+
+	t.Run("retry delay", func(t *testing.T) {
+		t.Run("not exceeding max retry delay", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			var totalSleepTime time.Duration
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors).
+				WithMaxRetries(3).
+				WithRetryDelay(100*time.Millisecond, 1000*time.Millisecond)
+			req.sleepFn = func(d time.Duration) {
+				totalSleepTime += d
+			}
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should retry with delay
+			assert.Equal(t, int64(100+200+400), totalSleepTime.Milliseconds())
+		})
+
+		t.Run("exceeding max retry delay", func(t *testing.T) {
+			callCount := 0
+
+			client := newHTTPErrClient(func(req *http.Request) {
+				callCount++
+
+				b, err := ioutil.ReadAll(req.Body)
+				assert.NoError(t, err)
+				assert.Equal(t, "test body", string(b))
+			})
+
+			config := Config{
+				Client:   client,
+				Reporter: reporter,
+			}
+
+			var totalSleepTime time.Duration
+
+			req := NewRequest(config, http.MethodPost, "/url").
+				WithText("test body").
+				WithRetryPolicy(RetryAllErrors).
+				WithMaxRetries(3).
+				WithRetryDelay(100*time.Millisecond, 300*time.Millisecond)
+			req.sleepFn = func(d time.Duration) {
+				totalSleepTime += d
+			}
+			req.chain.assertOK(t)
+
+			resp := req.Expect().
+				Status(http.StatusBadRequest)
+			resp.chain.assertOK(t)
+
+			// Should retry with delay
+			assert.Equal(t, int64(100+200+300), totalSleepTime.Milliseconds())
+		})
+	})
+}