Skip to content

Migrating from net/http

Already familiar with net/http? This guide maps your existing standard-library experience to HTTPC item by item: which idioms can be replaced mechanically, which semantics changed, and which defaults deserve a second look. All behavior descriptions follow the source code.

Compatibility Overview: Built on Top of net/http

HTTPC is not a replacement for net/http — it is an enhancement layer built on top of its transport. The underlying engine is still http.Client and http.Transport: connection reuse, HTTP/2 negotiation, TLS sessions, and proxy tunnels are all executed by the standard library; HTTPC layers security validation, a retry engine, a middleware chain, and the Result transformation on top (see the FAQ entry "How does HTTPC relate to net/http?").

What stays the same after migration:

  • Transport-layer behavior — connection-pool reuse, HTTP/2 negotiation, and TLS session resumption match the standard library, so performance characteristics do not degrade by switching;
  • Types are reused as-ishttp.Cookie, tls.Config, context.Context, io.Reader, and http.Header are used unchanged, with no adapter layer;
  • Mental model — package-level functions correspond to http.Get, a Client instance corresponds to http.Client, and context is passed the same way (see the two-layer API architecture in Core Concepts).

What you gain after migration:

  • Enforced TLS 1.2+, SSRF protection, CRLF-injection validation, and response-body size limits (secure by default);
  • Smart retries with exponential backoff (honors Retry-After; the timeout budget is shared across retries);
  • An onion-model middleware chain (logging/metrics/audit/request ID);
  • A one-stop Result wrapper — the response-body lifecycle is managed automatically, no Close() needed.

The same request, before and after migration:

go
package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("https://httpbin.org/get")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close() // must close manually, otherwise the connection leaks

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }

    fmt.Println(resp.StatusCode) // 200
    fmt.Println(len(body))       // response byte count
}
go
package main

import (
    "fmt"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://httpbin.org/get")
    if err != nil {
        panic(err) // network-layer errors only
    }

    fmt.Println(result.StatusCode())  // 200
    fmt.Println(len(result.RawBody())) // response byte count (already in memory, nothing to close)
}

The migrated code is shorter, and it now carries the TLS policy, SSRF protection, and up to 3 smart retries by default.

API Mapping Tables

Client and Requests

net/http idiomHTTPC equivalentKey difference
http.Get(url)httpc.Get(url)Both are package-level functions + a shared default instance (lazily initialized)
http.Post(url, ct, body)httpc.Post(url, httpc.WithJSON(data))The request body is declared via With* options; Content-Type is set automatically
http.PostForm(url, values)httpc.Post(url, httpc.WithForm(m))WithForm takes a map[string]string; for url.Values use WithBody(values, httpc.BodyForm)
http.Head(url)httpc.Head(url)One-to-one; Put/Patch/Delete/Options work the same way
client := &http.Client{...}httpc.New(cfg) / httpc.NewDefault()Returns the Client interface; holds a connection pool, release with Close()
http.DefaultClientThe default client inside package-level functionsLazy singleton; SetDefaultClient takes over, CloseDefaultClient releases and it auto-rebuilds
http.NewRequest + client.Do(req)client.Get(url, opts...) and other verb methodsNo need to build an *http.Request; method and URL are passed directly
http.NewRequestWithContext + Doclient.Request(ctx, method, url, opts...)The generic form, any method string
req.Header.Set(k, v)httpc.WithHeader(k, v) / WithHeaderMap(m)Header keys and values are CRLF-injection validated; invalid values return ErrInvalidHeader
req.Header.Set("User-Agent", ua)httpc.WithUserAgent(ua)Instance-level default via cfg.Defaults.UserAgent
req.SetBasicAuth(u, p)httpc.WithBasicAuth(u, p)Format-validated
req.AddCookie(&http.Cookie{...})httpc.WithCookie(http.Cookie{...})Takes a value type; for batches use WithCookies/WithCookieMap/WithCookieString
req.URL.Query() string assemblyhttpc.WithQuery(k, v) / WithQueryMap(m)Values support common scalars and fmt.Stringer; encoded and merged into the URL automatically
jar, _ := cookiejar.New(nil) on client.Jarcfg.Connection.EnableCookies = trueOr use a DomainClient that maintains cookies and common headers automatically (see Domain Sessions)
client.CheckRedirect = func(...)cfg.Defaults.FollowRedirects / MaxRedirectsPer-request WithFollowRedirects(false); redirect domain whitelist via Security.RedirectWhitelist (see Redirects)
Proxy: Transport.Proxycfg.Connection.ProxyURL / ProxyPool / EnableSystemProxyThe three modes take effect by priority; see Proxy & Proxy Pool

