A Go HTTP and WebSocket client with explicit body consumers, bounded response reads, replay-aware retries, and optional logging. Requires Go 1.27.
client, err := network.NewClient("https://api.example.com/v1",
network.WithTimeout(30*time.Second),
network.WithDefaultHeaders(http.Header{
"Authorization": {"Bearer " + token},
}),
)
if err != nil {
return err
}
defer client.CloseIdleConnections()
var user User
var apiError APIError
var status int
err = client.Post(ctx, "users",
network.JSONBody(input),
network.BindJSON(&user),
network.BindErrorJSON(&apiError),
network.BindStatusCode(&status),
)Do(ctx, method, endpoint, options...) is the core operation. Get, Head, Post, Put, Patch, Delete, and Options delegate to it. All return error. Relative endpoint paths append to the base path; query parameters merge with endpoint values taking precedence. Absolute endpoints replace the base URL. Default headers only apply to the base origin; explicit per-request headers apply to the selected endpoint.
Options run in caller order. Scalar settings and headers use the last value. Two success-body consumers or two error-body consumers are rejected. Client options and request options are separate types so mutable binding destinations cannot accidentally become client defaults.
| Request option | Purpose |
|---|---|
Body(data) |
Snapshot bytes; replayable for retries and redirects |
JSONBody(value) |
Encode and snapshot JSON when creating the option |
FormBody(values) |
Snapshot and encode URL-encoded form fields |
MultipartBody(fields, files...) |
Stream replayable file uploads without buffering files |
Query(values), QueryParam(key, value) |
Encode query parameters and preserve repeated values |
ReaderBody(reader) |
Stream a single-use body, transferring ownership |
ReplayableBody(factory, size) |
Open an independent reader for each attempt; size -1 is unknown |
BindJSON(&value), BindXML(&value) |
Explicit decoding, independent of response Content-Type |
BindBody(&value) |
Decode by Content-Type, including +json and +xml |
BindError(&value), BindErrorJSON(&value) |
Decode error responses while retaining the HTTP error |
BindBytes(&data), BindString(&text) |
Buffer binary or text responses explicitly |
BindErrorBytes(&data), HandleErrorResponse(handler) |
Consume arbitrary error payloads |
AcceptStatus(codes...) |
Override accepted statuses, e.g. 200, 304 |
FollowRedirects(enabled) |
Override redirect following for one request |
WriteBody(writer) |
Stream a response to a writer |
HandleResponse(handler) |
Custom synchronous consumption of successful responses |
BindHeader, BindCookies, BindStatusCode |
Metadata on successful and unsuccessful responses, including HEAD |
WithRequest(fn) |
Customize the request before execution |
The client closes every HTTP response. A custom handler must finish reading before returning and must not retain the body. Without a consumer, at most 64 KiB is drained before closing; this bounds unnecessary work, but larger discarded responses may prevent connection reuse. HEAD, 204, 205, and accepted 304 responses skip body decoding. Other empty bodies produce a decoding error when a decoder is requested. Decoders require one complete document and reject trailing JSON/XML documents. A destination implementing Validate(context.Context) error is validated after decoding.
Buffered, decoded, and streamed responses default to a 16 MiB limit. Override with WithMaxResponseBody or per-request MaxResponseBody; zero disables the limit. Writers and decoded destinations can contain partial data after failure. A custom handler that does not read the full body cannot detect whether unread content exceeds the limit. WithDecoder(mediaType, decoder) registers additional formats. ParseJSON[T](data) and ParseXML[T](data) provide standalone conversion helpers.
WithHTTPClient preserves custom transports and cookies. Its redirect checker runs only when redirect following is explicitly enabled, subject to the client redirect limit. Standard HTTP transports and TLS configurations are cloned at initialization, then reused. Custom transports are shared and must be concurrency-safe. Use WithTLSConfig after WithHTTPClient when overriding TLS. Client timeout defaults to 30 seconds across all attempts and backoff; an injected HTTP client's own timeout also applies to each attempt.
Use errors.Is with ErrConfiguration, ErrTransport, ErrStatus, ErrDecode, ErrBodyTooLarge, ErrRedirectLimit, or ErrRedirectPolicy. Use errors.As(err, &networkError) with var networkError *network.Error for HTTP metadata. Status zero means no HTTP response was received. Underlying cancellation, transport, and decoding errors remain available through unwrapping. Non-2xx responses are HTTP errors, including redirects when automatic redirect following is disabled. An error response with malformed bound content matches both ErrStatus and ErrDecode.
Retries are disabled by default. Configure WithRetryPolicy(network.RetryPolicy{...}), or Retries(n) per request. Default retry eligibility requires an idempotent method and a replayable body, and permits transport failures or HTTP 408/429/500/502/503/504. Decoding failures and canceled contexts are never retried. A custom Condition may permit other methods/statuses; it cannot bypass replay or decoding restrictions. Enabling POST retries requires application-level duplicate handling.
Backoff doubles up to MaxBackoff; optional jitter spreads retry load. Retry-After is a server minimum and may exceed MaxBackoff; only the local exponential delay is capped. The caller's context and overall timeout bound the resulting wait. Metadata/error binding destinations describe the latest received response; if a subsequent attempt fails before receiving a response, inspect the returned error (status zero) rather than stale bound metadata.
client, err := network.NewClient(baseURL,
network.WithLogHandler(func(ctx context.Context, event network.LogEvent) {
logger.Log(ctx, event) // Adapt to your application's logger.
}),
network.WithLogging(network.Behaviours{
LogSuccessfulTransactions: true,
LogRequestHeadersOnError: true,
LogRequestBodyOnError: true,
LogResponseHeadersOnError: true,
LogResponseBodyOnError: true,
}),
network.WithLogBodyLimit(4096),
)No handler means no logging or body capture. With a handler, failures are emitted automatically; successful events require LogSuccessfulTransactions or an always-on field flag. Each field uses always || (failed && onError). Events distinguish retry attempts, final completion, and WebSocket handshakes. Per-request WithRequestLogging overrides the client policy.
Request and response bodies are logged on errors by default when a handler is configured. Successful bodies are not logged by default. Captures are bounded to 4096 bytes by default. Replayable requests are captured before sending; response bodies and non-replayable requests are captured as consumed. No logger means no capture.
DefaultLogging() returns the default policy. WithLogging and WithRequestLogging replace the entire policy, so use WithLogging(network.Behaviours{}) to disable both body fields, or modify DefaultLogging() to keep defaults while enabling other fields:
logging := network.DefaultLogging()
logging.LogSuccessfulTransactions = true
// Pass network.WithLogging(logging) to NewClient.Truncation flags report capture limits. Authorization, proxy authorization, cookies, Set-Cookie, and X-Api-Key headers are redacted. Logged URLs omit credentials, query strings, and fragments. Application body fields and arbitrary underlying errors may contain sensitive data; use the handler to apply application-specific redaction. Logging handlers and callbacks run synchronously and must be concurrency-safe and must not panic.
Every HTTP method has an Async counterpart. Await() returns the final error and permits multiple concurrent waiters. Read bound values only after completion. AwaitContext(ctx) cancels waiting without canceling the request; cancel the request's context to stop the work. Callback(fn) runs before completion is published and must not await its own transaction. A callback is installed when its option is applied; URL/construction errors that occur before options run cannot invoke it.
conn, err := client.DialWebSocket(ctx, "/events", network.WebSocketOptions{
Subprotocols: []string{"events.v1"},
HandshakeTimeout: 10*time.Second,
ReadLimit: 1 << 20,
})
if err != nil {
return err
}
defer conn.Close(network.StatusNormalClosure, "done")
err = conn.Write(ctx, network.MessageText, []byte("hello"))HTTP(S) and WS(S) URLs are supported. WebSockets share transport, default headers, cookie jar, redirect policy, and handshake logging. The dialer exposes at most 1024 bytes of an unsuccessful handshake body for logging. Handshake timeout falls back to the client timeout; it does not set a connection lifetime deadline. The default message read limit is 1 MiB. Message reads, writes, and pings take their own contexts. Keep a reader active to process ping/pong and close frames. Reconnection and message replay are application responsibilities. WebSocket support is built on coder/websocket: WebSocket aliases websocket.Conn, so its streaming, concurrency, and close semantics apply directly. HTTP response consumers and retry policies do not apply to WebSockets.
Redirect following is disabled by default, including WebSocket handshakes and injected HTTP clients. A 3xx response is returned as ErrStatus, with its headers available through BindHeader. Opt in for the client or one request:
client, err := network.NewClient(baseURL,
network.WithFollowRedirects(true),
network.WithMaxRedirects(5),
)
// Disable following for this call even when enabled on the client.
err = client.Get(ctx, "/resource", network.FollowRedirects(false))The default limit is 10 redirect hops. A custom http.Client.CheckRedirect runs within that limit; it cannot bypass a disabled policy. Redirect-limit and custom-policy failures are not retried. Standard Go method rewriting applies: 301/302/303 normally turn POST into GET; 307/308 preserve methods and require a replayable body. Explicit authentication, cookie, and API-key headers are removed on cross-origin redirects. Cookie jars independently follow standard cookie domain rules. See the Go HTTP client documentation for redirect and cookie behavior.
- Sessions: pass an
http.CookieJarusingWithCookieJar(jar). Nil disables persistence. For individual cookies useWithRequestandreq.AddCookie. - Proxies:
WithProxy(http.ProxyURL(proxyURL))configures a standard transport;WithProxy(nil)disables environment proxies. Apply it afterWithHTTPClient. Custom transports configure their own proxy behavior. - Forms:
FormBody(url.Values{...})handles URL encoding and repeated fields. - File uploads:
MultipartBodyacceptsMultipartFile{FieldName, FileName, ContentType, Open}.Openreturns an independentio.ReadCloserfor each attempt; the client opens it lazily and closes it. File contents are streamed, while field values and headers are snapshotted in memory. Replay requires file contents to remain consistent across opens. - Conditional requests: combine headers such as
If-None-MatchwithAcceptStatus(200, 304); a 304 skips decoding and leaves the bound destination unchanged. The package does not maintain a response cache. - Downloads:
WriteBody(writer)streams to a file or other writer. The standard transport handles gzip decompression automatically unless you explicitly controlAccept-Encoding. The response limit applies to decompressed bytes. - Event streams: use
HandleResponse,Timeout(0), andMaxResponseBody(0)with a cancellable context for long-lived SSE or NDJSON reads. Consume the stream inside the handler; cancel its context to interrupt a blocked read. Reconnection and event parsing belong to the application.
Run the race-enabled coverage suite and vet:
go test -race -covermode=atomic -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go vet ./...Maintain at least 90% statement coverage. Tests use local loopback HTTP/TLS/WebSocket and proxy servers; no external service is required. Go may need to download the pinned coder/websocket module on the first run.
client.RoundTripper() wraps the configured transport for SDK-owned requests.
client.HTTPClient() provides that adapter with the configured cookie jar and
redirect policy. Both preserve streaming response ownership and writable HTTP
upgrade bodies. The SDK must read/close its response. The adapter does not decode,
limit, drain, retry, change URLs, or apply default headers to SDK requests. HTTP
statuses remain responses, not transport errors. HTTPClient() deliberately has
zero Timeout: Go's timeout body wrapper hides the writer needed for upgrades.
Use request-context deadlines, or set Timeout explicitly for HTTP-only SDKs.
Do not install an adapter back into its own client's underlying transport.
WithPrepareAttempt(fn) installs a client hook; PrepareAttempt(fn) overrides it
for a native request. It runs after backoff and body recreation, before capture
and sending, with a zero-based attempt number. Use it to refresh credentials or
signatures, respecting the supplied context. Preparation errors abort without a
retry. SDK adapters call it once per RoundTrip with attempt zero; the SDK owns its
retry numbering. Native redirects happen inside net/http and do not invoke this
hook per redirect; use a signing transport when every redirected send must be
signed. Keep GetBody consistent if replacing a body in the hook.
Replayable requests are now captured via a separate GetBody reader before sending,
so a connection failure still has request evidence. Captures remain bounded and
never consume the transmission body. GetBody factories may be invoked for
logging as well as replay; they must return independent, context-aware readers.
A diagnostic snapshot failure does not fail the request. Non-replayable streams
are observed as consumed. RequestBodyEvidence and ResponseBodyEvidence report
complete, partial, or unavailable; these indicate captured evidence, not
proof of delivery. ExchangeID correlates completion, retries and later reports.
report := new(network.FailureReport)
defer report.Release()
var payload []byte
err := client.Get(ctx, "/result",
network.CaptureExchange(report), network.BindBytes(&payload))
if err != nil { return err }
if err := validate(payload); err != nil {
report.ReportFailure(err) // includes enabled OnError fields for HTTP 200
return err
}For SDK requests, attach the handle with
network.ContextWithFailureReport(ctx, report). Each handle belongs to one
RoundTrip; use separate handles for SDK retries/redirects. Call ReportFailure
after consuming the response to EOF or closing it. It emits at most one
validation_failure event and returns false when the exchange is unfinished,
already reported/released, or the supplied error is nil. Native handles become
ready when Do returns and retain the final attempt. Reports retain detached,
bounded snapshots and context values until reported or released; always Release
after successful validation. Reporting does not change the completed HTTP result
or trigger retries. Capture and emitted fields still obey the logging policy;
no handler means no body capture.
WebSocket support is powered by coder/websocket. Thanks to Coder and the library's maintainers and contributors for providing a minimal, idiomatic, and context-first WebSocket implementation for Go.