You publish a Go SDK for partners: should it own its *http.Client, accept one, and cap MaxConnsPerHost?
answer
- Who is paged when the limit is wrong?
- Good defaults plus an escape hatch
- A process-global you must never touch
- A hard connection cap blocks rather than sheds
- Enforcement belongs where you can change it
basics
~20 sOwn a sensible default client per SDK instance, but let callers replace it. Pooling limits you hard-code become a throughput ceiling your partners cannot lift, so document the defaults, keep them changeable, and never touch http.DefaultTransport or http.DefaultClient.
solid answer
~50 sMy default is: the SDK constructs one `*http.Client` per SDK client value, keeps it for the value's lifetime, and exposes an option to supply a different one. Owning a default matters because most partners will never tune anything and the failure mode of no default is a transport built per call. Accepting one matters because the caller owns their environment — proxies, corporate TLS roots, instrumentation wrappers, and their own capacity plan. On `MaxConnsPerHost` I would set no cap by default and size `MaxIdleConnsPerHost` generously instead, because a hard connection cap silently converts overload into blocked requests inside my library, and the partner cannot raise it without shipping a new version of my SDK. Whatever I choose becomes part of the contract: the defaults get documented, changing them is a behaviour change worth a release note, and the SDK never mutates the process-global `http.DefaultTransport`.
code
go · 16 linestype Option func(*Client)
func WithHTTPClient(c *http.Client) Option {
return func(sdk *Client) { sdk.http = c }
}
func New(opts ...Option) *Client {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 200
t.MaxIdleConnsPerHost = 64
sdk := &Client{http: &http.Client{Transport: t, Timeout: 10 * time.Second}}
for _, o := range opts {
o(sdk)
}
return sdk
}go deeper
Know the two safe habits behind this decision: an SDK creates its HTTP client once, and a library never edits the process-wide http.DefaultTransport or http.DefaultClient.
Be able to compare the API shapes — hidden client, required client, default plus override, RoundTripper hook — and say what each one costs a caller who has a proxy or custom TLS.
Argue the operational consequence of a hard MaxConnsPerHost: it blocks rather than sheds, inside a binary you cannot redeploy, and it hides in latency graphs as an SDK hang.
Own the contract: which defaults you publish, how a change to them is released, and why quota enforcement belongs on the server where you can change it rather than in a partner's copy of your library.
## What is actually being decided The question looks like configuration and is really about who owns capacity. An HTTP client value in a Go SDK bundles three things: transport policy (pooling, proxying, TLS), per-call policy (timeouts, redirects), and observability hooks. Every one of those is something the caller may legitimately need to control, and every one of them is something most callers will never think about. The API shape decides who is on the hook when a limit turns out to be wrong at three in the morning. ## The four shapes, and what each costs **1. The SDK owns a client and hides it.** Simplest surface, best defaults for the median partner, and completely rigid: a partner behind a proxy, or one who needs a client-certificate chain, or one who wants request instrumentation, is blocked until you ship a release. Rigid also means *your* pool sizing is their capacity plan. **2. The SDK accepts a `*http.Client` and requires it.** Maximum control, and it pushes a decision onto everyone, including the partner who just wants to make an API call. Most will pass `http.DefaultClient`, which has no timeout at all, and you have converted a good default into a bad one. **3. Own a default, allow replacement.** Construct a configured client in the constructor and offer `WithHTTPClient(*http.Client)` as an option. This is the shape most Go SDKs converge on, and the one I would ship. The default protects the majority; the escape hatch keeps the minority from forking. **4. Accept a `http.RoundTripper` instead.** Finer-grained: the caller wraps transport behaviour while you keep control of timeouts and redirects. It composes nicely with instrumentation middleware, but it does not let a caller change the client-level timeout, so it is usually offered *alongside* option 3 rather than instead of it. ## Defaults worth choosing deliberately - **One client per SDK client value, not per call and not one package-level global.** Per call destroys pooling. A package-level global couples two SDK instances that a partner may deliberately have pointed at different environments with different credentials. - **A clone of `http.DefaultTransport`**, not a bare `&http.Transport{}`, so the caller keeps proxy-from-environment, HTTP/2 negotiation and sane handshake timeouts unless you consciously change them. - **`MaxIdleConnsPerHost` raised** from its effective default of two to something matching realistic concurrency against your service, with `MaxIdleConns` raised alongside it. This is the setting partners will never discover and most need. - **`MaxConnsPerHost` left at zero (no limit) by default.** A non-zero value does not shed load, it *blocks* requests waiting for a free connection. Blocking inside a third-party library, on a limit the partner did not choose and cannot change, is a support incident that looks like your SDK hanging. If you must protect the service, do it at the service with a real rejection, not in a client library. ## Where the organisational constraint bites The counter-argument is real and comes from the platform team: one partner's unbounded client can saturate a shared downstream, and they may want the SDK to hold the line. Two things resolve that tension. First, **client-side limits are advisory** — they exist in a binary you do not run, and a partner can always bypass them by supplying their own client. Anything that must hold under adversarial load belongs server-side: connection limits at the edge, per-tenant rate limits with a `429` and a retry hint. The SDK's limits are there to make the well-behaved case efficient, not to enforce policy. Second, **make the defaults legible and moveable**. Document the exact values in the package documentation, expose them as fields on an options struct, and treat a change to them as a behaviour change that gets a release note — because a partner who tuned around your old default will be surprised by the new one. If the platform team needs a different posture, the lever is a documented option plus guidance, not a silently lowered constant. ## The rules that are not negotiable - **Never mutate `http.DefaultTransport` or `http.DefaultClient`.** They are process-global and shared with every other package in the partner's binary. A library that sets a timeout or a pool size on them changes behaviour for code it has never seen. This is the single worst thing an SDK can do to a Go process. - **Never call `CloseIdleConnections` on a client you did not construct.** If the caller supplied it, they may be sharing it with other work. - **Do not construct a transport per call anywhere in the SDK**, including in convenience helpers, and say so in the contributor documentation, because this is exactly the defect that reappears in a helper someone adds later. - **Expose reuse as an observable.** A documented way to attach `httptrace` or a `RoundTripper` wrapper lets a partner diagnose pooling in their own environment without opening a ticket with you. ## How I would defend the call Defaults for the median partner, an escape hatch for the rest, no process-global side effects, and no client-side cap pretending to be an enforcement mechanism. If a partner saturates a shared dependency, that is a server-side control and a conversation about quota — not a constant hidden in my library.
- Why not set a conservative MaxConnsPerHost in the SDK to protect your service?Because reaching it blocks requests inside your library rather than rejecting them, so the partner sees unexplained latency and calls it an SDK hang. The limit also lives in a binary you cannot redeploy, and any partner can bypass it by supplying their own client. Real protection is a server-side connection limit and a rate limit that returns a status code.
- If the SDK accepts a caller-supplied *http.Client, what should it stop doing?Stop mutating it. Do not overwrite `Timeout`, do not swap `Transport`, and do not call `CloseIdleConnections` on it, because the caller may be sharing that client with other work. Read its settings if you must, document what you assume, and put any behaviour you need into your own request construction instead.
- How do you change a pooling default in an SDK without breaking partners?Treat it as a behaviour change: state the old and new values in the release notes, explain the workload the new value assumes, and make sure the option that overrides it existed in the previous release so a partner can pin the old behaviour without downgrading. Silent constant changes are how a partner's capacity plan breaks in a patch release.
- Should the SDK expose a *http.RoundTripper hook as well as the client?It is worth offering when partners need to wrap transport behaviour — instrumentation, header injection, an internal proxy — while keeping your timeout and redirect policy. It is a narrower, safer hook than handing over the whole client, so offering both lets most partners take the small one.
saying these in an interview costs you the question
- Mutates http.DefaultTransport or http.DefaultClient from the library
- Hard-codes pooling limits with no way for callers to override
- Requires every caller to supply a client, so most pass http.DefaultClient
- Treats a client-side connection cap as enforcement of a quota
- Creates the SDK's http.Client per API call
- Changes a default pool size in a patch release with no note