Search code examples
httpgoservertimeoutclient

Retry http request RoundTrip


I made a server with client hitting throught http. I set retry mechanism in the client inside its Transport's RoundTripper method. Here's the example of working code for each server and client:

server main.go

package main

import (
    "fmt"
    "net/http"
    "time"
)

func test(w http.ResponseWriter, req *http.Request) {
    time.Sleep(2 * time.Second)
    fmt.Fprintf(w, "hello\n")
}

func main() {
    http.HandleFunc("/test", test)
    http.ListenAndServe(":8090", nil)
}

client main.go

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"
)

type Retry struct {
    nums      int
    transport http.RoundTripper
}

// to retry
func (r *Retry) RoundTrip(req *http.Request) (resp *http.Response, err error) {
    for i := 0; i < r.nums; i++ {
        log.Println("Attempt: ", i+1)
        resp, err = r.transport.RoundTrip(req)
        if resp != nil && err == nil {
            return
        }
        log.Println("Retrying...")
    }
    return
}

func main() {
    r := &Retry{
        nums:      5,
        transport: http.DefaultTransport,
    }

    c := &http.Client{Transport: r}
    // each request will be timeout in 1 second
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8090/test", nil)
    if err != nil {
        panic(err)
    }
    resp, err := c.Do(req)
    if err != nil {
        panic(err)
    }
    fmt.Println(resp.StatusCode)
}

What's happening is the retry seems only work for first iteration. For subsequent iteration it doesn't wait for one second each, instead the debugging message printed for as much as the retry nums.

I expect the retry attempt to be waiting 1 second each as I put the timeout for 1 second in the context. But it seems only wait for 1 second for whole retries. What do I miss?

Beside, how to stop server from processing timeout request?, I saw CloseNotifier already deprecated.


Solution

  • The problem is with the context. Once the context is done, you cannot reuse the same context anymore. You have to re-create the context at every attempt. You can get the timeout from parent context, and use it to create new context with it.

    func (r *retry) RoundTrip(req *http.Request) (resp *http.Response, err error) {
        var (
            duration time.Duration
            ctx      context.Context
            cancel   func()
        )
        if deadline, ok := req.Context().Deadline(); ok {
            duration = time.Until(deadline)
        }
        for i := 0; i < r.nums; i++ {
            if duration > 0 {
                ctx, cancel = context.WithTimeout(context.Background(), duration)
                req = req.WithContext(ctx)
            }
            resp, err = r.rt.RoundTrip(req)
            ...
            // the rest of code
            ...
        }
        return
    }
    

    This code will create new fresh context at every attempt by using the timeout from its parent.