Skip to content

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

go get gitlab.com/phpboyscout/go/httpclient

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:

200 OK HTTP/1.1

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.Timeout bounds 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 with context 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:

client := httpclient.NewClient(
    httpclient.WithSensitiveHeaders("PRIVATE-TOKEN"),
)

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