├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── retry.go ├── retry_test.go └── version.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | .idea/ 27 | *.iml 28 | *.out 29 | *.tmp 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.7 5 | - 1.8 6 | - 1.9 7 | - tip 8 | 9 | before_install: 10 | - go get -u -v github.com/nbio/st 11 | - go get -u -v github.com/axw/gocov/gocov 12 | - go get -u -v github.com/mattn/goveralls 13 | - go get -u -v github.com/golang/lint/golint 14 | - go get -u -v gopkg.in/h2non/gentleman.v2 15 | - go get -u -v gopkg.in/eapache/go-resiliency.v1/retrier 16 | 17 | script: 18 | - diff -u <(echo -n) <(gofmt -s -d ./) 19 | - diff -u <(echo -n) <(go vet ./...) 20 | - diff -u <(echo -n) <(golint ./...) 21 | - go test -v -race -covermode=atomic -coverprofile=coverage.out 22 | 23 | after_success: 24 | - goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016-2017 Tomas Aparicio 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [gentleman](https://github.com/h2non/gentleman)-retry [![Build Status](https://travis-ci.org/h2non/gentleman-retry.png)](https://travis-ci.org/h2non/gentleman-retry) [![GoDoc](https://godoc.org/github.com/h2non/gentleman-retry?status.svg)](https://godoc.org/github.com/h2non/gentleman-retry) [![Coverage Status](https://coveralls.io/repos/github/h2non/gentleman-retry/badge.svg?branch=master)](https://coveralls.io/github/h2non/gentleman-retry?branch=master) [![Go Report Card](https://goreportcard.com/badge/github.com/h2non/gentleman-retry)](https://goreportcard.com/report/github.com/h2non/gentleman-retry) 2 | 3 | [gentleman](https://github.com/h2non/gentleman)'s v2 plugin providing retry policy capabilities to your HTTP clients. 4 | 5 | Constant backoff strategy will be used by default with a maximum of 3 attempts, but you use a custom or third-party retry strategies. 6 | Request bodies will be cached in the stack in order to re-send them if needed. 7 | 8 | By default, retry will happen in case of network error or server response error (>= 500 || = 429). 9 | You can use a custom `Evaluator` function to determine with custom logic when should retry or not. 10 | 11 | Behind the scenes it implements a custom [http.RoundTripper](https://golang.org/pkg/net/http/#RoundTripper) 12 | interface which acts like a proxy to `http.Transport`, in order to take full control of the response and retry the request if needed. 13 | 14 | ## Installation 15 | 16 | ```bash 17 | go get -u gopkg.in/h2non/gentleman-retry.v2 18 | ``` 19 | 20 | ## Versions 21 | 22 | - **[v1](https://github.com/h2non/gentleman-retry/tree/v1)** - First version, uses `gentleman@v1`. 23 | - **[v2](https://github.com/h2non/gentleman-retry/tree/master)** - Latest version, uses `gentleman@v2`. 24 | 25 | ## API 26 | 27 | See [godoc reference](https://godoc.org/github.com/h2non/gentleman-retry) for detailed API documentation. 28 | 29 | ## Examples 30 | 31 | #### Default retry strategy 32 | 33 | ```go 34 | package main 35 | 36 | import ( 37 | "fmt" 38 | 39 | "gopkg.in/h2non/gentleman.v2" 40 | "gopkg.in/h2non/gentleman-retry.v2" 41 | ) 42 | 43 | func main() { 44 | // Create a new client 45 | cli := gentleman.New() 46 | 47 | // Define base URL 48 | cli.URL("http://httpbin.org") 49 | 50 | // Register the retry plugin, using the built-in constant back off strategy 51 | cli.Use(retry.New(retry.ConstantBackoff)) 52 | 53 | // Create a new request based on the current client 54 | req := cli.Request() 55 | 56 | // Define the URL path at request level 57 | req.Path("/status/503") 58 | 59 | // Set a new header field 60 | req.SetHeader("Client", "gentleman") 61 | 62 | // Perform the request 63 | res, err := req.Send() 64 | if err != nil { 65 | fmt.Printf("Request error: %s\n", err) 66 | return 67 | } 68 | if !res.Ok { 69 | fmt.Printf("Invalid server response: %d\n", res.StatusCode) 70 | return 71 | } 72 | } 73 | ``` 74 | 75 | #### Exponential retry strategy 76 | 77 | I would recommend you using [go-resiliency](https://github.com/eapache/go-resiliency/tree/master/retrier) package for custom retry estrategies: 78 | ```go 79 | go get -u gopkg.in/eapache/go-resiliency.v1/retrier 80 | ``` 81 | 82 | ```go 83 | package main 84 | 85 | import ( 86 | "fmt" 87 | "time" 88 | 89 | "gopkg.in/h2non/gentleman.v2" 90 | "gopkg.in/h2non/gentleman-retry.v2" 91 | "gopkg.in/eapache/go-resiliency.v1/retrier" 92 | 93 | ) 94 | 95 | func main() { 96 | // Create a new client 97 | cli := gentleman.New() 98 | 99 | // Define base URL 100 | cli.URL("http://httpbin.org") 101 | 102 | // Register the retry plugin, using a custom exponential retry strategy 103 | cli.Use(retry.New(retrier.New(retrier.ExponentialBackoff(3, 100*time.Millisecond), nil))) 104 | 105 | // Create a new request based on the current client 106 | req := cli.Request() 107 | 108 | // Define the URL path at request level 109 | req.Path("/status/503") 110 | 111 | // Set a new header field 112 | req.SetHeader("Client", "gentleman") 113 | 114 | // Perform the request 115 | res, err := req.Send() 116 | if err != nil { 117 | fmt.Printf("Request error: %s\n", err) 118 | return 119 | } 120 | if !res.Ok { 121 | fmt.Printf("Invalid server response: %d\n", res.StatusCode) 122 | return 123 | } 124 | } 125 | ``` 126 | 127 | ## License 128 | 129 | MIT - Tomas Aparicio 130 | -------------------------------------------------------------------------------- /retry.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | "time" 9 | 10 | retry "gopkg.in/eapache/go-resiliency.v1/retrier" 11 | "gopkg.in/h2non/gentleman.v2/context" 12 | "gopkg.in/h2non/gentleman.v2/plugin" 13 | ) 14 | 15 | const ( 16 | // RetryTimes defines the default max amount of times to retry a request. 17 | RetryTimes = 3 18 | 19 | // RetryWait defines the default amount of time to wait before each retry attempt. 20 | RetryWait = 100 * time.Millisecond 21 | ) 22 | 23 | var ( 24 | // ErrServer stores the error when a server error happens. 25 | ErrServer = errors.New("retry: server response error") 26 | ) 27 | 28 | var ( 29 | // ConstantBackoff provides a built-in retry strategy based on constant back off. 30 | ConstantBackoff = retry.New(retry.ConstantBackoff(RetryTimes, RetryWait), nil) 31 | 32 | // ExponentialBackoff provides a built-int retry strategy based on exponential back off. 33 | ExponentialBackoff = retry.New(retry.ExponentialBackoff(RetryTimes, RetryWait), nil) 34 | ) 35 | 36 | // Retrier defines the required interface implemented by retry strategies. 37 | type Retrier interface { 38 | Run(func() error) error 39 | } 40 | 41 | // EvalFunc represents the function interface for failed request evaluator. 42 | type EvalFunc func(error, *http.Response, *http.Request) error 43 | 44 | // Evaluator determines when a request failed in order to retry it, 45 | // evaluating the error, response and optionally the original request. 46 | // 47 | // By default if will retry if an error is present or response status code is >= 500. 48 | // 49 | // You can override this function to use a custom evaluator function with additional logic. 50 | var Evaluator = func(err error, res *http.Response, req *http.Request) error { 51 | if err != nil { 52 | return err 53 | } 54 | if res.StatusCode >= 500 || res.StatusCode == 429 { 55 | return ErrServer 56 | } 57 | return nil 58 | } 59 | 60 | // New creates a new retry plugin based on the given retry strategy. 61 | func New(retrier Retrier) plugin.Plugin { 62 | if retrier == nil { 63 | retrier = ConstantBackoff 64 | } 65 | 66 | // Create retry new plugin 67 | plu := plugin.New() 68 | 69 | // Attach the middleware handler for before dial phase 70 | plu.SetHandler("before dial", func(ctx *context.Context, h context.Handler) { 71 | InterceptTransport(ctx, retrier) 72 | h.Next(ctx) 73 | }) 74 | 75 | return plu 76 | } 77 | 78 | // InterceptTransport is a middleware function handler that intercepts 79 | // the HTTP transport based on the given HTTP retrier and context. 80 | func InterceptTransport(ctx *context.Context, retrier Retrier) error { 81 | newTransport := &Transport{retrier, Evaluator, ctx.Client.Transport, ctx} 82 | ctx.Client.Transport = newTransport 83 | return nil 84 | } 85 | 86 | // Transport provides a http.RoundTripper compatible transport who encapsulates 87 | // the original http.Transport and provides transparent retry support. 88 | type Transport struct { 89 | retrier Retrier 90 | evaluator EvalFunc 91 | transport http.RoundTripper 92 | context *context.Context 93 | } 94 | 95 | // RoundTrip implements the required method by http.RoundTripper interface. 96 | // Performs the network transport over the original http.Transport but providing retry support. 97 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 98 | res := t.context.Response 99 | 100 | // Cache all the body buffer 101 | buf, err := ioutil.ReadAll(req.Body) 102 | if err != nil { 103 | return res, err 104 | } 105 | req.Body.Close() 106 | 107 | // Transport request via retrier 108 | t.retrier.Run(func() error { 109 | // Clone the http.Request for side effects free 110 | reqCopy := &http.Request{} 111 | *reqCopy = *req 112 | 113 | // Restore the cached body buffer 114 | reqCopy.Body = ioutil.NopCloser(bytes.NewBuffer(buf)) 115 | 116 | // Proxy to the original tranport round tripper 117 | res, err = t.transport.RoundTrip(reqCopy) 118 | return t.evaluator(err, res, req) 119 | }) 120 | 121 | // Restore original http.Transport 122 | t.context.Client.Transport = t.transport 123 | 124 | return res, err 125 | } 126 | -------------------------------------------------------------------------------- /retry_test.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/nbio/st" 13 | "gopkg.in/h2non/gentleman.v2" 14 | "gopkg.in/h2non/gentleman.v2/plugins/timeout" 15 | ) 16 | 17 | func TestRetryRequest(t *testing.T) { 18 | calls := 0 19 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | calls++ 21 | if calls < 3 { 22 | w.WriteHeader(http.StatusServiceUnavailable) 23 | return 24 | } 25 | w.Header().Set("foo", r.Header.Get("foo")) 26 | fmt.Fprintln(w, "Hello, world") 27 | })) 28 | defer ts.Close() 29 | 30 | req := gentleman.NewRequest() 31 | req.SetHeader("foo", "bar") 32 | req.URL(ts.URL) 33 | req.Use(New(nil)) 34 | 35 | res, err := req.Send() 36 | st.Expect(t, err, nil) 37 | st.Expect(t, res.Ok, true) 38 | st.Expect(t, res.StatusCode, 200) 39 | st.Expect(t, res.Header.Get("foo"), "bar") 40 | st.Expect(t, calls, 3) 41 | } 42 | 43 | func TestRetryRequestWithPayload(t *testing.T) { 44 | calls := 0 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | calls++ 47 | if calls < 3 { 48 | w.WriteHeader(http.StatusServiceUnavailable) 49 | return 50 | } 51 | buf, _ := ioutil.ReadAll(r.Body) 52 | fmt.Fprintln(w, string(buf)) 53 | })) 54 | defer ts.Close() 55 | 56 | req := gentleman.NewRequest() 57 | req.URL(ts.URL) 58 | req.Method("POST") 59 | req.BodyString("Hello, world") 60 | req.Use(New(nil)) 61 | 62 | res, err := req.Send() 63 | st.Expect(t, err, nil) 64 | st.Expect(t, res.Ok, true) 65 | st.Expect(t, res.RawResponse.ContentLength, int64(13)) 66 | st.Expect(t, res.StatusCode, 200) 67 | st.Expect(t, res.String(), "Hello, world\n") 68 | st.Expect(t, calls, 3) 69 | } 70 | 71 | func TestRetryServerError(t *testing.T) { 72 | calls := 0 73 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | calls++ 75 | w.WriteHeader(http.StatusServiceUnavailable) 76 | })) 77 | defer ts.Close() 78 | 79 | req := gentleman.NewRequest() 80 | req.URL(ts.URL) 81 | req.Use(New(nil)) 82 | 83 | res, err := req.Send() 84 | st.Expect(t, err, nil) 85 | st.Expect(t, res.Ok, false) 86 | st.Expect(t, res.StatusCode, 503) 87 | st.Expect(t, calls, 4) 88 | } 89 | 90 | func TestRetryNetworkError(t *testing.T) { 91 | req := gentleman.NewRequest() 92 | req.URL("http://127.0.0.1:9123") 93 | req.Use(New(nil)) 94 | 95 | res, err := req.Send() 96 | st.Reject(t, err, nil) 97 | st.Expect(t, strings.Contains(err.Error(), "connection refused"), true) 98 | st.Expect(t, res.Ok, false) 99 | st.Expect(t, res.StatusCode, 0) 100 | } 101 | 102 | // Timeout retry is not fully supported yet 103 | func testRetryNetworkTimeout(t *testing.T) { 104 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 | time.Sleep(1 * time.Second) 106 | w.WriteHeader(200) 107 | })) 108 | defer ts.Close() 109 | 110 | req := gentleman.NewRequest() 111 | req.URL(ts.URL) 112 | req.Use(timeout.Request(100 * time.Millisecond)) 113 | req.Use(New(nil)) 114 | 115 | res, err := req.Send() 116 | st.Reject(t, err, nil) 117 | st.Expect(t, strings.Contains(err.Error(), "request canceled"), true) 118 | st.Expect(t, res.Ok, false) 119 | st.Expect(t, res.StatusCode, 0) 120 | } 121 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | // Version defines the package semantic version 4 | const Version = "2.0.1" 5 | --------------------------------------------------------------------------------