pax_global_header00006660000000000000000000000064145035755620014526gustar00rootroot0000000000000052 comment=165afe32d98109bc981b6dc16e148bc17dafc6f4 golang-github-cristalhq-hedgedhttp-0.9.1/000077500000000000000000000000001450357556200203725ustar00rootroot00000000000000golang-github-cristalhq-hedgedhttp-0.9.1/.github/000077500000000000000000000000001450357556200217325ustar00rootroot00000000000000golang-github-cristalhq-hedgedhttp-0.9.1/.github/dependabot.yml000066400000000000000000000003051450357556200245600ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: gomod directory: "/" schedule: interval: daily - package-ecosystem: github-actions directory: "/" schedule: interval: daily golang-github-cristalhq-hedgedhttp-0.9.1/.github/workflows/000077500000000000000000000000001450357556200237675ustar00rootroot00000000000000golang-github-cristalhq-hedgedhttp-0.9.1/.github/workflows/build.yml000066400000000000000000000007761450357556200256230ustar00rootroot00000000000000name: build permissions: read-all on: push: branches: [main] pull_request: branches: [main] schedule: - cron: '0 0 * * 0' # run "At 00:00 on Sunday" workflow_dispatch: inputs: tag: description: 'Tag to create' required: true default: 'v0.0.0' # See https://github.com/cristalhq/.github/.github/workflows jobs: build: uses: cristalhq/.github/.github/workflows/build.yml@v0.5.0 vuln: uses: cristalhq/.github/.github/workflows/vuln.yml@v0.5.0 golang-github-cristalhq-hedgedhttp-0.9.1/LICENSE000066400000000000000000000020541450357556200214000ustar00rootroot00000000000000MIT License Copyright (c) 2021 cristaltech Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-cristalhq-hedgedhttp-0.9.1/README.md000066400000000000000000000044371450357556200216610ustar00rootroot00000000000000# hedgedhttp [![build-img]][build-url] [![pkg-img]][pkg-url] [![reportcard-img]][reportcard-url] [![coverage-img]][coverage-url] [![version-img]][version-url] Hedged HTTP client which helps to reduce tail latency at scale. ## Rationale See paper [Tail at Scale](https://www.barroso.org/publications/TheTailAtScale.pdf) by Jeffrey Dean, Luiz AndrĂ© Barroso. In short: the client first sends one request, but then sends an additional request after a timeout if the previous hasn't returned an answer in the expected time. The client cancels remaining requests once the first result is received. ## Acknowledge Thanks to [Bohdan Storozhuk](https://github.com/storozhukbm) for the review and powerful hints. ## Features * Simple API. * Easy to integrate. * Optimized for speed. * Clean and tested code. * Supports `http.Client` and `http.RoundTripper`. * Dependency-free. ## Install Go version 1.16+ ``` go get github.com/cristalhq/hedgedhttp ``` ## Example ```go ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://google.com", http.NoBody) if err != nil { panic(err) } timeout := 10 * time.Millisecond upto := 7 client := &http.Client{Timeout: time.Second} hedged, err := hedgedhttp.NewClient(timeout, upto, client) if err != nil { panic(err) } // will take `upto` requests, with a `timeout` delay between them resp, err := hedged.Do(req) if err != nil { panic(err) } defer resp.Body.Close() ``` Also see examples: [examples_test.go](https://github.com/cristalhq/hedgedhttp/blob/main/examples_test.go). ## Documentation See [these docs][pkg-url]. ## License [MIT License](LICENSE). [build-img]: https://github.com/cristalhq/hedgedhttp/workflows/build/badge.svg [build-url]: https://github.com/cristalhq/hedgedhttp/actions [pkg-img]: https://pkg.go.dev/badge/cristalhq/hedgedhttp [pkg-url]: https://pkg.go.dev/github.com/cristalhq/hedgedhttp [reportcard-img]: https://goreportcard.com/badge/cristalhq/hedgedhttp [reportcard-url]: https://goreportcard.com/report/cristalhq/hedgedhttp [coverage-img]: https://codecov.io/gh/cristalhq/hedgedhttp/branch/main/graph/badge.svg [coverage-url]: https://codecov.io/gh/cristalhq/hedgedhttp [version-img]: https://img.shields.io/github/v/release/cristalhq/hedgedhttp [version-url]: https://github.com/cristalhq/hedgedhttp/releases golang-github-cristalhq-hedgedhttp-0.9.1/examples_test.go000066400000000000000000000113641450357556200236030ustar00rootroot00000000000000package hedgedhttp_test import ( "context" "errors" "fmt" "io" "math/rand" "net/http" "sync/atomic" "time" "github.com/cristalhq/hedgedhttp" ) func ExampleClient() { ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cristalhq.dev", http.NoBody) if err != nil { panic(err) } timeout := 10 * time.Millisecond upto := 7 client := &http.Client{Timeout: time.Second} hedged, err := hedgedhttp.NewClient(timeout, upto, client) if err != nil { panic(err) } // will take `upto` requests, with a `timeout` delay between them resp, err := hedged.Do(req) if err != nil { panic(err) } defer resp.Body.Close() // and do something with resp // Output: } func Example_configNext() { rt := &observableRoundTripper{ rt: http.DefaultTransport, } cfg := hedgedhttp.Config{ Transport: rt, Upto: 3, Delay: 50 * time.Millisecond, Next: func() (upto int, delay time.Duration) { return 3, rt.MaxLatency() }, } client, err := hedgedhttp.New(cfg) if err != nil { panic(err) } // or client.Do resp, err := client.RoundTrip(&http.Request{}) _ = resp // Output: } func ExampleRoundTripper() { ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cristalhq.dev", http.NoBody) if err != nil { panic(err) } timeout := 10 * time.Millisecond upto := 7 transport := http.DefaultTransport hedged, stats, err := hedgedhttp.NewRoundTripperAndStats(timeout, upto, transport) if err != nil { panic(err) } // print stats periodically go func() { for { time.Sleep(time.Second) fmt.Fprintf(io.Discard, "all requests: %d\n", stats.ActualRoundTrips()) } }() // will take `upto` requests, with a `timeout` delay between them resp, err := hedged.RoundTrip(req) if err != nil { panic(err) } defer resp.Body.Close() // and do something with resp // Output: } func Example_instrumented() { transport := &InstrumentedTransport{ Transport: http.DefaultTransport, } _, err := hedgedhttp.NewRoundTripper(time.Millisecond, 3, transport) if err != nil { panic(err) } // Output: } type InstrumentedTransport struct { Transport http.RoundTripper // Used to make actual requests. } func (t *InstrumentedTransport) RoundTrip(req *http.Request) (*http.Response, error) { // NOTE: log, update metric, add trace to the req variable. resp, err := t.Transport.RoundTrip(req) if err != nil { return nil, err } // NOTE: log, update metric based on the resp variable. return resp, nil } func Example_ratelimited() { transport := &RateLimitedHedgedTransport{ Transport: http.DefaultTransport, Limiter: &RandomRateLimiter{}, } _, err := hedgedhttp.NewRoundTripper(time.Millisecond, 3, transport) if err != nil { panic(err) } // Output: } // by example https://pkg.go.dev/golang.org/x/time/rate type RateLimiter interface { Wait(ctx context.Context) error } type RateLimitedHedgedTransport struct { Transport http.RoundTripper // Used to make actual requests. Limiter RateLimiter // Any ratelimit-like thing. } func (t *RateLimitedHedgedTransport) RoundTrip(r *http.Request) (*http.Response, error) { // apply rate limit only for hedged requests if hedgedhttp.IsHedgedRequest(r) { if err := t.Limiter.Wait(r.Context()); err != nil { return nil, err } } return t.Transport.RoundTrip(r) } // Just for the example. type RandomRateLimiter struct{} func (r *RandomRateLimiter) Wait(ctx context.Context) error { if rand.Int()%2 == 0 { return errors.New("rate limit exceed") } return nil } func ExampleMultiTransport() { transport := &MultiTransport{ First: &http.Transport{MaxIdleConns: 10}, // just an example Hedged: &http.Transport{MaxIdleConns: 30}, // just an example } _, err := hedgedhttp.NewRoundTripper(time.Millisecond, 3, transport) if err != nil { panic(err) } // Output: } type MultiTransport struct { First http.RoundTripper // Used to make 1st requests. Hedged http.RoundTripper // Used to make hedged requests. } func (t *MultiTransport) RoundTrip(req *http.Request) (*http.Response, error) { if hedgedhttp.IsHedgedRequest(req) { return t.Hedged.RoundTrip(req) } return t.First.RoundTrip(req) } type observableRoundTripper struct { rt http.RoundTripper maxLatency atomic.Uint64 } func (ort *observableRoundTripper) MaxLatency() time.Duration { return time.Duration(ort.maxLatency.Load()) } func (ort *observableRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { start := time.Now() resp, err := ort.rt.RoundTrip(req) if err != nil { return resp, err } took := uint64(time.Since(start).Nanoseconds()) for { max := ort.maxLatency.Load() if max >= took { return resp, err } if ort.maxLatency.CompareAndSwap(max, took) { return resp, err } } } golang-github-cristalhq-hedgedhttp-0.9.1/go.mod000066400000000000000000000000601450357556200214740ustar00rootroot00000000000000module github.com/cristalhq/hedgedhttp go 1.16 golang-github-cristalhq-hedgedhttp-0.9.1/hedged.go000066400000000000000000000225241450357556200221460ustar00rootroot00000000000000package hedgedhttp import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" ) const infiniteTimeout = 30 * 24 * time.Hour // domain specific infinite // Client represents a hedged HTTP client. type Client struct { rt http.RoundTripper stats *Stats } // Config for the [Client]. type Config struct { // Transport of the [Client]. // Default is nil which results in [net/http.DefaultTransport]. Transport http.RoundTripper // Upto says how much requests to make. // Default is zero which means no hedged requests will be made. Upto int // Delay before 2 consequitive hedged requests. Delay time.Duration // Next returns the upto and delay for each HTTP that will be hedged. // Default is nil which results in (Upto, Delay) result. Next NextFn } // NextFn represents a function that is called for each HTTP request for retrieving hedging options. type NextFn func() (upto int, delay time.Duration) // New returns a new Client for the given config. func New(cfg Config) (*Client, error) { switch { case cfg.Delay < 0: return nil, errors.New("hedgedhttp: timeout cannot be negative") case cfg.Upto < 0: return nil, errors.New("hedgedhttp: upto cannot be negative") } if cfg.Transport == nil { cfg.Transport = http.DefaultTransport } rt, stats, err := NewRoundTripperAndStats(cfg.Delay, cfg.Upto, cfg.Transport) if err != nil { return nil, err } // TODO(cristaloleg): this should be removed after internals cleanup. rt2, ok := rt.(*hedgedTransport) if !ok { panic(fmt.Sprintf("want *hedgedTransport got %T", rt)) } rt2.next = cfg.Next c := &Client{ rt: rt2, stats: stats, } return c, nil } // Stats returns statistics for the given client, see [Stats] methods. func (c *Client) Stats() *Stats { return c.stats } // Do does the same as [RoundTrip], this method is presented to align with [net/http.Client]. func (c *Client) Do(req *http.Request) (*http.Response, error) { return c.rt.RoundTrip(req) } // RoundTrip implements [net/http.RoundTripper] interface. func (c *Client) RoundTrip(req *http.Request) (*http.Response, error) { return c.rt.RoundTrip(req) } // NewClient returns a new http.Client which implements hedged requests pattern. // Given Client starts a new request after a timeout from previous request. // Starts no more than upto requests. func NewClient(timeout time.Duration, upto int, client *http.Client) (*http.Client, error) { newClient, _, err := NewClientAndStats(timeout, upto, client) if err != nil { return nil, err } return newClient, nil } // NewClientAndStats returns a new http.Client which implements hedged requests pattern // And Stats object that can be queried to obtain client's metrics. // Given Client starts a new request after a timeout from previous request. // Starts no more than upto requests. func NewClientAndStats(timeout time.Duration, upto int, client *http.Client) (*http.Client, *Stats, error) { if client == nil { client = &http.Client{ Timeout: 5 * time.Second, } } newTransport, metrics, err := NewRoundTripperAndStats(timeout, upto, client.Transport) if err != nil { return nil, nil, err } client.Transport = newTransport return client, metrics, nil } // NewRoundTripper returns a new http.RoundTripper which implements hedged requests pattern. // Given RoundTripper starts a new request after a timeout from previous request. // Starts no more than upto requests. func NewRoundTripper(timeout time.Duration, upto int, rt http.RoundTripper) (http.RoundTripper, error) { newRT, _, err := NewRoundTripperAndStats(timeout, upto, rt) if err != nil { return nil, err } return newRT, nil } // NewRoundTripperAndStats returns a new http.RoundTripper which implements hedged requests pattern // And Stats object that can be queried to obtain client's metrics. // Given RoundTripper starts a new request after a timeout from previous request. // Starts no more than upto requests. func NewRoundTripperAndStats(timeout time.Duration, upto int, rt http.RoundTripper) (http.RoundTripper, *Stats, error) { switch { case timeout < 0: return nil, nil, errors.New("hedgedhttp: timeout cannot be negative") case upto < 0: return nil, nil, errors.New("hedgedhttp: upto cannot be negative") } if rt == nil { rt = http.DefaultTransport } if timeout == 0 { timeout = time.Nanosecond // smallest possible timeout if not set } hedged := &hedgedTransport{ rt: rt, timeout: timeout, upto: upto, metrics: &Stats{}, } return hedged, hedged.metrics, nil } type hedgedTransport struct { rt http.RoundTripper timeout time.Duration upto int next NextFn metrics *Stats } func (ht *hedgedTransport) RoundTrip(req *http.Request) (*http.Response, error) { mainCtx := req.Context() upto, timeout := ht.upto, ht.timeout if ht.next != nil { upto, timeout = ht.next() } // no hedged requests, just a regular one. if upto <= 0 { return ht.rt.RoundTrip(req) } // rollback to default timeout. if timeout < 0 { timeout = ht.timeout } errOverall := &MultiError{} resultCh := make(chan indexedResp, upto) errorCh := make(chan error, upto) ht.metrics.requestedRoundTripsInc() resultIdx := -1 cancels := make([]func(), upto) defer runInPool(func() { for i, cancel := range cancels { if i != resultIdx && cancel != nil { ht.metrics.canceledSubRequestsInc() cancel() } } }) for sent := 0; len(errOverall.Errors) < upto; sent++ { if sent < upto { idx := sent subReq, cancel := reqWithCtx(req, mainCtx, idx != 0) cancels[idx] = cancel runInPool(func() { ht.metrics.actualRoundTripsInc() resp, err := ht.rt.RoundTrip(subReq) if err != nil { ht.metrics.failedRoundTripsInc() errorCh <- err } else { resultCh <- indexedResp{idx, resp} } }) } // all request sent - effectively disabling timeout between requests if sent == upto { timeout = infiniteTimeout } resp, err := waitResult(mainCtx, resultCh, errorCh, timeout) switch { case resp.Resp != nil: resultIdx = resp.Index if resultIdx == 0 { ht.metrics.originalRequestWinsInc() } else { ht.metrics.hedgedRequestWinsInc() } return resp.Resp, nil case mainCtx.Err() != nil: ht.metrics.canceledByUserRoundTripsInc() return nil, mainCtx.Err() case err != nil: errOverall.Errors = append(errOverall.Errors, err) } } // all request have returned errors return nil, errOverall } func waitResult(ctx context.Context, resultCh <-chan indexedResp, errorCh <-chan error, timeout time.Duration) (indexedResp, error) { // try to read result first before blocking on all other channels select { case res := <-resultCh: return res, nil default: timer := getTimer(timeout) defer returnTimer(timer) select { case res := <-resultCh: return res, nil case reqErr := <-errorCh: return indexedResp{}, reqErr case <-ctx.Done(): return indexedResp{}, ctx.Err() case <-timer.C: return indexedResp{}, nil // it's not a request timeout, it's timeout BETWEEN consecutive requests } } } type indexedResp struct { Index int Resp *http.Response } func reqWithCtx(r *http.Request, ctx context.Context, isHedged bool) (*http.Request, context.CancelFunc) { ctx, cancel := context.WithCancel(ctx) if isHedged { ctx = context.WithValue(ctx, hedgedRequest{}, struct{}{}) } req := r.WithContext(ctx) return req, cancel } type hedgedRequest struct{} // IsHedgedRequest reports when a request is hedged. func IsHedgedRequest(r *http.Request) bool { val := r.Context().Value(hedgedRequest{}) return val != nil } var taskQueue = make(chan func()) func runInPool(task func()) { select { case taskQueue <- task: // submitted, everything is ok default: go func() { // do the given task task() const cleanupDuration = 10 * time.Second cleanupTicker := time.NewTicker(cleanupDuration) defer cleanupTicker.Stop() for { select { case t := <-taskQueue: t() cleanupTicker.Reset(cleanupDuration) case <-cleanupTicker.C: return } } }() } } // MultiError is an error type to track multiple errors. This is used to // accumulate errors in cases and return them as a single "error". // Inspired by https://github.com/hashicorp/go-multierror type MultiError struct { Errors []error ErrorFormatFn ErrorFormatFunc } func (e *MultiError) Error() string { fn := e.ErrorFormatFn if fn == nil { fn = listFormatFunc } return fn(e.Errors) } func (e *MultiError) String() string { return fmt.Sprintf("*%#v", e.Errors) } // ErrorOrNil returns an error if there are some. func (e *MultiError) ErrorOrNil() error { switch { case e == nil || len(e.Errors) == 0: return nil default: return e } } // ErrorFormatFunc is called by MultiError to return the list of errors as a string. type ErrorFormatFunc func([]error) string func listFormatFunc(es []error) string { if len(es) == 1 { return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", es[0]) } points := make([]string, len(es)) for i, err := range es { points[i] = fmt.Sprintf("* %s", err) } return fmt.Sprintf("%d errors occurred:\n\t%s\n\n", len(es), strings.Join(points, "\n\t")) } var timerPool = sync.Pool{New: func() interface{} { return time.NewTimer(time.Second) }} func getTimer(duration time.Duration) *time.Timer { timer := timerPool.Get().(*time.Timer) timer.Reset(duration) return timer } func returnTimer(timer *time.Timer) { timer.Stop() select { case <-timer.C: default: } timerPool.Put(timer) } golang-github-cristalhq-hedgedhttp-0.9.1/hedged_bench_test.go000066400000000000000000000042031450357556200243360ustar00rootroot00000000000000package hedgedhttp_test import ( "fmt" "io" "math/rand" "net/http" "sync" "sync/atomic" "testing" "time" "github.com/cristalhq/hedgedhttp" ) var localRandom = sync.Pool{ New: func() interface{} { return rand.New(rand.NewSource(time.Now().Unix())) }, } func getLocalRand() *rand.Rand { return localRandom.Get().(*rand.Rand) } func returnLocalRand(rnd *rand.Rand) { localRandom.Put(rnd) } type FuncRoundTripper struct { f func(request *http.Request) (*http.Response, error) } func (f *FuncRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return f.f(request) } func BenchmarkHedgedRequest(b *testing.B) { benchmarks := []struct { concurrency int }{ {concurrency: 1}, {concurrency: 2}, {concurrency: 4}, {concurrency: 8}, {concurrency: 12}, {concurrency: 16}, {concurrency: 24}, {concurrency: 32}, } for _, bm := range benchmarks { b.Run(fmt.Sprintf("concurrency-%v", bm.concurrency), func(b *testing.B) { b.ReportAllocs() target := &FuncRoundTripper{ f: func(request *http.Request) (*http.Response, error) { rnd := getLocalRand() defer returnLocalRand(rnd) if rnd.Float32() < 0.3 { return &http.Response{}, nil } return nil, io.EOF }, } errors := uint64(0) var snapshot atomic.Value hedgedTarget, metrics, err := hedgedhttp.NewRoundTripperAndStats(10*time.Nanosecond, 10, target) mustOk(b, err) initialSnapshot := metrics.Snapshot() snapshot.Store(&initialSnapshot) go func() { ticker := time.NewTicker(1 * time.Millisecond) defer ticker.Stop() for range ticker.C { currentSnapshot := metrics.Snapshot() snapshot.Store(¤tSnapshot) } }() req := newGetReq("whatever") mustOk(b, err) var wg sync.WaitGroup wg.Add(bm.concurrency) for i := 0; i < bm.concurrency; i++ { go func() { for i := 0; i < b.N/bm.concurrency; i++ { _, err := hedgedTarget.RoundTrip(req) if err != nil { atomic.AddUint64(&errors, 1) } } wg.Done() }() } wg.Wait() if rand.Float32() < 0.001 { fmt.Printf("Snapshot: %+v\n", snapshot.Load()) } }) } } golang-github-cristalhq-hedgedhttp-0.9.1/hedged_test.go000066400000000000000000000365701450357556200232130ustar00rootroot00000000000000package hedgedhttp_test import ( "context" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/cristalhq/hedgedhttp" ) func TestClient(t *testing.T) { const handlerSleep = 100 * time.Millisecond url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(handlerSleep) }) cfg := hedgedhttp.Config{ Transport: http.DefaultTransport, Upto: 3, Delay: 50 * time.Millisecond, Next: func() (upto int, delay time.Duration) { return 5, 10 * time.Millisecond }, } client, err := hedgedhttp.New(cfg) mustOk(t, err) start := time.Now() resp, err := client.Do(newGetReq(url)) took := time.Since(start) mustOk(t, err) defer resp.Body.Close() mustTrue(t, resp != nil) mustEqual(t, resp.StatusCode, http.StatusOK) stats := client.Stats() mustEqual(t, stats.ActualRoundTrips(), uint64(5)) mustEqual(t, stats.OriginalRequestWins(), uint64(1)) mustTrue(t, took >= handlerSleep && took < (handlerSleep+10*time.Millisecond)) } func TestClientBadNextUpto(t *testing.T) { const handlerSleep = 100 * time.Millisecond url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(handlerSleep) }) cfg := hedgedhttp.Config{ Transport: http.DefaultTransport, Upto: 2, Delay: 50 * time.Millisecond, Next: func() (upto int, delay time.Duration) { return -1, 10 * time.Millisecond }, } client, err := hedgedhttp.New(cfg) mustOk(t, err) start := time.Now() resp, err := client.Do(newGetReq(url)) took := time.Since(start) mustOk(t, err) defer resp.Body.Close() mustTrue(t, resp != nil) mustEqual(t, resp.StatusCode, http.StatusOK) stats := client.Stats() mustEqual(t, stats.ActualRoundTrips(), uint64(0)) mustTrue(t, took >= handlerSleep && took < (handlerSleep+10*time.Millisecond)) } func TestClientBadNextDelay(t *testing.T) { const handlerSleep = 100 * time.Millisecond url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(handlerSleep) }) cfg := hedgedhttp.Config{ Transport: http.DefaultTransport, Upto: 2, Delay: 150 * time.Millisecond, Next: func() (upto int, delay time.Duration) { return 2, -10 * time.Millisecond }, } client, err := hedgedhttp.New(cfg) mustOk(t, err) start := time.Now() resp, err := client.Do(newGetReq(url)) took := time.Since(start) mustOk(t, err) defer resp.Body.Close() mustTrue(t, resp != nil) mustEqual(t, resp.StatusCode, http.StatusOK) stats := client.Stats() mustEqual(t, stats.ActualRoundTrips(), uint64(1)) mustTrue(t, took >= handlerSleep && took < (handlerSleep+10*time.Millisecond)) } func TestValidateInput(t *testing.T) { var err error _, err = hedgedhttp.New(hedgedhttp.Config{ Delay: -time.Second, }) mustFail(t, err) _, err = hedgedhttp.New(hedgedhttp.Config{ Upto: -1, }) mustFail(t, err) _, _, err = hedgedhttp.NewClientAndStats(-time.Second, 0, nil) mustFail(t, err) _, _, err = hedgedhttp.NewClientAndStats(time.Second, -1, nil) mustFail(t, err) _, _, err = hedgedhttp.NewClientAndStats(time.Second, -1, nil) mustFail(t, err) _, err = hedgedhttp.NewRoundTripper(time.Second, -1, nil) mustFail(t, err) } func TestUpto(t *testing.T) { var gotRequests int64 url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&gotRequests, 1) time.Sleep(100 * time.Millisecond) }) const upto = 7 client, err := hedgedhttp.NewClient(10*time.Millisecond, upto, nil) mustOk(t, err) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() mustEqual(t, atomic.LoadInt64(&gotRequests), int64(upto)) } func TestUptoWithInstrumentation(t *testing.T) { var gotRequests int64 url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&gotRequests, 1) time.Sleep(100 * time.Millisecond) }) const upto = 7 client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() mustEqual(t, atomic.LoadInt64(&gotRequests), int64(upto)) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() <= upto) } func TestNoTimeout(t *testing.T) { const sleep = 10 * time.Millisecond var gotRequests int64 url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt64(&gotRequests, 1) time.Sleep(sleep) }) const upto = 10 client, metrics, err := hedgedhttp.NewClientAndStats(0, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() have := atomic.LoadInt64(&gotRequests) mustTrue(t, have >= 1 && have <= upto) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustTrue(t, metrics.ActualRoundTrips() >= 2 && metrics.ActualRoundTrips() <= upto) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() <= upto) } func TestFirstIsOK(t *testing.T) { url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, 10, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) mustOk(t, err) mustEqual(t, string(body), "ok") wantEqualMetrics(t, metrics, hedgedhttp.StatsSnapshot{ RequestedRoundTrips: 1, ActualRoundTrips: 1, FailedRoundTrips: 0, CanceledByUserRoundTrips: 0, CanceledSubRequests: 0, }) } func TestBestResponse(t *testing.T) { const shortest = 20 * time.Millisecond timeouts := [...]time.Duration{30 * shortest, 5 * shortest, shortest, shortest, shortest} timeoutCh := make(chan time.Duration, len(timeouts)) for _, t := range timeouts { timeoutCh <- t } url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(<-timeoutCh) }) start := time.Now() ctx, cancel := context.WithCancel(context.Background()) defer cancel() const upto = 5 client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newCtxGetReq(ctx, url)) mustOk(t, err) defer resp.Body.Close() mustTrue(t, float64(time.Since(start)) <= float64(shortest)*2.5) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustTrue(t, metrics.ActualRoundTrips() >= upto-1 && metrics.ActualRoundTrips() <= upto) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() < upto) } func TestOriginalResponseWins(t *testing.T) { const shortest = 20 * time.Millisecond timeouts := [...]time.Duration{shortest, 30 * shortest, 5 * shortest, shortest, shortest, shortest} timeoutCh := make(chan time.Duration, len(timeouts)) for _, t := range timeouts { timeoutCh <- t } url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(<-timeoutCh) }) start := time.Now() ctx, cancel := context.WithCancel(context.Background()) defer cancel() const upto = 5 client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newCtxGetReq(ctx, url)) mustOk(t, err) defer resp.Body.Close() mustTrue(t, float64(time.Since(start)) <= float64(shortest)*2.5) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustTrue(t, metrics.ActualRoundTrips() <= 3) mustEqual(t, metrics.OriginalRequestWins(), uint64(1)) mustEqual(t, metrics.HedgedRequestWins(), uint64(0)) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() < upto) } func TestHedgedResponseWins(t *testing.T) { const shortest = 20 * time.Millisecond timeouts := [...]time.Duration{30 * shortest, 5 * shortest, shortest, shortest, shortest} timeoutCh := make(chan time.Duration, len(timeouts)) for _, t := range timeouts { timeoutCh <- t } url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(<-timeoutCh) }) start := time.Now() ctx, cancel := context.WithCancel(context.Background()) defer cancel() const upto = 5 client, metrics, err := hedgedhttp.NewClientAndStats(5*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newCtxGetReq(ctx, url)) mustOk(t, err) defer resp.Body.Close() mustTrue(t, float64(time.Since(start)) <= float64(shortest)*2.5) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustEqual(t, metrics.OriginalRequestWins(), uint64(0)) mustEqual(t, metrics.HedgedRequestWins(), uint64(1)) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() < upto) } func TestGetSuccessEvenWithErrorsPresent(t *testing.T) { var gotRequests uint64 const upto = 5 url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { idx := atomic.AddUint64(&gotRequests, 1) if idx == upto { w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("success")) mustOk(t, err) return } conn, _, err := w.(http.Hijacker).Hijack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } conn.Close() // emulate error by closing connection on client side }) client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() mustEqual(t, resp.StatusCode, http.StatusOK) respBytes, err := io.ReadAll(resp.Body) mustOk(t, err) mustEqual(t, string(respBytes), "success") mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustEqual(t, metrics.FailedRoundTrips(), uint64(upto-1)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() <= 4) } func TestGetFailureAfterAllRetries(t *testing.T) { url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { conn, _, err := w.(http.Hijacker).Hijack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } conn.Close() // emulate error by closing connection on client side }) const upto = 5 client, metrics, err := hedgedhttp.NewClientAndStats(time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) _, err = client.Do(newGetReq(url)) mustFail(t, err) wantErrStr := fmt.Sprintf(`%d errors occurred:`, upto) mustTrue(t, strings.Contains(err.Error(), wantErrStr)) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustEqual(t, metrics.FailedRoundTrips(), uint64(upto)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() <= upto) } func TestHangAllExceptLast(t *testing.T) { const upto = 5 var gotRequests uint64 blockCh := make(chan struct{}) defer close(blockCh) url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { idx := atomic.AddUint64(&gotRequests, 1) if idx == upto { time.Sleep(100 * time.Millisecond) return } <-blockCh }) client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) resp, err := client.Do(newGetReq(url)) mustOk(t, err) defer resp.Body.Close() mustEqual(t, resp.StatusCode, http.StatusOK) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustEqual(t, metrics.FailedRoundTrips(), uint64(0)) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(0)) mustTrue(t, metrics.CanceledSubRequests() < upto) } func TestCancelByClient(t *testing.T) { blockCh := make(chan struct{}) defer close(blockCh) url := testServerURL(t, func(w http.ResponseWriter, r *http.Request) { <-blockCh }) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(50 * time.Millisecond) cancel() }() const upto = 5 client, metrics, err := hedgedhttp.NewClientAndStats(10*time.Millisecond, upto, nil) mustOk(t, err) wantZeroMetrics(t, metrics) _, err = client.Do(newCtxGetReq(ctx, url)) mustFail(t, err) mustEqual(t, metrics.RequestedRoundTrips(), uint64(1)) mustEqual(t, metrics.ActualRoundTrips(), uint64(upto)) mustTrue(t, metrics.FailedRoundTrips() < upto) mustEqual(t, metrics.CanceledByUserRoundTrips(), uint64(1)) mustTrue(t, metrics.CanceledSubRequests() <= upto) } func TestIsHedged(t *testing.T) { var gotRequests int rt := testRoundTripper(func(req *http.Request) (*http.Response, error) { if gotRequests == 0 { mustFalse(t, hedgedhttp.IsHedgedRequest(req)) } else { mustTrue(t, hedgedhttp.IsHedgedRequest(req)) } gotRequests++ return nil, errors.New("just an error") }) const upto = 7 client, err := hedgedhttp.NewRoundTripper(10*time.Millisecond, upto, rt) mustOk(t, err) _, err = client.RoundTrip(newGetReq("http://no-matter-what")) mustFail(t, err) mustEqual(t, gotRequests, upto) } type testRoundTripper func(req *http.Request) (*http.Response, error) func (t testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return t(req) } func wantZeroMetrics(tb testing.TB, metrics *hedgedhttp.Stats) { tb.Helper() wantEqualMetrics(tb, metrics, hedgedhttp.StatsSnapshot{}) } func wantEqualMetrics(tb testing.TB, metrics *hedgedhttp.Stats, snapshot hedgedhttp.StatsSnapshot) { tb.Helper() if metrics == nil { tb.Fatalf("Metrics object can't be nil") } mustEqual(tb, metrics.Snapshot(), snapshot) mustEqual(tb, metrics.RequestedRoundTrips(), snapshot.RequestedRoundTrips) mustEqual(tb, metrics.ActualRoundTrips(), snapshot.ActualRoundTrips) mustEqual(tb, metrics.FailedRoundTrips(), snapshot.FailedRoundTrips) mustEqual(tb, metrics.CanceledByUserRoundTrips(), snapshot.CanceledByUserRoundTrips) mustEqual(tb, metrics.CanceledSubRequests(), snapshot.CanceledSubRequests) } func testServerURL(tb testing.TB, h func(http.ResponseWriter, *http.Request)) string { tb.Helper() server := httptest.NewServer(http.HandlerFunc(h)) tb.Cleanup(server.Close) return server.URL } func newGetReq(url string) *http.Request { req, err := http.NewRequest(http.MethodGet, url, http.NoBody) if err != nil { panic(err) } return req } func newCtxGetReq(ctx context.Context, url string) *http.Request { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { panic(err) } return req } func mustEqual(tb testing.TB, have, want interface{}) { tb.Helper() if have != want { tb.Fatalf("\nhave: %v\nwant: %v\n", have, want) } } func mustOk(tb testing.TB, err error) { tb.Helper() if err != nil { tb.Fatal(err) } } func mustFail(tb testing.TB, err error) { tb.Helper() if err == nil { tb.Fatal("want err, got nil") } } func mustTrue(tb testing.TB, b bool) { tb.Helper() if !b { tb.Fatal() } } func mustFalse(tb testing.TB, b bool) { tb.Helper() if b { tb.Fatal() } } golang-github-cristalhq-hedgedhttp-0.9.1/stats.go000066400000000000000000000067161450357556200220710ustar00rootroot00000000000000package hedgedhttp import "sync/atomic" // atomicCounter is a false sharing safe counter. type atomicCounter struct { count uint64 _ [7]uint64 } type cacheLine [64]byte // Stats object that can be queried to obtain certain metrics and get better observability. type Stats struct { _ cacheLine requestedRoundTrips atomicCounter actualRoundTrips atomicCounter failedRoundTrips atomicCounter originalRequestWins atomicCounter hedgedRequestWins atomicCounter canceledByUserRoundTrips atomicCounter canceledSubRequests atomicCounter _ cacheLine } func (s *Stats) requestedRoundTripsInc() { atomic.AddUint64(&s.requestedRoundTrips.count, 1) } func (s *Stats) actualRoundTripsInc() { atomic.AddUint64(&s.actualRoundTrips.count, 1) } func (s *Stats) failedRoundTripsInc() { atomic.AddUint64(&s.failedRoundTrips.count, 1) } func (s *Stats) originalRequestWinsInc() { atomic.AddUint64(&s.originalRequestWins.count, 1) } func (s *Stats) hedgedRequestWinsInc() { atomic.AddUint64(&s.hedgedRequestWins.count, 1) } func (s *Stats) canceledByUserRoundTripsInc() { atomic.AddUint64(&s.canceledByUserRoundTrips.count, 1) } func (s *Stats) canceledSubRequestsInc() { atomic.AddUint64(&s.canceledSubRequests.count, 1) } // RequestedRoundTrips returns count of requests that were requested by client. func (s *Stats) RequestedRoundTrips() uint64 { return atomic.LoadUint64(&s.requestedRoundTrips.count) } // ActualRoundTrips returns count of requests that were actually sent. func (s *Stats) ActualRoundTrips() uint64 { return atomic.LoadUint64(&s.actualRoundTrips.count) } // FailedRoundTrips returns count of requests that failed. func (s *Stats) FailedRoundTrips() uint64 { return atomic.LoadUint64(&s.failedRoundTrips.count) } // OriginalRequestWins returns count of original requests that were faster than the original. func (s *Stats) OriginalRequestWins() uint64 { return atomic.LoadUint64(&s.originalRequestWins.count) } // HedgedRequestWins returns count of hedged requests that were faster than the original. func (s *Stats) HedgedRequestWins() uint64 { return atomic.LoadUint64(&s.hedgedRequestWins.count) } // CanceledByUserRoundTrips returns count of requests that were canceled by user, using request context. func (s *Stats) CanceledByUserRoundTrips() uint64 { return atomic.LoadUint64(&s.canceledByUserRoundTrips.count) } // CanceledSubRequests returns count of hedged sub-requests that were canceled by transport. func (s *Stats) CanceledSubRequests() uint64 { return atomic.LoadUint64(&s.canceledSubRequests.count) } // StatsSnapshot is a snapshot of Stats. type StatsSnapshot struct { RequestedRoundTrips uint64 // count of requests that were requested by client ActualRoundTrips uint64 // count of requests that were actually sent FailedRoundTrips uint64 // count of requests that failed CanceledByUserRoundTrips uint64 // count of requests that were canceled by user, using request context CanceledSubRequests uint64 // count of hedged sub-requests that were canceled by transport } // Snapshot of the stats. func (s *Stats) Snapshot() StatsSnapshot { return StatsSnapshot{ RequestedRoundTrips: s.RequestedRoundTrips(), ActualRoundTrips: s.ActualRoundTrips(), FailedRoundTrips: s.FailedRoundTrips(), CanceledByUserRoundTrips: s.CanceledByUserRoundTrips(), CanceledSubRequests: s.CanceledSubRequests(), } }