What httpclient does not do¶
httpclient is a factory for one thing: a hardened *http.Client. A good deal of what
people expect from an HTTP library is deliberately absent, either because it belongs to
another module or because it was decided against. This page lists what is missing and what
to do instead, so an absence reads as a decision rather than an oversight.
HTTP/2 is not negotiated¶
A client from NewClient() speaks HTTP/1.1, even to a server that offers HTTP/2. The
plain http.DefaultClient negotiates HTTP/2 to the same server; this one does not.
That is a consequence of hardening rather than a choice made against HTTP/2. net/http
disables its automatic HTTP/2 upgrade as soon as a transport carries a custom
TLSClientConfig or DialContext, unless ForceAttemptHTTP2 is set — and NewTransport
sets both a hardened TLS config and a dialer, without setting ForceAttemptHTTP2.
If a downstream requires HTTP/2 — gRPC-over-HTTP/2, or a server that rejects HTTP/1.1 —
this module cannot reach it as configured. Build the transport yourself and pass it to
WithTransport:
tr := httpclient.NewTransport(nil) // hardened TLS, limits and timeouts
tr.ForceAttemptHTTP2 = true
client := httpclient.NewClient(httpclient.WithTransport(tr))
There is no option for it, and the redirect policy and timeout are unaffected either way.
There are no configuration keys and no environment variables¶
Nothing here reads a config file, a YAML key or an environment variable of its own. Every
setting is a functional option passed in code, and the only environment variables that
reach the client at all are the standard proxy ones net/http reads
(HTTP_PROXY, HTTPS_PROXY, NO_PROXY — see
defaults and limits).
A question of the form "which config key sets the client timeout?" has no answer here. A
service that wants its client configurable reads its own configuration — with
go/config, for instance — and passes the values to
NewClient as options. That keeps this module free of a configuration dependency, which is
the point of it being framework-free.
It does not retry POST, or anything else non-idempotent¶
With WithRetry enabled, a POST that comes back 503 is returned to you after a single
attempt. Only the RFC 9110 idempotent methods — GET, HEAD, OPTIONS, PUT, DELETE — are
retried on a retryable response, because replaying a POST can duplicate a side effect the
server already performed.
The exception is a failure that provably never reached the origin, such as a refused connection: nothing was processed, so any method is safe to replay and is retried.
To retry non-idempotent requests anyway, set RetryAllMethods or list the methods in
RetryableMethods on the RetryConfig — and only where every such request carries an
idempotency key or is otherwise safe to repeat. The rule and the escape hatches both belong
to go/transit, which owns the retry transport.
It does not manage cookies or sessions¶
Client.Jar is left nil, so Set-Cookie responses are discarded and no cookie is ever
sent automatically. A login flow that depends on a session cookie surviving a redirect will
not work out of the box.
Assign a jar yourself if you need one:
Note what that does to the credential protection: a cookie in a jar is applied by the
client on every hop by host, which is a different mechanism from the header stripping
WithSensitiveHeaders performs.
It does not bound the response body¶
No option limits how much a server may send. ResponseHeaderTimeout bounds the wait for
response headers, and Timeout bounds the whole exchange, but a server that streams
steadily within the timeout can return an arbitrarily large body straight into whatever
reads it.
Wrap the body when the size matters:
It does not present a client certificate for you¶
There is no mTLS option. WithCertPool sets the roots the client verifies against, which
is the other direction — it does not make the client identify itself.
To present a client certificate while keeping the hardened settings, apply a go/tls
Pair to a copy of the default config and pass that:
cfg := gtls.DefaultConfig()
pair := gtls.Pair{Enabled: true, Cert: "/etc/pki/client.pem", Key: "/etc/pki/client.key"}
if err := pair.ApplyTo(cfg); err != nil {
return err
}
client := httpclient.NewClient(httpclient.WithTLSConfig(cfg))
ApplyTo appends the certificate and leaves the minimum version, cipher suites and curve
preferences of the hardened config intact.
It does not trace or measure requests¶
No spans, no metrics, no request IDs. go/transit supplies OpenTelemetry middleware for
the server side; there is no client equivalent wired in here, and WithRequestLogging
logs at debug level only.
Client-side instrumentation goes in the middleware chain, where it wraps the hardened transport instead of replacing it:
client := httpclient.NewClient(
httpclient.WithClientMiddleware(transithttp.NewClientChain(
func(next http.RoundTripper) http.RoundTripper {
return otelhttp.NewTransport(next)
},
)),
)
Any func(http.RoundTripper) http.RoundTripper is a valid chain member, so this works for
a recorder, a header injector, or anything else shaped like a round-tripper.
It is not the server half, and it is not a request library¶
Serving HTTP, gRPC, the gateway, health and lifecycle control are not here and never will
be — a consumer that only makes outbound calls should not link them. They live in
go/transit, go/controls and
go-tool-base. A depfootprint_test.go
guard fails the build if any of them appears in this module's dependency graph.
Nor is this a convenience wrapper: there is no Get(url, &out), no JSON encoding, no URL
builder, no pagination. NewClient returns a stock *http.Client and you use net/http
directly, which is why a client from here can be handed to any library that accepts one.
It offers no post-quantum key exchange¶
The TLS configuration lists X25519 and P-256 explicitly as its curve preferences. Go's own default in 1.26 also offers the hybrid post-quantum groups (X25519MLKEM768 and the SecP variants), and an explicit list opts out of them, including any future additions to Go's default.
That list belongs to go/tls's DefaultConfig, so it is
the place to change it for every consumer. To opt in for one client, copy the default
config, set CurvePreferences yourself, and pass it to WithTLSConfig.
One client is one connection pool¶
NewClient builds a fresh *http.Transport on every call, and a transport owns its
connection pool. Two clients built from identical options share nothing.
Build one client per downstream service and reuse it for the process's lifetime. A client
created per request re-dials and re-handshakes every time and holds idle connections open
until IdleConnTimeout closes them, which is slower and heavier than the standard library
at its defaults, not safer.