Getting started¶
By the end of this you'll have a hardened HTTP client making a real request, retrying a
downstream that fails, and refusing to leak a credential across a redirect. It takes about
ten minutes and needs a network connection and the Go toolchain named in the module's
go.mod — currently Go 1.26.5.
Everything here is code — there's no configuration file to write and no environment variable to set.
Install the module¶
That brings go/tls, go/transit and transit's small transitive set with it. What it will never bring is go-tool-base, the server stack, the gRPC SDK, a CLI or config library, or a cloud SDK — a guard test in the module fails the build if any of them appears in the dependency graph.
Make a request through a hardened client¶
NewClient returns an ordinary *http.Client. You use it exactly as you'd use the
standard library's, but it arrives secured:
package main
import (
"fmt"
"gitlab.com/phpboyscout/go/httpclient"
)
func main() {
client := httpclient.NewClient()
resp, err := client.Get("https://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status, resp.Proto)
}
Run it and you'll see:
You didn't opt in to anything, and that client already enforces a TLS 1.2 floor with curated cipher suites, bounded connection pools, a 30-second overall timeout, and a redirect policy that caps the chain and refuses an HTTPS→HTTP downgrade. The full list is in defaults and limits.
The HTTP/1.1 in that output is not a fluke — this client doesn't negotiate HTTP/2, and
the limitations page explains why
and what to do if you need it.
Build one client and keep it. Each NewClient call creates its own connection pool, so a
client per request costs you a fresh handshake every time.
Retry a downstream that's having a bad day¶
429s, 502s and dropped connections are normal against real services. WithRetry installs
the retry transport from go/transit — exponential backoff with full jitter, honouring
Retry-After:
import (
"time"
transithttp "gitlab.com/phpboyscout/go/transit/http"
)
client := httpclient.NewClient(
httpclient.WithTimeout(2*time.Minute),
httpclient.WithRetry(transithttp.DefaultRetryConfig()),
)
DefaultRetryConfig() gives you three retries, backing off from 500ms up to 30s, on 429,
502, 503 and 504.
Two things bite here, so set them up front rather than discovering them:
- The timeout covers all of it.
Client.Timeoutbounds the whole exchange including the backoff waits, and the default is 30 seconds — less than a single maximum backoff. That's why the example above raises it. Leave it at the default and a retrying request can die mid-backoff withcontext deadline exceeded (Client.Timeout exceeded while awaiting headers). - POST isn't retried. Only GET, HEAD, OPTIONS, PUT and DELETE are replayed on a retryable response, because repeating a POST can repeat whatever it did. A refused connection is retried whatever the method, because nothing reached the server.
The config is a go/transit type because the retry behaviour lives in that module; this
factory wires it into the client's transport.
Stop a token following a redirect¶
Say you fetch an artefact from an internal API with a PRIVATE-TOKEN header, and the API
302s you to object storage on another host. Go strips Authorization and Cookie on a
cross-host redirect — but not a custom header, which it copies to every hop. Your token
goes to the storage host.
Name the header and it stops:
Redirects that stay on the same scheme, host and port keep the header, so an internal
/download → /asset redirect still works. Anything that leaves loses it, and the hop
goes out unauthenticated — which is what a pre-signed storage URL wants anyway.
It's off unless you ask for it, because a client following authenticated redirects inside one trust domain would otherwise break.
Talk to a service behind a private CA¶
For an internal service whose certificate chains to your own CA, add the CA to the roots the client verifies against:
import (
"crypto/x509"
"os"
)
pool, err := x509.SystemCertPool()
if err != nil {
return err
}
pem, err := os.ReadFile("/etc/pki/internal-ca.pem")
if err != nil {
return err
}
pool.AppendCertsFromPEM(pem)
client := httpclient.NewClient(httpclient.WithCertPool(pool))
Start from x509.SystemCertPool(), as above, unless you mean to trust nothing else. The
pool you pass replaces the system trust store rather than adding to it, so a pool
holding only your internal CA gives you a client that fails every public HTTPS call with
x509: certificate signed by unknown authority. The hardened cipher suites and TLS floor
survive either way.
Where to go next¶
- Compose client middleware — stack retry, the circuit breaker, credentials and logging in the right order.
- Client options — every option, its default, and what happens when the value is wrong.
- What httpclient does not do — the absences worth knowing about before you design around one.
- Hardened defaults & the factory model — why the factory and the middleware are separate modules.