Skip to content
io-daPublic

About

An HTTP client to request all the things.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

network

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),
)

HTTP requests

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.

Errors and retries

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.

Logging

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.

Async and WebSockets

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.

Redirects, sessions, proxies, and uploads

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.CookieJar using WithCookieJar(jar). Nil disables persistence. For individual cookies use WithRequest and req.AddCookie.
  • Proxies: WithProxy(http.ProxyURL(proxyURL)) configures a standard transport; WithProxy(nil) disables environment proxies. Apply it after WithHTTPClient. Custom transports configure their own proxy behavior.
  • Forms: FormBody(url.Values{...}) handles URL encoding and repeated fields.
  • File uploads: MultipartBody accepts MultipartFile{FieldName, FileName, ContentType, Open}. Open returns an independent io.ReadCloser for 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-Match with AcceptStatus(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 control Accept-Encoding. The response limit applies to decompressed bytes.
  • Event streams: use HandleResponse, Timeout(0), and MaxResponseBody(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.

Verification

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.

SDK integration and deferred validation

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.

Acknowledgments

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.

About

An HTTP client to request all the things.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages