Skip to content

Core Concepts

Understanding the following concepts will give you a quick, holistic grasp of HTTPC.

Core Building Blocks at a Glance

ComponentResponsibilityKey Points
Client (interface)Executes requests; manages the connection pool and lifecycleCreated via New(cfg) / NewDefault(); 7 verb methods + Request + Download + Close
Doer (interface)Minimal request interfaceA single Request(ctx, method, url, opts...) method; used for mocks and custom implementations
RequestOption (With* functions)Builds a single requestFunctional options; applied in the order passed; the first failure aborts the request immediately
MiddlewareFunc / HandlerMiddleware and terminal handlerOnion model; composed with Chain(mw...)
RequestMutator / ResponseMutatorRead/write views of the request/response inside middlewareRead/write the request in the request phase, the response in the response phase
SessionManagerSession-state storeThread safe; manages cookies and common request headers uniformly
DomainClienter (interface)Domain clientBinds a base URL + embedded session; relative paths are joined automatically
ResultResponse wrapperThree-part structure (request/response/metadata); nil-safe accessors; reclaimed by GC automatically
ClientErrorNetwork-layer error classificationExtract with errors.As; Code() / IsRetryable() / Attempts

How the components relate:

text
Package-level functions ──share──▶ default Client ◀──creates── New(cfg)

                 ┌─────────────────────┼─────────────────────┐
                 ▼                     ▼                     ▼
          Middleware chain        Engine execution       Download
          (optional)              security/retry         streaming file
          Chain(mw...)                                   download
                 │                     │
                 ▼                     ▼
          RequestMutator        Result (Request / Response / Meta)

DomainClienter = Client + base URL + SessionManager
(injects session headers/cookies before each request, writes Set-Cookie back after each response)

Two-Layer API Architecture

HTTPC provides two equivalent ways to make requests, mirroring the relationship between http.Get and http.Client in the standard library net/http:

Package-level functions — zero configuration; they share a lazily-initialized default client internally. Suitable for scripts and one-off requests:

go
result, err := httpc.Get("https://api.example.com/data")

Client instances — full control over configuration, connection pools, and lifecycle. Suitable for long-running services:

go
client, err := httpc.NewDefault()
defer func() { _ = client.Close() }()
result, err := client.Get("https://api.example.com/data")

Both approaches accept the same request options (WithHeader, WithJSON, ...) and return the same *Result type. Package-level functions are thin wrappers around Client instances. The default client is replaceable: SetDefaultClient(client) installs a custom instance as the default (the old instance is closed automatically), and CloseDefaultClient() closes and resets it — after a close, the next package-level call rebuilds it automatically.

When to use which?

One-off requests or quick prototypes -> package-level functions. Production services, custom configuration, or connection-pool management -> Client instances.

Configuration System: Config vs With* Options

HTTPC splits configuration into two independent layers to avoid confusion:

LayerCarrierScopeTypical fields
Instance configurationConfig structEntire client lifetimeTimeouts, retry strategy, connection pool, TLS
Request optionsWithXxx() functionsSingle requestWithHeader, WithJSON, WithTimeout

Instance configuration is passed to New() via the Config struct, starting from DefaultConfig() and modified as needed:

go
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 60 * time.Second
cfg.Retry.MaxRetries = 5
client, err := httpc.New(cfg)

Request options are passed on each call to supplement or override instance-level defaults:

go
result, err := client.Get(url,
    httpc.WithHeader("Authorization", "Bearer "+token),
    httpc.WithTimeout(30*time.Second),
)

You can also start from preset configurations (SecureConfig(), PerformanceConfig(), etc.). See the Config API.

Configuration across the library follows one convention: the main Config and SessionConfig are passed by value (required); middleware configs are passed by pointer, where passing nil means using the defaults; DownloadConfig is passed by pointer and requires FilePath to be set. Every XxxConfig has a matching DefaultXxxConfig() constructor — start from the defaults and adjust fields as needed. This is the consistent HTTPC configuration idiom.

Request Lifecycle

Every request goes through the following flow:

text
Options applied -> Middleware chain (if any) -> Engine execution -> Retry (if needed) -> Result returned
    ^                                       ^
  With* functions                  Connection pool / TLS / proxy / SSRF checks

Stage-by-stage details:

  • Options appliedWith* functions set request headers, body, timeouts, and so on; they run in the order passed, and any option returning an error (e.g. a header failing CRLF validation) fails the request immediately.
  • Middleware chain — active when Config.Middleware.Middlewares is configured; the request passes through middleware in registration order, and the terminal Handler hands the (possibly modified) request to the engine. With no middleware configured, the request goes straight to the engine with zero extra overhead.
  • Engine execution — URL/header validation (on by default) -> SSRF dial check (private IPs blocked by default) -> DNS resolution (optional DoH) -> take a connection from the pool (create one if short, bounded by MaxConnsPerHost) -> TLS handshake (version policy, optional certificate pinning) -> send the request -> read the response (response-body size and decompression caps checked).
  • Retry — Retryable conditions: timeouts, transport errors, most transient network errors, plus status codes 408/429/500/502/503/504. Backoff is Delay × BackoffFactor^n plus jitter, with each wait capped at MaxRetryDelay; a Retry-After response header takes precedence (capped at 60s). The total duration is bounded by Timeouts.Request or WithTimeoutthe timeout budget is shared across retries and does not reset each round.
  • Result — carries the response data, request metadata, and retry statistics; internal engine objects are pooled but transparent to callers, and Result is reclaimed by GC automatically — no manual release required.

Exhausted retries end one of two ways:

  • Network errors exhausted -> an error is returned (ClientError.Attempts records the attempt count);
  • Retryable status codes (e.g. 503) exhausted -> the last response is returned (result.StatusCode() == 503, with Meta.Attempts recording the total), and the caller handles the status code.

Middleware Model

A middleware is a function of the form func(Handler) Handler (MiddlewareFunc); Handler is the signature of the function that actually processes the request:

go
type Handler func(ctx context.Context, req RequestMutator) (ResponseMutator, error)
type MiddlewareFunc func(Handler) Handler

Middleware is registered on Config.Middleware.Middlewares and composed into an onion model by Chain(middlewares...): wrapped in registration order — the first middleware is the outermost layer; the request phase runs in order, the response phase in reverse.

Skeleton of a custom middleware:

go
func TimingMiddleware(report func(d time.Duration)) httpc.MiddlewareFunc {
    return func(next httpc.Handler) httpc.Handler {
        return func(ctx context.Context, req httpc.RequestMutator) (httpc.ResponseMutator, error) {
            start := time.Now()
            resp, err := next(ctx, req)        // Call the inner layer (next middleware or the engine)
            report(time.Since(start))          // Response-phase logic (runs in reverse order)
            return resp, err
        }
    }
}

Built-in middleware at a glance:

MiddlewareResponsibilityBehavior with nil config
LoggingMiddlewareEmits request/response summaries (URLs redacted automatically)Logging disabled (no-op)
RecoveryMiddlewareCatches panics inside the chain and converts them to errors
RequestIDMiddlewareInjects X-Request-ID (generated with crypto/rand)Default header name and secure generator
TimeoutMiddlewareMiddleware-layer timeout (takes effect ahead of the client's built-in timeout)Timeout disabled (passthrough)
MetricsMiddlewarePer-request callback (method/URL/status/duration/error)Metrics disabled (no-op)
AuditMiddlewareCompliance audit events (text/json formats, sensitive headers redacted)Default text config
HeaderMiddlewareAttaches static headers to every request (CRLF validated at creation)No headers (passthrough)

Do not use for Download or streaming requests

TimeoutMiddleware is not suitable for Download or WithStreamBody(true) requests — it cancels the context as soon as the handler returns (once the response headers have been received), which makes reading the response body fail with "context canceled". Use WithTimeout for such cases instead.

Sessions and the Domain Client

For consecutive requests to the same domain (login state, common headers, cookie pass-through), use a DomainClient instead of hand-building URLs and passing cookies on every call:

go
dc, err := httpc.NewDomainDefault("https://api.example.com")
if err != nil {
    log.Fatal(err)
}
defer dc.Close()

dc.SetHeader("Authorization", "Bearer "+token) // Session header: sent automatically on subsequent requests

_, _ = dc.Post("/login", httpc.WithJSON(creds)) // Set-Cookie from the response enters the session automatically
_, _ = dc.Get("/me")                            // Session cookies attached automatically

Division of labor between the two components:

  • DomainClient — binds a base URL; relative paths are joined automatically (/users -> https://api.example.com/users), while full http(s):// URLs are used as-is; it has built-in path-traversal protection (an error is returned if the joined result escapes the base path). A cookie jar is enabled automatically at creation.
  • SessionManager — a thread-safe session-state store (cookies + headers); DomainClient embeds it: before each request it injects session state into the request options, and after the response it writes Set-Cookie back. It can also be used standalone (NewSessionManagerDefault()).

Request options are applied twice

Request options passed to a DomainClient are applied twice internally — once to capture session state (cookies/headers) and once to perform the real request. Avoid placing side-effecting logic in options (counters, one-time nonces, etc.).

See the Domain Client & Sessions guide.

Secure Defaults

HTTPC is secure by default and requires no extra configuration to gain:

  • TLS 1.2+ enforced encryption
  • SSRF protection — blocks connections to private/reserved IP addresses (127.0.0.1, 10.x, 192.168.x, etc.)
  • CRLF injection protection — automatic validation of request headers and URLs
  • Response body size limit — 10MB by default, preventing memory exhaustion
  • Decompression-bomb guardrail — decompressed response bodies capped at 100MB by default
  • Strict Content-Length validation — on by default; errors when the body length does not match the declared value

To connect to internal services (VPN, intranet), set Security.AllowPrivateIPs = true or use SSRFExemptCIDRs for precise exemptions. See the Security Overview.

Error Model

HTTPC distinguishes network-layer errors from HTTP status codes:

  • Network-layer errors (connection failures, timeouts, TLS errors, etc.) -> returned as error; use errors.As to extract a ClientError for classification and retryability
  • HTTP status codes (4xx, 5xx) -> not returned as error; check them via methods like result.IsSuccess()
go
result, err := client.Get(url)
if err != nil {
    // Network-layer error — the request did not complete successfully
    var clientErr *httpc.ClientError
    if errors.As(err, &clientErr) {
        log.Printf("Error type: %s, retryable: %v", clientErr.Code(), clientErr.IsRetryable())
    }
    return err
}
// Request completed successfully — check the HTTP status code
if !result.IsSuccess() {
    log.Printf("HTTP error: %d", result.StatusCode())
}

The context carried by ClientError:

MemberDescription
TypeError classification (ErrorTypeTimeout, ErrorTypeNetwork, etc. — a 12-value enum)
Code()Short code string: TIMEOUT, NETWORK_ERROR, TLS_ERROR, DNS_ERROR, CONTEXT_CANCELED, VALIDATION_ERROR, HTTP_ERROR, etc.
IsRetryable()Whether a retry is worthwhile (context cancellation/validation/TLS/certificate always false; timeout/transport always true; network/DNS/5xx depend on the cause)
AttemptsAttempts made so far (including the first)
StatusCodeAssociated HTTP status code (if applicable)
CauseUnderlying error; transparent to errors.Is / errors.As
URL / MethodRedacted request URL and request method

Common sentinel errors can be matched with errors.Is: ErrClientClosed (using a closed client), ErrResponseBodyEmpty (Unmarshal on an empty body), ErrResponseBodyTooLarge (parse body over 50MB), and more — see Error Types for the full list.

See Error Handling.

Concurrency and Resource Management

  • Client concurrency safety — one client can be shared by any number of goroutines; the connection pool is managed per host internally, so there is no need to create separate clients for concurrency.
  • Result is independent and release-free — every request returns a brand-new *Result (allocated once together with its three metadata structs); holding it carries no lifecycle burden — leave it to the GC.
  • Explicit Closeclient.Close() releases the connection pool and transport resources; requests after a close return ErrClientClosed.
  • Self-healing default client — the default client behind package-level functions can be replaced with SetDefaultClient() and closed with CloseDefaultClient(); after a close, the next package-level call rebuilds it automatically.
  • Panic safety netRequest has an internal recover fallback: unexpected panics on the execution path are converted into an error with a stack trace instead of crashing through to the caller.