Response Handling

net/http idiomHTTPC equivalentKey difference
resp.StatusCoderesult.StatusCode()Nil-safe accessor; status checks via IsSuccess() / IsClientError() / IsServerError() / IsRedirect()
resp.Status / resp.Protoresult.Response.Status / result.Proto()Protocol version such as HTTP/1.1
resp.Header.Get(k)result.Response.Headers.Get(k)Still a standard http.Header, case-insensitive
io.ReadAll(resp.Body)result.Body() / result.RawBody()The response body has already been read into memory and copied; decompression is automatic
defer resp.Body.Close()No equivalentDo not look for a close hook — connections are managed by the pool, Result is left to the GC
json.NewDecoder(resp.Body).Decode(&v)result.Unmarshal(&v)An empty body returns the sentinel error ErrResponseBodyEmpty (not io.EOF, see below)
resp.Cookies()result.ResponseCookies() / result.GetCookie(name)Also GetRequestCookie to inspect the cookies actually sent
resp.ContentLengthresult.Response.ContentLength
resp.Request (final request after redirects)result.RequestContains URL / Method / Headers / Cookies
io.Copy(f, resp.Body) to diskresult.SaveToFile(path)For large files switch to Download (streaming, resumable; see File Transfer)
(no equivalent)result.MetaNew in HTTPC: Duration / Attempts / RedirectChain / ProxyURL
resp.TrailerNo equivalentResult does not expose trailers; trailer-dependent scenarios are not supported yet

Error-Model Differences

This is the part where migration bites hardest. Start with what is the same: neither library treats 4xx/5xx as an errorerr only means the request could not be completed successfully. The real differences are the shape of err, the response-body lifecycle, and retry behavior:

Dimensionnet/httpHTTPC
Error shapeThe raw transport error wrapped in *url.ErrorClassified *ClientError (12-value ErrorType enum)
How to classifyType assertions (net.Error, net.DNSError, x509.UnknownAuthorityError…) or string matchingExtract with errors.As, then read Code() / IsRetryable() / Attempts; sentinel errors via errors.Is
Response when err != nilresp may be non-nil (when CheckRedirect returns an error it carries the last response)result is always nil — no need to nil-check the response
Body read errorsSurface at io.ReadAll / Decode time (io.EOF, unexpected EOF)Already read during the request phase; a read failure is returned as a ClientError (ErrorTypeResponseRead) with err
Empty body + JSON decodeDecode returns io.EOFUnmarshal returns ErrResponseBodyEmpty
RetriesNone — errors go straight to the callerTimeouts/transport errors and 408/429/500/502/503/504 are retried automatically; exhausted network errors return an error, exhausted retryable status codes return the last response
URLs in error messagesPrinted as-is (may contain credentials)Sanitized automatically (credentials ***:***, sensitive params [REDACTED])

Typical error handling before migration:

go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("https://api.example.com/users/42")
    if err != nil {
        // *url.Error: connection failures, timeouts, and TLS errors all
        // surface here; further classification needs type assertions or
        // string matching
        panic(err)
    }
    defer resp.Body.Close()

    // 4xx/5xx are not errors: normal path, status code checked manually
    if resp.StatusCode != http.StatusOK {
        fmt.Println("HTTP error:", resp.StatusCode)
        return
    }

    var user map[string]any
    // Decode returns io.EOF on an empty body — a commonly missed branch
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil && !errors.Is(err, io.EOF) {
        panic(err)
    }
    fmt.Println(user["name"])
}
// Output (depends on the server's response):
// HTTP error: 404

After migration:

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://api.example.com/users/42")
    if err != nil {
        // Network-layer error: already classified as a ClientError (12 types),
        // carrying a short code and retryability
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            log.Printf("error type: %s, retryable: %v, attempts: %d",
                clientErr.Code(), clientErr.IsRetryable(), clientErr.Attempts)
        }
        panic(err)
    }

    // 4xx/5xx are not errors: check via Result's status helpers
    if !result.IsSuccess() {
        fmt.Println("HTTP error:", result.StatusCode())
        return
    }

    var user map[string]any
    // An empty body returns the sentinel error ErrResponseBodyEmpty,
    // matched precisely with errors.Is
    if err := result.Unmarshal(&user); err != nil {
        if errors.Is(err, httpc.ErrResponseBodyEmpty) {
            fmt.Println("(empty response body)")
            return
        }
        panic(err)
    }
    fmt.Println(user["name"])
}
// Output (depends on the server's response):
// HTTP error: 404

Common error-classification scenarios side by side (clientErr is the *httpc.ClientError extracted via errors.As):

What you want to detectnet/http idiomHTTPC idiom
Timeoutvar ne net.Error + ne.Timeout()clientErr.Type == httpc.ErrorTypeTimeout
DNS failurevar de *net.DNSError + errors.Ashttpc.ErrorTypeDNS
Certificate validation failurevar ce x509.UnknownAuthorityError + errors.Ashttpc.ErrorTypeCertificate
TLS protocol errorString matching "tls:"httpc.ErrorTypeTLS
Connection refused/resetvar oe *net.OpError + errors.Ashttpc.ErrorTypeNetwork
Context canceled/deadlineerrors.Is(err, context.Canceled)httpc.ErrorTypeContextCanceled (never retried)

For the complete picture of error classification, retryability, and sentinel errors, see Error Handling and Error Types.

Request Body, Header, and Context Migration

The three-step net/http flow — "build the request → set fields one by one → Do" — collapses into "verb method + declarative options" in HTTPC.

go
// net/http: manual serialization, manual headers, manual query-string assembly
payload, _ := json.Marshal(map[string]any{"name": "test"})
req, err := http.NewRequest("POST", "https://api.example.com/orders", bytes.NewReader(payload))
if err != nil {
    log.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

q := req.URL.Query()
q.Set("page", "2")
req.URL.RawQuery = q.Encode()

resp, err := client.Do(req)
go
// HTTPC: options are the request; Content-Type is set automatically and
// query parameters are encoded automatically
result, err := client.Post("https://api.example.com/orders",
    httpc.WithJSON(map[string]any{"name": "test"}),
    httpc.WithBearerToken(token),
    httpc.WithQuery("page", 2),
)

Mapping of standard-library operations to request options:

Standard-library operationHTTPC option
json.Marshal + bytes.NewReader + Content-TypeWithJSON(data) (equivalent to WithBody(data, BodyJSON))
xml.MarshalWithXML(data)
url.Values form encodingWithForm(m) / WithBody(values, httpc.BodyForm)
Hand-written multipart.Writer boundariesWithFile(field, name, content) / WithFormData(form)
bytes.NewReader(raw) raw bodyWithBody(raw) (type auto-detected) / WithBinary(data, ct...)
req.Header.Set(k, v)WithHeader(k, v) / WithHeaderMap(m)
req.SetBasicAuth / hand-assembled BearerWithBasicAuth(u, p) / WithBearerToken(t)
req.AddCookieWithCookie(c) / WithCookies(cs) / WithCookieMap(m) / WithCookieString(s)
io.Reader streaming request bodyWithBody(reader) (passed through as-is; bypasses size validation — wrap with io.LimitReader)

Context usage matches the standard library — context.Context is still the carrier of timeouts and cancellation; only where you pass it changes:

go
// net/http: the ctx goes into the request object
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
go
// HTTPC: ctx is passed directly as the first argument
result, err := client.Request(ctx, "GET", url)

// Convenience methods (Get/Post, etc.) do not take a ctx — swap it in
// with WithContext:
result, err = client.Get(url, httpc.WithContext(ctx))

For the complete option list, see Request and Response and the Request Options API.

Timeout System Comparison

http.Client.Timeout is a single timeout covering the whole process; HTTPC splits it into five independently configurable levels and adds a per-request override:

net/httpHTTPC fieldDefaultScope
http.Client.TimeoutTimeouts.Request180sTotal request timeout, covering all retries and backoff waits
Transport.DialContext (net.Dialer{Timeout})Timeouts.Dial10sTCP connection establishment
Transport.TLSHandshakeTimeoutTimeouts.TLSHandshake10sTLS handshake (HTTPS only)
Transport.ResponseHeaderTimeoutTimeouts.ResponseHeader0 (disabled)Waiting for response headers; a positive value acts as a transport-level hard cap
Transport.IdleConnTimeoutTimeouts.IdleConn90sHow long idle connections are kept
(no per-request override)WithTimeout(d)Per-request override of the total budget; capped at 30 minutes
go
// net/http: a single timeout covering the whole process (no retries)
client := &http.Client{Timeout: 30 * time.Second}
go
// HTTPC: instance-level total budget + per-request override
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 30 * time.Second // total budget including all retries
client, _ := httpc.New(cfg)

result, err := client.Get(url, httpc.WithTimeout(30*time.Second)) // override for this request

Three semantic differences to keep in mind:

  • The total budget is shared across retriesTimeouts.Request / WithTimeout covers all retry attempts and backoff waits; the clock does not restart per attempt;
  • ResponseHeader is special — defaults to 0 (disabled), leaving the total budget fully in charge; once set to a positive value it applies to all requests sharing the same client and, when shorter, overrides WithTimeout (defense-in-depth against slowloris; SecureConfig() ships it configured);
  • Long-running responses — for APIs that need long waits (AI APIs and the like), simply give WithTimeout enough budget; by default there is no "response-header timeout cuts off slow responses" problem.

See the timeout section of Request and Response and the FAQ entry How do I choose timeouts?.

Transport Customization Migration

The http.Transport tuning fields commonly used with the standard library all map to the corresponding sub-structures of Config:

http.Transport / http.Client fieldHTTPC configurationDefault
MaxIdleConnsConnection.MaxIdleConns50
MaxConnsPerHost / MaxIdleConnsPerHostConnection.MaxConnsPerHost10
Proxy: http.ProxyFromEnvironmentConnection.EnableSystemProxyfalse
Custom Proxy functionConnection.ProxyURL (single proxy) / ProxyPool (pool rotation)empty
TLSClientConfigSecurity.TLSConfignil
ForceAttemptHTTP2Connection.EnableHTTP2true
ResponseHeaderTimeoutTimeouts.ResponseHeader0 (disabled)
MaxResponseHeaderBytesConnection.MaxResponseHeaderBytes0 (standard-library default 10MB)
CheckRedirectDefaults.FollowRedirects / MaxRedirects + Security.RedirectWhitelisttrue / 10
Custom DialContext (dialer)No direct entry pointSSRF validation is wrapped into the dial layer

Existing tls.Config knowledge (mTLS, custom CAs, cipher suites) carries over unchanged:

go
package main

import (
    "crypto/tls"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    // An existing tls.Config (custom CA, cipher suites, mTLS client
    // certificates) migrates as-is
    tlsCfg := &tls.Config{
        MinVersion: tls.VersionTLS12,
        MaxVersion: tls.VersionTLS13,
    }

    cfg := httpc.DefaultConfig()
    cfg.Security.TLSConfig = tlsCfg
    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://httpbin.org/get")
    if err != nil {
        log.Fatal(err) // network-layer error
    }
    fmt.Println(result.StatusCode()) // 200
}

WARNING

Once Security.TLSConfig is set, the MinTLSVersion / MaxTLSVersion fields are ignored — the TLS version policy follows the tls.Config you pass in (set MinVersion yourself and keep it at TLS 1.2 or higher).

Two boundaries worth knowing:

  • No custom-Transport injection point — HTTPC creates and manages the *http.Transport itself (SSRF validation is wrapped into the dial function, redirect policy is injected via CheckRedirect); Config does not expose the whole Transport. If you need extreme dial customization, first check whether Config.Connection / Config.Security already covers it.
  • Use Doer to replace the whole implementation — a test mock or alternate implementation only needs the single-method interface Doer (Request(ctx, method, url, opts...)), without wiring up the full Client interface; see Testing.

Migration Pitfalls Checklist

These behavioral differences cause the most trouble when moving from net/http:

1. resp.Body does not need (and must not) be closed manually

What Result holds is the already-read and copied bytes — HTTPC performs the read, drain, and close internally, and the underlying connection is managed by the pool. When migrating, delete every defer resp.Body.Close() and do not look for a close hook. See the FAQ entry "Do I need to close the response body manually?" for details.

2. Retries are on by default — non-idempotent POSTs may be submitted twice

net/http never retries; HTTPC by default retries timeouts/transport errors and 408/429/500/502/503/504 up to 3 times, regardless of the request method. Order-placement and payment-style endpoints must handle this:

go
// Dangerous: up to 3 retries by default, and POST takes part in them
result, err := client.Post("https://api.example.com/orders", httpc.WithJSON(order))

// Safe (option 1): disable retries per request on non-idempotent endpoints
result, err = client.Post("https://api.example.com/orders",
    httpc.WithJSON(order),
    httpc.WithMaxRetries(0),
)

// Safe (option 2): deduplicate via a server-side idempotency key (recommended)
result, err = client.Post("https://api.example.com/orders",
    httpc.WithJSON(order),
    httpc.WithHeader("Idempotency-Key", orderID),
)

For mitigation strategies, see the FAQ entry "Can retries cause duplicate POST submissions?".

3. SSRF protection is on by default — internal networks and localhost are blocked

After migrating from net/http, accessing 127.0.0.1, 10.x, 192.168.x, and other private/reserved addresses fails outright (net/http has no such restriction). For local development, pick the option with the smallest blast radius first:

go
// Per-request exemption (recommended, smallest blast radius)
result, err := httpc.Get("http://localhost:8080/health",
    httpc.WithAllowPrivateIPs(true),
)

// Client-level precise CIDR exemption (e.g. VPC / Tailscale)
cfg := httpc.DefaultConfig()
cfg.Security.SSRFExemptCIDRs = []string{"10.0.0.0/8"}

For the full policy, see SSRF Protection.

4. The default client is a lazy singleton — use an explicit instance for long-running services

Package-level functions share an internally managed default client (auto-rebuilt after being closed). Production services should create an explicit instance to control configuration and lifecycle; requests after Close() return ErrClientClosed:

go
// Long-running service: explicit instance, shared process-wide, Close when done
client, err := httpc.NewDefault()
if err != nil {
    log.Fatal(err)
}
defer client.Close()

// To make package-level functions use your configuration: take over the
// default client (the old instance is closed automatically)
custom, err := httpc.New(httpc.SecureConfig())
if err != nil {
    log.Fatal(err)
}
if err := httpc.SetDefaultClient(custom); err != nil {
    log.Fatal(err)
}

5. Match sentinel errors with errors.Is / errors.As, not string matching

Sentinel errors such as ErrClientClosed, ErrResponseBodyEmpty, ErrResponseBodyTooLarge, and ErrInvalidHeader should be tested with errors.Is; classified errors are extracted as *ClientError with errors.As. The error chain unwraps down to the underlying cause (Cause).

6. Response bodies have default caps

Regular requests cap the response body at 10MB by default and the decompressed body at 100MB (guarding against memory exhaustion and decompression bombs); exceeding them errors out. Old code that downloaded large files with http.Get + io.Copy should switch to Download (streams to disk, resumable, progress callbacks). The caps are adjustable via Security.MaxResponseBodySize / MaxDecompressedBodySize.

7. Redirect behavior is predictable but has defaults

Redirects are followed by default (up to 10). Note that WithMaxRedirects(0) / MaxRedirects = 0 is the "unset" sentinel value, not a disable — to stop following, use WithFollowRedirects(false) or Defaults.FollowRedirects = false. See Redirects.

Step-by-Step Migration Checklist

Execute in order; every step can be verified independently:

  1. Install the dependencygo get github.com/cybergodev/httpc, and swap "github.com/cybergodev/httpc" in wherever "net/http" client calls live (types like http.Cookie still come from the standard library).
  2. Mechanically replace request callshttp.Gethttpc.Get, client.Do(req)client.Get/Post/...; http.Client literals → httpc.New(cfg).
  3. Delete resource-management code — remove defer resp.Body.Close() and io.ReadAll; switch to result.Body() / result.RawBody() / result.Unmarshal(&v).
  4. Rework error handling — in the err branch, extract ClientError with errors.As as needed; turn status-code checks into the result.IsSuccess() family; replace the io.EOF empty-body branch with errors.Is(err, httpc.ErrResponseBodyEmpty).
  5. Map timeoutshttp.Client.Timeoutcfg.Timeouts.Request; per-request differences via WithTimeout; place former Transport-level timeouts per Timeout System Comparison.
  6. Migrate Transport tuning — pool sizes, proxies, TLS, and HTTP/2 land in Config.Connection / Config.Security per the table above; move tls.Config into Security.TLSConfig.
  7. Handle the secure defaults — add SSRF exemptions for internal-network/localhost calls; confirm the response-body caps fit the payload sizes your APIs return.
  8. Review the retry impact — add an idempotency key or WithMaxRetries(0) for non-idempotent POSTs; make sure the retry budget matches your business timeouts.
  9. Finish lifecycle housekeeping — long-running services use an explicit instance + defer client.Close(); confirm no client is created per request on the hot path.
  10. Regression-test — run your existing integration tests; focus on error paths (network loss/timeouts/4xx/5xx) and large-response scenarios.

Next Steps

  • Tutorial - Build a complete GitHub API client in 30 minutes, covering typical post-migration patterns
  • Request and Response - Complete request options and Result response handling
  • Core Concepts - Two-layer API architecture, configuration system, and request lifecycle
  • FAQ - Source-grounded answers on retries, timeouts, proxies, cookies, and other frequent questions