Help us shape the future of Go! Take the 2026 Go Developer Survey

client

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 11 Imported by: 0

README

Client

arkoren.dev/core/http/client retries outgoing HTTP requests. client.Transport wraps any http.RoundTripper, so it plugs into a standard http.Client:

outbound := &http.Client{
	Transport: client.Transport(nil, func(transport *client.Builder) {
		transport.Attempts(5)
	}),
	Timeout: 10 * time.Second,
}

Only requests that are safe to repeat are retried: idempotent methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) and any request with an Idempotency-Key header, as long as the body can be rewound with GetBody. Transport errors and the status codes 408, 429, 502, 503, and 504 are retried with exponential backoff and full jitter; a 500 is not. Retry-After on a 429 or 503 replaces the backoff, unless it asks for more than 10 seconds or outlasts the context deadline, in which case the response is returned as is. Valid decimal values remain subject to that cap even when they exceed machine integer ranges. Signed or malformed numbers use the configured backoff instead.

This snippet uses net/http, time, and arkoren.dev/core/http/client. Only use an idempotency key when the destination implements deduplication for it. Inspect final HTTP statuses and close each returned response body; a non-2xx response is not itself a Go error. Body-read failures are not retried. A native writable 101 Switching Protocols body remains an io.ReadWriteCloser; read-only bodies do not gain writing. Close the body even after I/O errors. Closure and attempt-context cancellation run once, and repeated closes retain the first Close result. The native body controls how cancellation affects upgrade I/O.

Each attempt is its own telemetry client span, and resends carry http.request.resend_count. Retries are logged at Debug level without paths, queries, headers, or bodies.

Construction callbacks set attempts, backoff, per-attempt timeout, retry predicate, telemetry, and logger. Zero attempts selects three; a negative count permits one attempt. Nonpositive attempt timeouts disable that timeout, and negative custom backoff results mean no wait. Backoff keeps a zero initial delay at zero even if growth overflows; factors below one or NaN count as one. Its internal sampling bound is 1<<62 nanoseconds even without a configured maximum. See the package documentation for details.

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

Examples

Constants

This section is empty.

Variables

View Source
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

func (transport *Builder) AttemptTimeout(timeout time.Duration) *Builder

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

func (transport *Builder) Attempts(attempts int) *Builder

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

func (transport *Builder) Backoff(backoff func(attempt int) time.Duration) *Builder

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

func (transport *Builder) Events(bus *event.Bus) *Builder

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

func (transport *Builder) Logger(logger *slog.Logger) *Builder

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

func (transport *Builder) Propagate(propagate func(*http.Request) bool) *Builder

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

func (transport *Builder) RetryIf(retryIf func(*http.Response, error) bool) *Builder

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

func (transport *Builder) Telemetry(instance *telemetry.Telemetry) *Builder

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)

Directories

Path Synopsis
internal
retry
Package retry owns replay safety, attempt lifetime, and retry sequencing.
Package retry owns replay safety, attempt lifetime, and retry sequencing.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL