Writing HTTP client middleware in Go

In this post, I am going to share what I have learned about writing HTTP client middleware in Go. Let’s get started!

HTTP client and Transport

Go’s http.Client defines a default value for the Transport field when one is not specified:

type Client struct {
        // Transport specifies the mechanism by which individual
        // HTTP requests are made.
        // If nil, DefaultTransport is used.
        Transport RoundTripper
        
        // other fields
}

Graphically, the role and position of http.DefaultTransport can be shown as follows:

http.Client by default uses http.DefaultTransport

The job of DefaultTransport is to send a HTTP request from your computer to the network server, over the network, over TCP.

Now, as we can see above, DefaultTransport is of type http.RoundTripper, which is an interface defined as follows:

type RoundTripper interface {
        RoundTrip(*Request) (*Response, error)
}

Now, as with any other interface, we can write our own type which implements the RoundTripper interface and use that as the Transport for a HTTP client. That is the key to writing client side HTTP middleware.

Let’s see a first example.

Writing your own RoundTripper implementation

Our first RoundTripper implementation will forward all requests to the DefaultTransport’s RoundTrip() method.

Custom RoundTripper implementation calling http.DefaultTransport.RoundTrip()

First, define a struct, let’s call it, demoRoundTripper:

type demoRoundTripper struct{}

Then, define a RoundTrip() method on the struct with the following properties:

The function body will simply call http.DefaultTransport.RoundTrip() with the *http.Request value received and return the *http.Response and error values returned.

The method definition will look as follows:

func (t *demoRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
        return http.DefaultTransport.RoundTrip(r)
}

Once you have defined the demoRoundTripper struct and a RoundTrip() method, you have a http.RoundTripper implementation.

To configure a HTTP client to use the above RoundTripper implementation, all we need to do is to set the Transport field as follows:

client := http.Client{
       Transport: &demoRoundTripper{},
}
resp, err := client.Get("https://example.com")

There is absolutely no useful purpose of writing a middleware like the above. However, what is useful though is a RoundTrip() implementation which executes other code before and after calling http.DefaultTransport.RoundTrip().

This is the pattern followed by middleware that implements logging and metrics, adds common headers or implements caching. I cover examples of using this pattern of writing middleware in my book, Practical Go - for example, middleware to add logging and a middleware that adds headers to outgoing requests. This blog post illustrates how you can implement client-side caching.

Next, we will write a roundtripper implementation to return previously configured responses and not call the remote server at all.

Returning static responses

This pattern of writing middleware is useful for writing stub or mock implementation of remote servers. One situation in which this is extremely useful is in writing tests for your application where you don’t want to interact with remote network servers.

When you invoke the client’s GET (or any other) method, the following steps occur in such a mdidleware:

  1. The RoundTrip() method of the custom roundtripper implementation is invoked.
  2. This method doesn’t call http.DefaultTransport.RoundTrip(). Hence, the remote request never gets the request.
  3. Instead, it creates and returns a *http.Response value itself, with a nil error value
    • If the roundtripper wants to abort the request, it can return a nil *http.Response value and non-nil error value.

Graphically, it works as follows:

Custom RoundTripper implementation returning static responses

Let’s see an example of such a roundtripper implementation.

We define a demoRoundTripper struct and a RoundTrip() method on it as earlier:

type demoRoundTripper struct{}

func (t *demoRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
        // roundtripper logic goes here
}

Inside the RoundTrip() method, based on the outgoing request’s URL, we can choose to either return a static response or call the http.DefaultTransport.RoundTrip() method, as follows:

        switch r.URL.String() {
        case "https://github.com":
                responseBody := "This is github.com stub"
                respReader := io.NopCloser(strings.NewReader(responseBody))
                resp := http.Response{
                        StatusCode:    http.StatusOK,
                        Body:          respReader,
                        ContentLength: int64(len(responseBody)),
                        Header: map[string][]string{
                                "Content-Type": {"text/plain"},
                        },
                }
                return &resp, nil

        case "https://example.com":
                return http.DefaultTransport.RoundTrip(r)

        default:
                return nil, errors.New("Request URL not supported by stub")
        }
}

The above roundtripper implementation will exhibit the following behavior:

When we configure a HTTP client with the above roundtripper implementation and send HTTP GET request to https://github.com, you will get the statically configured response as follows:

2022/09/20 06:24:03 Sending GET request to: https://github.com
2022/09/20 06:24:03 This is github.com stub

If we send a HTTP GET request for https://example.com, you will see the response that example.com will send back:

2022/09/20 06:24:03 Sending GET request to: https://example.com
2022/09/20 06:24:04 <!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

If we send a HTTP GET request for any other URL, you will get an error:

2022/09/20 06:24:03 Sending GET request to: https://github.com/api
2022/09/20 06:24:03 Get "https://github.com/api": Request URL not supported by stub

You can find a runnable example here.

As you completely control what happens to the outgoing request, your roundtripper implementation can implement logic based on the request type - such as GET or POST, send redirect responses back, drop requests completely to simulate failure scenarios and such. Now of course, we don’t want to make our stub too complicated, but it should be just enough for our current requirements.

It’s also worth pointing out that you should make sure you refer to the documentation of RoundTripper interface as to what you should or should not do in your implementation:

// copied from:https://pkg.go.dev/net/http#RoundTripper

// RoundTrip should not attempt to interpret the response. In
// particular, RoundTrip must return err == nil if it obtained
// a response, regardless of the response's HTTP status code.

// RoundTrip should not attempt to
// handle higher-level protocol details such as redirects,
// authentication, or cookies.
//
// RoundTrip should not modify the request, except for
// consuming and closing the Request's Body. RoundTrip may
// read fields of the request in a separate goroutine. Callers
// should not mutate or reuse the request until the Response's
// Body has been closed.
//
// RoundTrip must always close the body, including on errors,
// but depending on the implementation may do so in a separate
// goroutine even after RoundTrip returns. This means that
// callers wanting to reuse the body for subsequent requests
// must arrange to wait for the Close call before doing so.
//

Summary

In this post, we saw how we can write HTTP client middleware in Go. We started off writing the simplest HTTP client middleware by forwarding the request to the remote server and then focused on writing middleware that can be used to implement stubs for remote network services.

Hope you found the post useful and I will end this post with references to the key standard library documentation: