Documentation
¶
Overview ¶
Package httpkit provides an HTTP client for talking to other services: TLS and mTLS configuration, retries with exponential backoff and jitter over a policy that knows which requests are safe to replay, and a hook for propagating cross-process context.
Layout ¶
The root package depends on nothing outside the standard library. It provides Client, the retry policy in RetryOptions, and the Propagator seam described below.
Anything that needs a third-party module lives in a subpackage instead, so importing the root package never links a library the service does not use:
- github.com/soulteary/http-kit/v2/otelprop -- OpenTelemetry trace context, and with it go.opentelemetry.io/otel.
A service that makes HTTP calls but emits no spans pays nothing for tracing support existing; only importing the subpackage links it in. Measured for a program importing only the root package, v1.5.0 against v2.0.0: 36 fewer linked packages, 7 fewer modules, a 16.5% smaller binary, and an empty indirect requirement block in its own go.mod.
Getting started ¶
client, err := httpkit.NewClient(&httpkit.Options{
BaseURL: "https://api.example.com",
Timeout: 5 * time.Second,
UserAgent: "myservice/1.0",
})
req, err := client.NewRequest(ctx, http.MethodGet, "v1/users", nil)
resp, err := client.DoRequestWithRetry(ctx, req, httpkit.DefaultRetryOptions())
Options.BaseURL is optional. A caller that already holds absolute URLs passes them to Client.NewRequest unchanged, or skips it and builds requests with net/http.
What gets retried ¶
Retrying is not free of consequences, so Client.DoRequestWithRetry replays a request only when replaying it is safe: the method must be idempotent by RFC 9110, or carry an Idempotency-Key header, and the body must be replayable (nil, http.NoBody, or a GetBody the request carries -- http.NewRequest and Client.NewRequest populate it for the common in-memory body types). A streaming body is sent once.
Permanent failures -- a certificate that will not verify, an unsupported scheme, a cancelled context -- are not retried, because the only thing a second attempt adds is delay. Retry-After is honoured, and capped at RetryOptions.MaxRetryDelay like any other delay. A zero MaxRetryDelay is a zero ceiling, not the absence of one.
Propagating context across services ¶
Propagator is how trace headers, baggage or a request ID reach the wire. Client.Do applies the configured one to every attempt, so no call site has to remember to:
httpkit.NewClient(&httpkit.Options{
BaseURL: "https://api.example.com",
Propagator: otelprop.Global(),
})
This replaces v1's Client.InjectTraceContext, which called otel.GetTextMapPropagator directly. That coupled every user of this package to OpenTelemetry, and left a forgotten call site as a trace that silently stopped at the network boundary. Anything that is not OpenTelemetry is a PropagatorFunc; MultiPropagator composes several.
Example ¶
The common case: a client with a base URL, a request built against it, and the response.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
httpkit "github.com/soulteary/http-kit/v2"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s %s (User-Agent: %s)", r.Method, r.URL.Path, r.Header.Get("User-Agent"))
}))
defer srv.Close()
client, err := httpkit.NewClient(&httpkit.Options{
BaseURL: srv.URL,
Timeout: 5 * time.Second,
UserAgent: "myservice/1.0",
})
if err != nil {
panic(err)
}
req, err := client.NewRequest(context.Background(), http.MethodGet, "v1/users", nil)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode)
fmt.Println(string(body))
}
Output: 200 GET /v1/users (User-Agent: myservice/1.0)
Index ¶
- type Client
- func (c *Client) Do(req *http.Request) (*http.Response, error)
- func (c *Client) DoRequestWithRetry(ctx context.Context, req *http.Request, retryOpts *RetryOptions) (*http.Response, error)
- func (c *Client) GetBaseURL() string
- func (c *Client) GetHTTPClient() *http.Client
- func (c *Client) InjectContext(ctx context.Context, req *http.Request)
- func (c *Client) NewRequest(ctx context.Context, method, ref string, body io.Reader) (*http.Request, error)
- func (c *Client) ResolveURL(ref string) (string, error)
- type Options
- type Propagator
- type PropagatorFunc
- type RetryOptions
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a generic HTTP client with common functionality
func (*Client) Do ¶
Do performs an HTTP request, applying the client's User-Agent and Propagator first.
func (*Client) DoRequestWithRetry ¶
func (c *Client) DoRequestWithRetry(ctx context.Context, req *http.Request, retryOpts *RetryOptions) (*http.Response, error)
DoRequestWithRetry performs an HTTP request with retry logic.
ctx is applied to the request itself, not only to the waits between attempts, so cancelling it aborts an in-flight attempt.
A request is only retried when replaying it is safe: the method must be idempotent (or carry an Idempotency-Key), and the body must be replayable. Streaming bodies without GetBody are sent once.
Example ¶
Retries apply to idempotent requests with a replayable body. The delay grows as RetryDelay * BackoffMultiplier^attempt, capped at MaxRetryDelay, with up to 20% jitter subtracted -- so do not assert on exact timings.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
httpkit "github.com/soulteary/http-kit/v2"
)
func main() {
var attempts int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = fmt.Fprint(w, "ok")
}))
defer srv.Close()
client, err := httpkit.NewClient(&httpkit.Options{BaseURL: srv.URL})
if err != nil {
panic(err)
}
req, err := client.NewRequest(context.Background(), http.MethodGet, "/flaky", nil)
if err != nil {
panic(err)
}
resp, err := client.DoRequestWithRetry(context.Background(), req, httpkit.DefaultRetryOptions())
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
fmt.Println(attempts, resp.StatusCode, string(body))
}
Output: 3 200 ok
func (*Client) GetHTTPClient ¶
GetHTTPClient returns the underlying http.Client
func (*Client) InjectContext ¶
InjectContext applies the configured Propagator to req's headers, and is a no-op when there is none.
Do calls this on every attempt, so a request sent through Do or DoRequestWithRetry already carries the headers. Call it directly only for a request sent some other way -- through GetHTTPClient, say.
It replaces InjectTraceContext, which called otel.GetTextMapPropagator directly. Keeping that name for this behaviour would have been worse than removing it: code that compiled unchanged would have silently stopped propagating anything until Options.Propagator was set. See github.com/soulteary/http-kit/v2/otelprop.
func (*Client) NewRequest ¶
func (c *Client) NewRequest(ctx context.Context, method, ref string, body io.Reader) (*http.Request, error)
NewRequest builds a request against the client's base URL, with the client's User-Agent already applied.
ref is either an absolute URL, used as it stands, or a path that is appended to BaseURL with exactly one slash between the two; its query string and fragment are kept. Building the URL by hand -- which is what this package left callers to do, `client.GetBaseURL()+"/users"` -- produces "https://api.example.com/v1//users" the moment either side changes its mind about the slash.
It is a convenience, not a requirement: a request built any other way still works with Do and DoRequestWithRetry.
func (*Client) ResolveURL ¶
ResolveURL returns the URL NewRequest would request for ref. See NewRequest for the rule.
An absolute ref is returned unchanged, so a caller holding full URLs of its own can route them through the same path as relative ones. A relative ref with no BaseURL to resolve against is an error rather than a request to nowhere.
Example ¶
BaseURL is optional. A caller that already holds absolute URLs -- a configuration loader fetching from several hosts, say -- passes them straight to NewRequest.
package main
import (
"fmt"
httpkit "github.com/soulteary/http-kit/v2"
)
func main() {
client, err := httpkit.NewClient(&httpkit.Options{BaseURL: "https://api.example.com/v1/"})
if err != nil {
panic(err)
}
for _, ref := range []string{"users", "/users", "users?limit=10", "https://other.example.org/raw"} {
u, err := client.ResolveURL(ref)
if err != nil {
panic(err)
}
fmt.Println(u)
}
// No BaseURL: absolute references still resolve, relative ones are an error.
bare, err := httpkit.NewClient(&httpkit.Options{})
if err != nil {
panic(err)
}
if _, err := bare.ResolveURL("users"); err != nil {
fmt.Println("error:", err)
}
}
Output: https://api.example.com/v1/users https://api.example.com/v1/users https://api.example.com/v1/users?limit=10 https://other.example.org/raw error: cannot resolve "users": it is not an absolute URL and the client has no BaseURL
type Options ¶
type Options struct {
// BaseURL is the prefix Client.NewRequest resolves a relative path
// against, and what Client.GetBaseURL returns. It is optional: a client
// built without one still serves Do and DoRequestWithRetry for requests
// carrying absolute URLs, which is how callers that hold full URLs of
// their own already used this package. Requiring it only made them pass a
// placeholder that nothing read.
BaseURL string
Timeout time.Duration
UserAgent string
Transport http.RoundTripper
// Propagator injects cross-process context -- trace headers, baggage, a
// request ID -- into every request Do sends. Leave it nil to send none.
//
// For OpenTelemetry, use the otelprop subpackage:
// Propagator: otelprop.Global(). Nothing in this package imports
// OpenTelemetry, so a client that does not trace does not link it.
Propagator Propagator
TLSCACertFile string // For verifying server certificate
TLSClientCert string // Client certificate file for mTLS
TLSClientKey string // Client private key file for mTLS
TLSServerName string // Server name for TLS verification
InsecureSkipVerify bool // Skip TLS certificate verification (not recommended)
}
Options for creating a new Client
type Propagator ¶
Propagator injects cross-process context into an outgoing request's headers: W3C trace context, B3, baggage, a tenant or request ID -- whatever the surrounding system carries between services.
It is the seam that keeps this package free of any particular tracing library. Client.InjectTraceContext used to call otel.GetTextMapPropagator directly, which meant every user of this package linked OpenTelemetry -- its four modules and 130-odd packages -- whether or not they traced anything. The OpenTelemetry implementation now lives in the otelprop subpackage and is reached through this interface:
client, err := httpkit.NewClient(&httpkit.Options{
BaseURL: "https://api.example.com",
Propagator: otelprop.Global(),
})
Header rather than *http.Request on purpose: injecting context means setting headers, and an interface that asks for no more than that can be satisfied by anything -- including a plain function, via PropagatorFunc.
Inject must be safe for concurrent use: one Client serves many goroutines, and Client.Do calls it once per attempt.
func MultiPropagator ¶
func MultiPropagator(ps ...Propagator) Propagator
MultiPropagator returns a Propagator that applies each of ps in order.
Trace context and an application's own headers usually come from different places, and composing them should not require writing an adapter type. Nil entries are skipped; with no non-nil entry the result is nil, which Client treats as "no propagation" rather than as an error.
type PropagatorFunc ¶
PropagatorFunc adapts a plain function to Propagator, so a one-line convention needs no type of its own:
Propagator: httpkit.PropagatorFunc(func(ctx context.Context, h http.Header) {
if id, ok := ctx.Value(requestIDKey).(string); ok {
h.Set("X-Request-ID", id)
}
}),
Example ¶
A Propagator adds headers to every request the client sends, so no call site has to remember to. For OpenTelemetry trace context, use otelprop.Global() from the otelprop subpackage; anything else is a function.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
httpkit "github.com/soulteary/http-kit/v2"
)
func main() {
type tenantKey struct{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("server saw X-Tenant:", r.Header.Get("X-Tenant"))
}))
defer srv.Close()
client, err := httpkit.NewClient(&httpkit.Options{
BaseURL: srv.URL,
Propagator: httpkit.PropagatorFunc(func(ctx context.Context, h http.Header) {
if tenant, ok := ctx.Value(tenantKey{}).(string); ok {
h.Set("X-Tenant", tenant)
}
}),
})
if err != nil {
panic(err)
}
ctx := context.WithValue(context.Background(), tenantKey{}, "acme")
req, err := client.NewRequest(ctx, http.MethodGet, "/data", nil)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
_ = resp.Body.Close()
}
Output: server saw X-Tenant: acme
func (PropagatorFunc) Inject ¶
func (f PropagatorFunc) Inject(ctx context.Context, h http.Header)
Inject calls f, and does nothing when f is nil.
A nil func is reachable by accident -- PropagatorFunc(cfg.Inject) with an unset field is a non-nil Propagator wrapping nothing, which Client cannot tell apart from a real one. Calling it would panic on the first request rather than at configuration time, which is the worst place to find out.
type RetryOptions ¶
type RetryOptions struct {
MaxRetries int
RetryDelay time.Duration
MaxRetryDelay time.Duration
BackoffMultiplier float64
RetryableStatusCodes []int
}
RetryOptions configuration for retry logic
func DefaultRetryOptions ¶
func DefaultRetryOptions() *RetryOptions
DefaultRetryOptions returns default retry options
func (*RetryOptions) CalculateRetryDelay ¶
func (r *RetryOptions) CalculateRetryDelay(attempt int) time.Duration
CalculateRetryDelay returns the delay before the given retry attempt (0-based) using exponential backoff: RetryDelay * BackoffMultiplier^attempt, capped at MaxRetryDelay.
The previous formula was RetryDelay * (attempt+1) * BackoffMultiplier, which grows linearly no matter what the multiplier is -- 200ms, 400ms, 600ms for a multiplier of 2 -- despite the field name and the documented "exponential backoff".
Example ¶
The backoff curve, before jitter. Past MaxRetryDelay every attempt waits the ceiling -- including when MaxRetryDelay is left at zero, which is a zero ceiling and not "no ceiling".
package main
import (
"fmt"
httpkit "github.com/soulteary/http-kit/v2"
)
func main() {
opts := httpkit.DefaultRetryOptions()
for attempt := range 6 {
fmt.Println(attempt, opts.CalculateRetryDelay(attempt))
}
}
Output: 0 100ms 1 200ms 2 400ms 3 800ms 4 1.6s 5 2s
func (*RetryOptions) IsRetryableError ¶
func (r *RetryOptions) IsRetryableError(err error, statusCode int) bool
IsRetryableError checks if an error should trigger a retry.
A transport error is retryable only when it is transient. Certificate verification failures, an unsupported URL scheme, a cancelled context and similar permanent errors used to be retried the full MaxRetries times: the request could never succeed, so the only effect was to delay the failure.
func (*RetryOptions) IsRetryableErrorCtx ¶
IsRetryableErrorCtx is IsRetryableError with the caller's context, so a per-attempt http.Client.Timeout can be told apart from the caller's own deadline. Only the latter ends the call.