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.