Skip to content

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:

jar, err := cookiejar.New(nil)
client := httpclient.NewClient()
client.Jar = jar

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:

body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))

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.