HTTP Client

> Instrumented, secure-by-default outbound HTTP client with APM events and trace propagation.

The httpclient package wraps net/http and dispatches APM events on every outbound request. Latency, status, and body sizes flow into your observability stack automatically. Its defaults are hardened: TLS 1.2 minimum, a capped redirect chain that strips credentials on cross-origin hops, an SSRF dial guard, a response-body size cap, and per-stage transport timeouts (see Secure defaults).

Import path: github.com/velocitykode/velocity/httpclient

Creating a client

c := httpclient.New()

Options:

c := httpclient.New(
    httpclient.WithBaseURL("https://api.example.com"),
    httpclient.WithTimeout(10 * time.Second),
    httpclient.WithHTTPClient(&http.Client{ /* custom transport */ }),
)

Default timeout is 30 seconds. The default transport caps redirect chains at 10 hops; tighten or disable that with WithMaxRedirects (see Secure defaults).

Methods

All methods take a context.Context as the first argument so that cancellation, deadlines, and trace IDs propagate through the call:

ctx := r.Context()

resp, err := c.Get(ctx, "/users/42")
if err != nil {
    return err
}
defer resp.Body.Close()

// POST with JSON
body := strings.NewReader(`{"name":"alice"}`)
resp, err := c.Post(ctx, "/users", "application/json", body)

// PUT, PATCH, DELETE also available
resp, err := c.Put(ctx, "/users/42", "application/json", body)
resp, err := c.Patch(ctx, "/users/42", "application/json", body)
resp, err := c.Delete(ctx, "/users/42")

For full control, build an *http.Request yourself and call Do:

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/search", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.URL.RawQuery = "q=velocity"

resp, err := c.Do(ctx, req)

Base URL resolution

When WithBaseURL(...) is set, relative paths starting with / are resolved against the base URL. Absolute URLs are used as-is:

c := httpclient.New(httpclient.WithBaseURL("https://api.example.com"))

c.Get(ctx, "/users")                    // → https://api.example.com/users
c.Get(ctx, "https://other.example/x")   // → https://other.example/x

Events

Every call dispatches one of two events through the application’s event dispatcher:

  • *httpclient.RequestSent - fired on every successful response
  • *httpclient.RequestFailed - fired on transport errors
type RequestSent struct {
    Context      context.Context
    Method       string
    URL          string
    StatusCode   int
    DurationMs   int64
    RequestSize  int64
    ResponseSize int64
    TraceID      string
    SpanID       string
    ParentID     string
}

type RequestFailed struct {
    Context    context.Context
    Method     string
    URL        string
    Error      string
    DurationMs int64
    TraceID    string
    SpanID     string
    ParentID   string
}

Trace IDs are read from the context via the trace package - if your request came in through Velocity’s middleware, the outgoing call automatically carries the incoming trace.

Wiring a dispatcher

c := httpclient.New()
c.SetEventDispatcher(func(ctx context.Context, event interface{}) error {
    return v.Events.Dispatch(event)
})

The dispatcher receives the request-scoped context.Context so listeners can observe request-scoped values; the event is one of the types above.

Once wired, APM agents subscribing to http.request.sent and http.request.failed events will record every outbound request.

Secure defaults

httpclient.New() is hardened out of the box. The defaults apply on the framework-built transport; when you supply your own via WithHTTPClient, transport-level fields stay under your control (the TLS minimum and dial guard are still applied in place when your transport is a plain *http.Transport).

ProtectionDefaultTune with
Minimum TLS versionTLS 1.2WithMinTLSVersion(tls.VersionTLS13)
Redirect cap10 hopsWithMaxRedirects(n) (n <= 0 disables redirect following)
Cross-origin credential strippingAuthorization, Cookie, Proxy-Authorization removed when an eTLD+1 host changes (or on an https to http downgrade)always on
SSRF dial guardrefuses loopback, RFC1918, link-local, CGNAT, and cloud-metadata IPs (IPv4 + IPv6); resolved IP is pinned to defeat DNS rebindingWithAllowedHosts(...), WithoutPrivateIPDeny()
Env proxyHTTP_PROXY/HTTPS_PROXY/NO_PROXY ignored while the dial guard is onWithProxyAllowed()
Response body cap32 MiB; reads past it return ErrResponseTooLargeWithMaxResponseBytes(n) (n <= 0 disables)
TLS handshake timeout10sWithTLSHandshakeTimeout(d)
Response header timeout30sWithResponseHeaderTimeout(d)
Idle connection timeout90sWithIdleConnTimeout(d)
Expect-continue timeout1sWithExpectContinueTimeout(d)

The SSRF gate runs both at the URL-host level (so it covers proxy mode) and at dial time. To reach a known internal service while still blocking everything else, allowlist its eTLD+1 host:

c := httpclient.New(httpclient.WithAllowedHosts("internal.svc.cluster.local"))

The response cap is enforced on the read stream, not the Content-Length header, so a server that lies about its length cannot bypass it. When the cap is hit, the read returns httpclient.ErrResponseTooLarge:

resp, err := c.Get(ctx, "/big")
if err != nil {
    return err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if errors.Is(err, httpclient.ErrResponseTooLarge) {
    // payload exceeded WithMaxResponseBytes
}
WithoutPrivateIPDeny() disables the SSRF guard entirely, and WithProxyAllowed() re-enables honouring proxy environment variables. Use them only for tests or trusted egress paths. Prefer WithAllowedHosts for a targeted exception.

Shutdown

Shutdown closes idle keep-alive connections held by the underlying transport, honouring the context deadline. In-flight requests are not cancelled, so drive that through the request context:

if err := c.Shutdown(ctx); err != nil {
    return err
}

Design notes

  • One call = one event. No sampling inside the client. Filter in listeners if volume is a concern.
  • No retries. httpclient doesn’t retry failed requests; compose a retry loop in your caller when you need it, or plug in a custom *http.Client via WithHTTPClient.
  • No middleware chain. Wrap the client if you need cross-cutting behavior (auth headers, circuit breakers) rather than extending it.