HTTPClient on the Factory as a lazy closure with tuned timeouts
byob-http-client.2
factory-dihttp
Problem: http.DefaultClient has no timeouts — a hung TCP handshake
hangs forever regardless of ctx.Done(). A separately configured
client shared across commands avoids this, but constructing it
eagerly in main() costs time for commands that never make a
request.
Idea: put the client construction behind a lazy closure on the
Factory (byob-factory-di.1, byob-config.3). f.HTTPClient() (*http.Client, error); sync.OnceValues caches the result (two-return, so
OnceValues, not OnceValue). The client itself
wraps a *http.Transport with explicit timeouts — DialContext,
TLSHandshakeTimeout, ResponseHeaderTimeout,
ExpectContinueTimeout, IdleConnTimeout — rather than relying on
DefaultTransport (which has some, but not ResponseHeaderTimeout).
Per-request cancellation still flows through ctx, but the transport
timeouts catch cases where ctx alone can't (network-level hangs
during handshake).
Gzip: do not set Accept-Encoding manually on outgoing requests.
stdlib's http.Transport.DisableCompression defaults to false, which
means the transport auto-sets Accept-Encoding: gzip and
auto-decompresses response bodies — you get transparent gzip for
free. Setting the header explicitly disables the auto-decompress
path and you'll read a compressed body as if it were plain text.
Response-body size limits: callers reading untrusted response bodies
should wrap the reader with http.MaxBytesReader(nil, resp.Body, maxBytes) (or io.LimitReader for the non-HTTP-server case) before
io.ReadAll. A malicious or buggy endpoint returning a multi-GB
body can OOM the client otherwise. Pick a ceiling per call site —
no single default fits both "paginated list of items" and "fetch
artifact binary".
Tradeoffs: a private transport means the client isn't equivalent to
http.DefaultClient — users who grab the client for ad-hoc scripts
get different timeout behavior than stdlib. That's the point, but
worth a docstring.
Design
func newHTTPClient(ua string) *http.Client {
tp := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
return &http.Client{
Transport: buildTransport(ua, tp),
Timeout: 0, // use per-request ctx, not a client-wide timeout
}
}
// f.HTTPClient is sync.OnceValues-wrapped so the first caller pays
// the construction cost, everyone else gets the cached client.
Client-wide Timeout stays at 0 deliberately: operations with
streaming bodies (downloads, large uploads) would otherwise be
killed mid-transfer. Use ctx per-request to bound total time.