Documentation
¶
Overview ¶
Package client provides an http.RoundTripper that retries safe outgoing requests with backoff and jitter, honoring Retry-After. Transport wraps an http.RoundTripper, so it plugs into any http.Client; there is no client type of its own. NewTransport instead creates an owned clone of the current native default transport, optionally applying a shallow-cloned tls.Config before wrapping retries. A nil TLS configuration preserves the default's TLS settings. Retire that owned pool through http.Client.CloseIdleConnections; Transport leaves connection-pool ownership with the caller.
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Attempts(5)
})}
resp, err := httpClient.Get("https://api.example.com/items")
What is retried ¶
A request is retried only when it is safe to send twice: its method is GET, HEAD, OPTIONS, TRACE, PUT, or DELETE, or it carries an Idempotency-Key header; and it has no body or a body that http.Request.GetBody can rewind (http.NewRequest sets GetBody for bytes, strings, and readers it recognizes). Eligibility is always enforced; Builder.RetryIf only judges the outcome of an attempt. The default judges transport errors and the status codes 408, 429, 502, 503, and 504 retryable. A 500 is not, because it usually signals a bug a repeat would not fix.
Waiting ¶
Between attempts the transport waits the Builder.Backoff delay, by default exponential with full jitter (Backoff). On 429 and 503 responses a Retry-After header (seconds or an HTTP date) replaces that delay; a wait longer than 10 seconds, or one that would outlast the request context's deadline, is not taken: the response is returned as is. Canceling the request context ends a wait immediately. A valid decimal Retry-After cannot bypass that cap by overflowing a machine integer; signed or malformed numbers instead use the configured backoff. Backoff preserves zero initial delays even for infinite exponential growth. Negative custom backoff delays count as zero; nonpositive attempt timeouts are disabled, and negative attempt counts permit only one attempt.
Upgraded response bodies ¶
A native writable 101 Switching Protocols body retains io.Writer, so callers can assert io.ReadWriteCloser. Read-only bodies do not gain writing, and unrelated native optional interfaces are not preserved. Reads and writes keep their native results; the caller must close the body. Close closes the native body once, then cancels the attempt context even after a close error. Repeated and concurrent closes return the first result. Upgrade I/O still follows the native body's cancellation behavior.
Observability ¶
Each attempt goes through the telemetry transport, so each is its own client span; resends carry http.request.resend_count. Retries are logged at Debug level with the method, host, attempt, delay, and status code or error type; paths, queries, headers, and bodies are never logged.
Caveats ¶
When attempts run out, the last response or error is returned unchanged. The transport never modifies the caller's request. Wrap the transport of a client that follows redirects once: each redirect is a new request with its own attempts.
With Builder.Events, a request the transport gives up on publishes RequestFailed ("http.client.request.failed") with the method, host, attempts, and the final status or error.
Index ¶
- Variables
- func Backoff(initial time.Duration, factor float64, maximum time.Duration) func(attempt int) time.Duration
- func NewTransport(config *tls.Config, configure ...BuilderFunc) (http.RoundTripper, error)
- func Transport(base http.RoundTripper, configure ...BuilderFunc) http.RoundTripper
- type Builder
- func (transport *Builder) AttemptTimeout(timeout time.Duration) *Builder
- func (transport *Builder) Attempts(attempts int) *Builder
- func (transport *Builder) Backoff(backoff func(attempt int) time.Duration) *Builder
- func (transport *Builder) Events(bus *event.Bus) *Builder
- func (transport *Builder) Logger(logger *slog.Logger) *Builder
- func (transport *Builder) Propagate(propagate func(*http.Request) bool) *Builder
- func (transport *Builder) RetryIf(retryIf func(*http.Response, error) bool) *Builder
- func (transport *Builder) Telemetry(instance *telemetry.Telemetry) *Builder
- type BuilderFunc
- type RequestFailedEvent
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var RequestFailed = event.NewTopic[RequestFailedEvent]("http.client.request.failed")
RequestFailed is published when Transport gives up on a request: it returns a transport error, or a response that Builder.RetryIf would have retried once attempts ran out or Retry-After asked for too long. Publication requires Builder.Events and is suppressed when the request context is done. This reports a final transport observation, not durable delivery or a later body-read failure.
Example:
bus := event.New()
unsubscribe, err := bus.Listen(client.RequestFailed,
func(ctx context.Context, failed client.RequestFailedEvent) error {
fmt.Println(failed.Attempts, failed.StatusCode)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
outgoing := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Events(bus)
})}
_ = outgoing
Functions ¶
func Backoff ¶
func Backoff(initial time.Duration, factor float64, maximum time.Duration) func(attempt int) time.Duration
Backoff returns a delay function for Builder.Backoff: exponential with full jitter. The delay before the first retry (attempt 2) is drawn uniformly from [0, initial), and the bound is multiplied by factor for each later retry and capped at maximum. The argument order matches queue.JobBuilder.Backoff.
Backoff never panics or fails. A negative initial or maximum counts as zero, a zero maximum omits the configured cap, and a factor below one (or NaN) counts as one. With a zero initial every delay is zero, even when exponential growth would overflow. Attempts below two use the first retry's bound. Infinite growth saturates at maximum, with an internal bound of 1<<62 nanoseconds for safe jitter sampling even when maximum is omitted.
Example:
httpClient := &http.Client{Transport: client.Transport(nil,
func(transport *client.Builder) {
transport.Backoff(client.Backoff(50*time.Millisecond, 3, time.Second))
})}
A zero initial delay remains zero even with unbounded growth:
backoff := client.Backoff(0, math.Inf(1), 0) delay := backoff(2048) // zero, without overflow or panic _ = delay
Example (ZeroInitial) ¶
ExampleBackoff_zeroInitial demonstrates disabling waits without allowing infinite growth to turn the zero policy into an invalid random bound.
package main
import (
"fmt"
"math"
"arkoren.dev/core/http/client"
)
func main() {
backoff := client.Backoff(0, math.Inf(1), 0)
fmt.Println(backoff(2048))
}
Output: 0s
func NewTransport ¶
func NewTransport(config *tls.Config, configure ...BuilderFunc) (http.RoundTripper, error)
NewTransport clones the current http.DefaultTransport and wraps it with Transport's retry and telemetry policy. The default must be a nonnil *http.Transport; any other value returns an error before callbacks run. Native proxy, connection pooling, and HTTP/2 settings are preserved.
A nil config preserves the cloned default's TLS settings. A nonnil config replaces them with config.Clone(): the top-level configuration is independent, but native nested values (pools, certificates, callbacks, and their captures) remain shared. Configure those values before construction and do not mutate them while in use. Neither the supplied config nor the default is modified.
Construction opens no connections. Callbacks run once, synchronously in order, through Transport; nil callbacks and application panics propagate. Use Transport directly when you already have a custom round tripper. Retire the owned connection pool with http.Client.CloseIdleConnections; active requests are not interrupted.
Example:
roundTripper, err := client.NewTransport(&tls.Config{
MinVersion: tls.VersionTLS12,
}, func(transport *client.Builder) {
transport.Attempts(5)
})
if err != nil {
return err
}
httpClient := &http.Client{Transport: roundTripper}
_ = httpClient
Example ¶
ExampleNewTransport configures TLS without changing the shared default pool.
package main
import (
"crypto/tls"
"fmt"
"net/http"
"time"
"arkoren.dev/core/http/client"
)
func main() {
transport, err := client.NewTransport(&tls.Config{MinVersion: tls.VersionTLS12},
func(transport *client.Builder) {
transport.Attempts(5)
},
)
if err != nil {
fmt.Println(err)
return
}
outgoing := &http.Client{Transport: transport, Timeout: 10 * time.Second}
outgoing.CloseIdleConnections()
fmt.Println("configured")
}
Output: configured
func Transport ¶
func Transport(base http.RoundTripper, configure ...BuilderFunc) http.RoundTripper
Transport returns a round tripper that retries failed requests through base. A nil base uses http.DefaultTransport.
A request is retried only when its method is idempotent (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) or it has an Idempotency-Key header, and when it has no body or http.Request.GetBody can rewind it. These rules always apply; Builder.RetryIf only decides on the outcome of an attempt. Each attempt uses a clone of the request, so the caller's request is never modified, and the previous response body is drained (up to 4 KiB) and closed before the next attempt.
Between attempts it waits Builder.Backoff, or the Retry-After header of a 429 or 503 response. If that header asks for more than 10 seconds (a fixed cap, independent of Builder.Backoff) or for more time than the context deadline allows, retrying stops and the response is returned as is. A wait ends early when the request context is done, and Transport then returns the context's error. When attempts run out, the last response or error is returned unchanged. With Builder.Events, returning a transport error, or a response Builder.RetryIf still wanted retried, publishes RequestFailed; a done request context publishes nothing.
The transport wraps the telemetry transport, so every attempt is its own client span, and every resend has the http.request.resend_count attribute. Trace context is injected only into internal destinations unless Builder.Propagate says otherwise.
A 101 Switching Protocols response retains io.Writer when the native body provides it, so callers can assert io.ReadWriteCloser for an upgraded stream. Read-only bodies do not gain write capability. Callers own the returned body: reads and writes preserve native results without closing it or canceling its attempt context. Close closes the native body once, then releases the attempt context, even if closing fails; repeated or concurrent closes return the same error. An upgrade retains the attempt context and its deadline until Close; the native body determines how cancellation affects its I/O.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Attempts(5)
})}
Example ¶
ExampleTransport retries a GET that first fails with 503.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"
"arkoren.dev/core/http/client"
)
func main() {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
if calls.Add(1) == 1 {
writer.WriteHeader(http.StatusServiceUnavailable)
return
}
writer.WriteHeader(http.StatusOK)
}))
defer server.Close()
sender := &http.Client{
Transport: client.Transport(nil,
func(transport *client.Builder) {
transport.Attempts(5)
},
func(transport *client.Builder) {
transport.Backoff(client.Backoff(10*time.Millisecond, 2, time.Second))
},
),
}
response, err := sender.Get(server.URL)
if err != nil {
fmt.Println(err)
return
}
defer func() {
// Closing this empty test response is best-effort cleanup.
_ = response.Body.Close()
}()
fmt.Println(response.StatusCode, calls.Load())
}
Output: 200 2
Example (DirectRoundTrip) ¶
ExampleTransport_directRoundTrip uses an in-memory protocol without networking.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"testing/fstest"
"arkoren.dev/core/http/client"
)
func main() {
files := fstest.MapFS{"health": {Data: []byte("ready")}}
base := &http.Transport{}
base.RegisterProtocol("file", http.NewFileTransportFS(files))
transport := client.Transport(base)
request, err := http.NewRequest(http.MethodGet, "file:///health", nil)
if err != nil {
fmt.Println(err)
return
}
response, err := transport.RoundTrip(request)
if err != nil {
fmt.Println(err)
return
}
data, err := io.ReadAll(response.Body)
err = errors.Join(err, response.Body.Close())
if err != nil {
fmt.Println(err)
return
}
fmt.Println(response.StatusCode, string(data))
}
Output: 200 ready
Types ¶
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder configures Transport before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Attempts(5)
})}
_ = httpClient
func (*Builder) AttemptTimeout ¶
AttemptTimeout bounds each attempt, from sending the request to reading the response body. Nonpositive values set none; zero is the default. The total time is bounded by the request context. Upgrades retain this context and its deadline until Body.Close; native I/O determines how it honors them.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.AttemptTimeout(time.Second)
})}
_ = httpClient
func (*Builder) Attempts ¶
Attempts is the total number of attempts, including the first, as in queue.JobBuilder.Retry and workflow.StepBuilder.Retry. The default, zero, is 3. A negative value disables retries: every request is attempted once.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Attempts(5)
})}
_ = httpClient
func (*Builder) Backoff ¶
Backoff returns the delay before attempt number attempt, which is at least 2 (the first retry). The default, nil, is Backoff with an initial delay of 100ms, factor 2, and maximum of 10s. A negative result counts as zero.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Backoff(nil)
})}
_ = httpClient
func (*Builder) Events ¶
Events receives RequestFailed. The default, nil, publishes nothing.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Events(event.New())
})}
_ = httpClient
func (*Builder) Logger ¶
Logger receives one Debug record per retry. The default, nil, is slog.Default.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Logger(slog.Default())
})}
_ = httpClient
func (*Builder) Propagate ¶
Propagate decides, per outgoing request, whether trace context is injected; see telemetry.TransportBuilder.Propagation. The default, nil, injects only to internal destinations (telemetry.PropagateInternal).
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Propagate(nil)
})}
_ = httpClient
func (*Builder) RetryIf ¶
RetryIf reports whether the outcome of an attempt should be retried: exactly one of the response and the error is non-nil. It never overrides the method and body rules of Transport, and is not called once the request context is done. The default, nil, retries transport errors and the status codes 408, 429, 502, 503, and 504.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.RetryIf(nil)
})}
_ = httpClient
func (*Builder) Telemetry ¶
Telemetry instruments each attempt as its own client span. The default, nil, follows telemetry.Default, resolved per request.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
httpClient := &http.Client{Transport: client.Transport(nil, func(transport *client.Builder) {
transport.Telemetry(nil)
})}
_ = httpClient
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc configures one Transport construction draft. Policies run once, synchronously in order; supplied nil functions panic, while an expanded nil slice supplies no policies. Retaining the draft cannot change the published transport. Services and function captures remain shared, not cloned.
Example:
var retries client.BuilderFunc = func(transport *client.Builder) {
transport.Attempts(5)
}
outgoing := &http.Client{Transport: client.Transport(nil, retries)}
_ = outgoing
type RequestFailedEvent ¶
type RequestFailedEvent struct {
// Method is the caller's request method, copied without normalization. Use it
// to classify an observed operation, not as proof that replay was safe.
//
// Example:
//
// failed := client.RequestFailedEvent{Method: http.MethodGet}
// fmt.Println(failed.Method)
Method string
// Host is the destination hostname from the request URL, without a port,
// path, or query. It is request metadata, not a resolved network address.
//
// Example:
//
// failed := client.RequestFailedEvent{Host: "api.example.com"}
// fmt.Println(failed.Host)
Host string
// Attempts is the positive number of attempts made for this round trip,
// including the initial attempt. Redirects have their own round trips.
//
// Example:
//
// failed := client.RequestFailedEvent{Attempts: 3}
// fmt.Println(failed.Attempts)
Attempts int
// StatusCode is the returned response status, or zero when Err is nonnil.
// A response reports the RetryIf predicate's final classification; arbitrary
// 4xx or 5xx statuses are not automatically failed events.
//
// Example:
//
// failed := client.RequestFailedEvent{StatusCode: http.StatusServiceUnavailable}
// fmt.Println(failed.StatusCode)
StatusCode int
// Err is the returned native transport error; it is nil with a response.
// Inspect its cause with errors.Is/errors.As before choosing recovery. Native
// error strings can contain sensitive details and are not sanitized here.
//
// Example:
//
// failed := client.RequestFailedEvent{Err: io.ErrUnexpectedEOF}
// if errors.Is(failed.Err, io.ErrUnexpectedEOF) {
// fmt.Println("incomplete transport response")
// }
Err error
}
RequestFailedEvent describes the final outcome reported by RequestFailed, not every retry attempt or a later response-body read error. Exactly one of Err and a nonzero StatusCode describes the outcome. Request cancellation suppresses publication; per-attempt timeout errors can still be reported while the parent request remains active. Applications own redaction of any native error text.
Example:
failed := client.RequestFailedEvent{
Method: http.MethodGet, Host: "api.example.com", Attempts: 3,
StatusCode: http.StatusServiceUnavailable,
}
fmt.Println(failed.Method, failed.Host, failed.Attempts, failed.StatusCode)