Sad story of http.DefaultTransport

Even if you're an experienced Go developer, this expression might make you raise an eyebrow for a moment:

http.DefaultTransport.(*http.Transport)

Interestingly, it shows a couple of not-so-common features in Go's standard library. Let's review them.

http.DefaultTransport

Packages in Go mainly consist of:

  • Functions, like http.StatusText(), which gives the text description for a numeric HTTP status code.
  • Types, such as http.Client, which is used to make HTTP requests.
  • Constants, like http.StatusOK, which equals 200 and means the request was successful.

Less commonly, packages export variables. Typically, these are errors, like http.ErrNotSupported. But sometimes, they are something else.

http.DefaultTransport is one of those rare package variables that isn't an error. Basically, it’s an implementation of the HTTP protocol (called "transport") with default settings.

Since the transport in the http package is represented by the Transport type, it makes sense to define DefaultTransport like this:

var DefaultTransport = &Transport{
    // default settings
}

But in reality, it looks like this:

var DefaultTransport RoundTripper = &Transport{
    // default settings
}

Although DefaultTransport is in fact a pointer to a Transport value, it's declared as a RoundTripper interface. We'll look into this mystery a bit later.

http.RoundTripper

RoundTripper is an interface with a single RoundTrip method:

type RoundTripper interface {
    // RoundTrip executes a single HTTP transaction,
    // returning a Response for the provided Request.
    RoundTrip(*Request) (*Response, error)
}

The http.Client type delegates the actual request execution to its Transport property, which is of type RoundTripper:

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

This is flexible and convenient. To change how the transport works, we just need to implement the RoundTrip method in a custom type:

type DummyTransport struct{}

func (t *DummyTransport) RoundTrip(*http.Request) (*http.Response, error) {
    return nil, errors.New("not implemented")
}

Then the Client will use it:

func main() {
    c := &http.Client{}
    c.Transport = &DummyTransport{}
    resp, err := c.Get("http://example.com")
    fmt.Println(resp, err)
}
<nil> Get "http://example.com": not implemented

So, why is DefaultTransport declared as a RoundTripper? We could have just declared it as *Transport and used it wherever a RoundTripper is needed — since *Transport implements the RoundTripper interface, that's okay in Go.

Why RoundTripper?

Why is DefaultTransport declared as a RoundTripper interface instead of a specific *Transport type, even though it actually holds a *Transport value?

var DefaultTransport RoundTripper = &Transport{...}

You won't find an answer in the official documentation, so here's my take.

First, it clearly shows that DefaultTransport is what the Client expects in its Transport field — a RoundTripper. But, since *Transport implements the RoundTripper interface, this isn't really necessary — it just makes the intention clearer.

Second, it serves as a safety check. If *Transport no longer meets the RoundTripper interface, the DefaultTransport declaration won't compile. It's also not strictly necessary — the client already uses DefaultTransport as RoundTripper in its transport method:

func (c *Client) transport() RoundTripper {
	if c.Transport != nil {
		return c.Transport
	}
	return DefaultTransport
}

If at any point *Transport stops matching the RoundTripper interface, the code won't compile, no matter how DefaultTransport is declared. But, of course, it's more reliable to check the type in the DefaultTransport declaration — who knows what might happen to the transport method in the future.

Finally, by making DefaultTransport an interface, the stdlib developers left themselves the option to replace the implementation in the future. For example, they could create a new MagicTransport instead of Transport without breaking backward compatibility.

But in reality, this will never happen.

Transport leak

DefaultTransport is a classic example of a leaked abstraction.

In many codebases, DefaultTransport is cast to *Transport to set up the transport options. This is often done with an unsafe type assertion (there's even an example of this in the stdlib documentation):

t := http.DefaultTransport.(*http.Transport).Clone()
// Now you can change transport properties.
t.TLSHandshakeTimeout = time.Second
t.DisableKeepAlives = true

If the stdlib developers replace the implementation of DefaultTransport with a different type, this code will panic. So, in practice, such changes won't happen.

I think it was not a great idea to declare DefaultTransport as a RoundTripper. It would be better if it were *http.Transport, so we wouldn't have to do type assertions all the time. If the stdlib developers wanted an interface guard for *Transport, they could just state it separately:

var _ RoundTripper = (*Transport)(nil)

Final thoughts

To improve the situation, it would be helpful to have a http.NewDefaultTransport function that returns a specific type:

func NewDefaultTransport() *Transport {
    return &Transport{...}
}

var DefaultTransport RoundTripper = NewDefaultTransport()

Then we can just call NewDefaultTransport() instead of the messy http.DefaultTransport.(*http.Transport).Clone().

However, the Go team shows no intention of implementing this proposal.

Oh well.

──

P.S. Want to learn more about Go? Check out my book on concurrency

★ Subscribe to keep up with new posts.