Skip to content

Commit

Permalink
added client & tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mmadfox committed Mar 4, 2020
1 parent a690aa9 commit bf82141
Show file tree
Hide file tree
Showing 7 changed files with 649 additions and 0 deletions.
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.PHONY: deps test cover sync-coveralls mocks

deps:
go mod download

test: deps
go test ./...

covertest: deps
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

sync-coveralls: deps
go test -coverprofile=coverage.out ./...
goveralls -coverprofile=coverage.out -reponame=httpclient -repotoken=${COVERALLS_HTTPCLIENT_TOKEN} -service=local

mocks: deps
mockgen -package=httpclient -destination=client_mock.go . Doer

59 changes: 59 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package httpclient

import (
"context"
"io"
"net/http"
"time"
)

// Doer interface has the method required to use a type as custom http client.
// The net/*http.Client type satisfies this interface.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}

// Client is a generic HTTP client interface.
type Client interface {
Get(ctx context.Context, url string, headers http.Header) (*http.Response, error)
Post(ctx context.Context, url string, body io.Reader, headers http.Header) (*http.Response, error)
Put(ctx context.Context, url string, body io.Reader, headers http.Header) (*http.Response, error)
Delete(ctx context.Context, url string, headers http.Header) (*http.Response, error)
Do(req *http.Request) (*http.Response, error)
}

// RequestHook allows a function to run before each retry. The HTTP
// request which will be made, and the retry number (0 for the initial
// request) are available to users.
type RequestHook func(*http.Request, int)

// ResponseHook is like RequestHook, but allows running a function
// on each HTTP response. This function will be invoked at the end of
// every HTTP request executed, regardless of whether a subsequent retry
// needs to be performed or not. If the response body is read or closed
// from this method, this will affect the response returned from Do().
type ResponseHook func(*http.Request, *http.Response)

// CheckRetry specifies a policy for handling retries. It is called
// following each request with the response and error values returned by
// the http.Client. If CheckRetry returns false, the Client stops retrying
// and returns the response to the caller. If CheckRetry returns an error,
// that error value is returned in lieu of the error from the request. The
// Client will close any response body when retrying, but if the retry is
// aborted it is up to the CheckRetry callback to properly close any
// response body before returning.
type CheckRetry func(req *http.Request, resp *http.Response, err error) (bool, error)

// BackOff specifies a policy for how long to wait between retries.
// It is called after a failing request to determine the amount of time
// that should pass before trying again.
type BackOff func(attemptNum int, resp *http.Response) time.Duration

// ErrorHandler is called if retries are expired, containing the last status
// from the http library. If not specified, default behavior for the library is
// to close the body and return an error indicating how many tries were
// attempted. If overriding this, be sure to close the body if needed.
type ErrorHandler func(resp *http.Response, err error, numTries int) (*http.Response, error)

// ErrorHook is called when the request returned a connection error.
type ErrorHook func(req *http.Request, err error, retry int)
49 changes: 49 additions & 0 deletions client_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/mediabuyerbot/httpclient

go 1.14

require (
github.com/gojek/valkyrie v0.0.0-20190210220504-8f62c1e7ba45
github.com/golang/mock v1.4.1
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.5.1
)
200 changes: 200 additions & 0 deletions http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package httpclient

import (
"bytes"
"context"
"io"
"io/ioutil"
"net/http"
"time"

"github.com/gojek/valkyrie"

"github.com/pkg/errors"
)

const (
DefaultHTTPTimeout = 60 * time.Second
)

// HttpClient is the http client implementation
type HttpClient struct {
baseURL string
client Doer
retryCount int
requestHook RequestHook
responseHook ResponseHook
errorHook ErrorHook
checkRetry CheckRetry
backOff BackOff
errorHandler ErrorHandler
}

var defaultBackOffPolicy = func(attemptNum int, resp *http.Response) time.Duration {
return 500 * time.Millisecond
}

// New returns a new instance of Client.
func New(opts ...Option) (Client, error) {
client := HttpClient{
backOff: defaultBackOffPolicy,
client: &http.Client{
Timeout: DefaultHTTPTimeout,
},
}
for _, opt := range opts {
opt(&client)
}
return &client, nil
}

// Get makes a HTTP GET request to provided URL.
func (c *HttpClient) Get(ctx context.Context, url string, headers http.Header) (*http.Response, error) {
var response *http.Response
if len(c.baseURL) > 0 {
url = c.baseURL + url
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return response, errors.Wrap(err, "GET - request creation failed")
}
request.Header = headers
return c.Do(request)
}

// Post makes a HTTP POST request to provided URL and requestBody.
func (c *HttpClient) Post(ctx context.Context, url string, body io.Reader, headers http.Header) (*http.Response, error) {
var response *http.Response
if len(c.baseURL) > 0 {
url = c.baseURL + url
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return response, errors.Wrap(err, "POST - request creation failed")
}

request.Header = headers

return c.Do(request)
}

// Put makes a HTTP PUT request to provided URL and requestBody.
func (c *HttpClient) Put(ctx context.Context, url string, body io.Reader, headers http.Header) (*http.Response, error) {
var response *http.Response
if len(c.baseURL) > 0 {
url = c.baseURL + url
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body)
if err != nil {
return response, errors.Wrap(err, "PUT - request creation failed")
}

request.Header = headers

return c.Do(request)
}

// Delete makes a HTTP DELETE request with provided URL.
func (c *HttpClient) Delete(ctx context.Context, url string, headers http.Header) (*http.Response, error) {
var response *http.Response
if len(c.baseURL) > 0 {
url = c.baseURL + url
}
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return response, errors.Wrap(err, "DELETE - request creation failed")
}

request.Header = headers

return c.Do(request)
}

// Do makes an HTTP request with the native `http.Do` interface.
func (c *HttpClient) Do(req *http.Request) (resp *http.Response, err error) {
var bodyReader *bytes.Reader

req.Close = true
if req.Body != nil {
reqData, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(reqData)
req.Body = ioutil.NopCloser(bodyReader)
}

multiErr := &valkyrie.MultiError{}
var numTries int
for i := 0; i <= c.retryCount; i++ {
isRetryOk := c.retryCount > 0 && i < c.retryCount
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}

if c.requestHook != nil {
c.requestHook(req, i)
}

var err error
resp, err = c.client.Do(req)
if bodyReader != nil {
_, _ = bodyReader.Seek(0, 0)
}
if err != nil {
if c.errorHook != nil {
c.errorHook(req, err, i)
}

multiErr.Push(err.Error())

if c.checkRetry != nil {
checkOK, checkErr := c.checkRetry(req, resp, err)
if !checkOK {
if checkErr != nil {
multiErr.Push(checkErr.Error())
}
break
}
}
if isRetryOk {
wait := c.backOff(i, resp)
time.Sleep(wait)
}
numTries++
continue
}

if c.responseHook != nil {
c.responseHook(req, resp)
}

var nextLoop bool
isDefaultRetryPolicy := resp.StatusCode >= http.StatusInternalServerError && isRetryOk

if c.checkRetry != nil && isRetryOk {
checkOK, checkErr := c.checkRetry(req, resp, nil)
if !checkOK {
if checkErr != nil {
multiErr.Push(checkErr.Error())
}
break
}
nextLoop = true
} else if isDefaultRetryPolicy {
nextLoop = true
}

if nextLoop {
wait := c.backOff(i, resp)
time.Sleep(wait)
numTries++
continue
}
break
}
if c.errorHandler != nil {
return c.errorHandler(resp, multiErr.HasError(), numTries)
}
return resp, multiErr.HasError()
}

0 comments on commit bf82141

Please sign in to comment.