httpkit

package module
v2.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 21, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

README

http-kit

Go Reference Go Report Card License codecov

中文文档

A lightweight Go HTTP client library with TLS/mTLS support, automatic retry with exponential backoff, and a hook for propagating context across services.

Features

  • TLS/mTLS Support - Full TLS configuration including CA certificates, client certificates for mutual TLS authentication
  • Automatic Retry - Exponential backoff with jitter, over a policy that knows which requests are safe to replay
  • Context Propagation - A Propagator hook applied to every request; OpenTelemetry lives in the otelprop subpackage
  • Configurable Options - Flexible client configuration with sensible defaults
  • A Standard-Library-Only Root Package - importing httpkit links nothing else

Layout

The root package depends on nothing outside the standard library. Anything needing a third-party module lives in a subpackage, so importing the root package never links a library your service does not use:

Package Brings in
github.com/soulteary/http-kit/v2 nothing — the standard library only
github.com/soulteary/http-kit/v2/otelprop go.opentelemetry.io/otel

Measured for a program importing only the root package, v1.5.0 against v2.0.0 (CGO_ENABLED=0 go build -trimpath, go1.27.0 linux/amd64):

v1.5.0 v2.0.0
Linked packages 227 191
Modules in the build 8 1
// indirect lines in your go.mod 7 0
Modules in your go.sum 11 1
Binary 9,487,279 B 7,918,888 B (−16.5%)

A program that does trace pays what it paid before: root package plus otelprop is 228 linked packages and a 9,494,047-byte binary — one package and 6,768 bytes more than v1.5.0.

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0)
  • go.opentelemetry.io/otel, only if you import otelprop

Installation

go get github.com/soulteary/http-kit/v2

Upgrading from v1? The import path changed and Client.InjectTraceContext is gone — see Upgrade Notes (v2.0.0).

Quick Start

Basic HTTP Client
import httpkit "github.com/soulteary/http-kit/v2"

// Create a simple client
client, err := httpkit.NewClient(&httpkit.Options{
    BaseURL:   "https://api.example.com",
    Timeout:   10 * time.Second,
    UserAgent: "myservice/1.0",
})
if err != nil {
    log.Fatal(err)
}

// Build a request against the base URL and send it
req, err := client.NewRequest(ctx, http.MethodGet, "v1/users", nil)
if err != nil {
    log.Fatal(err)
}
resp, err := client.Do(req)

BaseURL is optional. If you already hold absolute URLs, pass one to NewRequest — or skip the helper and build requests with net/http; Do and DoRequestWithRetry take any *http.Request.

client, _ := httpkit.NewClient(&httpkit.Options{Timeout: 10 * time.Second})
req, _ := client.NewRequest(ctx, http.MethodGet, "https://other.example.org/raw", nil)
Client with TLS/mTLS
// Server certificate verification against a custom CA
client, err := httpkit.NewClient(&httpkit.Options{
    BaseURL:       "https://secure-api.example.com",
    TLSCACertFile: "/path/to/ca.crt",
    TLSServerName: "secure-api.example.com",
})

// Mutual TLS — both cert and key are required
client, err = httpkit.NewClient(&httpkit.Options{
    BaseURL:       "https://mtls-api.example.com",
    TLSCACertFile: "/path/to/ca.crt",
    TLSClientCert: "/path/to/client.crt",
    TLSClientKey:  "/path/to/client.key",
})

NewClient validates the combination and returns an error rather than building a client that quietly does less than you asked:

  • Transport and the TLS options are mutually exclusive. A caller-supplied RoundTripper carries its own TLS configuration; configure TLS on the transport itself.
  • TLSClientCert and TLSClientKey must be set together. One without the other would produce a TLS config with no certificate in it.

When the TLS options are used, the transport is cloned from http.DefaultTransport, so proxy support (HTTPS_PROXY), HTTP/2 and the standard connection-pool limits are kept. TLS 1.2 is the floor.

Call opts.Validate() yourself if you want to check a configuration before constructing a client.

Automatic Retry
client, _ := httpkit.NewClient(&httpkit.Options{
    BaseURL: "https://api.example.com",
})

// Default retry options: 3 retries, exponential backoff with jitter
req, _ := client.NewRequest(ctx, http.MethodGet, "/data", nil)
resp, err := client.DoRequestWithRetry(ctx, req, nil)

// Or customize
retryOpts := &httpkit.RetryOptions{
    MaxRetries:        5,
    RetryDelay:        200 * time.Millisecond,
    MaxRetryDelay:     5 * time.Second,
    BackoffMultiplier: 2.0,
    RetryableStatusCodes: []int{
        http.StatusTooManyRequests,
        http.StatusServiceUnavailable,
        http.StatusGatewayTimeout,
    },
}
resp, err = client.DoRequestWithRetry(context.Background(), req, retryOpts)
What gets retried

A request is retried only when all three hold:

  1. The method is idempotent, or opted in. RFC 9110 idempotent methods — GET, HEAD, PUT, DELETE, OPTIONS, TRACE, and an empty method (net/http treats that as GET) — are retried. POST and PATCH are attempted once unless the request carries an Idempotency-Key header:

    req.Header.Set("Idempotency-Key", uuid.NewString())
    
  2. The body can be replayed. The first attempt consumes and closes req.Body, so a retry needs req.GetBody. http.NewRequest populates it for the common in-memory body types (*bytes.Buffer, *bytes.Reader, *strings.Reader). A body built from an arbitrary io.Reader has no GetBody, and such a request is attempted once rather than replayed empty.

  3. The failure is transient. A retryable status code, or a transport error that another attempt could plausibly fix. Permanent failures are not retried: certificate verification failures, an unsupported URL scheme, a cancelled caller context.

Backoff

The delay is RetryDelay × BackoffMultiplier^attempt, capped at MaxRetryDelay, with up to 20% jitter so a fleet retrying after the same upstream failure does not do so in lockstep. A multiplier below 1 means no growth.

A Retry-After response header wins over the computed delay — including Retry-After: 0 and an HTTP-date already in the past, both of which mean "retry now". It is still capped by MaxRetryDelay (unconditionally, a zero ceiling included), and an out-of-range value saturates rather than wrapping, so a malformed or adversarial upstream cannot step past your ceiling in either direction.

The caller's context is attached to the request, so cancelling it aborts an in-flight attempt, not just the sleep between attempts.

Discarded response bodies are drained so the connection returns to the pool, bounded by bytes, by a timeout, and by the context — draining never holds up a retry.

// Inspect the policy directly if you need to
delay := retryOpts.CalculateRetryDelay(2)
retryable := retryOpts.IsRetryableError(err, resp.StatusCode)
retryable = retryOpts.IsRetryableErrorCtx(ctx, err, resp.StatusCode) // tells a
// per-attempt http.Client.Timeout apart from the caller's own deadline
Context Propagation

A Propagator writes headers on every request the client sends. Configure it once, on the client — there is nothing to remember at the call site, which is what the old InjectTraceContext required and what made a forgotten call a trace that silently stopped at the network boundary.

type Propagator interface {
    Inject(ctx context.Context, h http.Header)
}
OpenTelemetry
import (
    httpkit "github.com/soulteary/http-kit/v2"
    "github.com/soulteary/http-kit/v2/otelprop"
    "go.opentelemetry.io/otel"
)

client, _ := httpkit.NewClient(&httpkit.Options{
    BaseURL:    "https://api.example.com",
    Propagator: otelprop.Global(), // uses otel.GetTextMapPropagator()
})

// Create a span in your application
tracer := otel.Tracer("my-service")
ctx, span := tracer.Start(context.Background(), "api-call")
defer span.End()

// traceparent is injected automatically
req, _ := client.NewRequest(ctx, http.MethodGet, "/data", nil)
resp, err := client.Do(req)

otelprop.Global() resolves the global propagator at each injection, not at construction time. That matters because otel.SetTextMapPropagator is normally called from main, after any client a package-level constructor built — capturing it early would freeze OpenTelemetry's no-op default and inject nothing for the life of the process.

To pin one per client instead:

Propagator: otelprop.New(propagation.NewCompositeTextMapPropagator(
    propagation.TraceContext{}, propagation.Baggage{},
))
Anything else

Propagation that is not OpenTelemetry needs no subpackage — it is a function. MultiPropagator composes several, in order.

Propagator: httpkit.MultiPropagator(
    otelprop.Global(),
    httpkit.PropagatorFunc(func(ctx context.Context, h http.Header) {
        if id, ok := ctx.Value(requestIDKey).(string); ok {
            h.Set("X-Request-ID", id)
        }
    }),
),

The propagator runs on every retry attempt, because a retried request is a new request on the wire. For a request you send some other way — through GetHTTPClient, say — call client.InjectContext(ctx, req) yourself; it is a no-op when no propagator is configured.

API Reference

Client Options
Option Type Default Description
BaseURL string "" Prefix NewRequest resolves a relative path against. Optional
Timeout time.Duration 10s Request timeout
UserAgent string "" User-Agent header value
Transport http.RoundTripper nil Custom HTTP transport
Propagator Propagator nil Injects headers into every request. See Context Propagation
TLSCACertFile string "" Path to CA certificate file
TLSClientCert string "" Path to client certificate file (for mTLS)
TLSClientKey string "" Path to client private key file (for mTLS)
TLSServerName string "" Server name for TLS verification
InsecureSkipVerify bool false Skip TLS certificate verification (not recommended)
Retry Options
Option Type Default Description
MaxRetries int 3 Maximum number of retry attempts
RetryDelay time.Duration 100ms Initial delay between retries
MaxRetryDelay time.Duration 2s Maximum delay between retries
BackoffMultiplier float64 2.0 Multiplier for exponential backoff
RetryableStatusCodes []int [408, 429, 500, 502, 503, 504] HTTP status codes that trigger retry
Client Methods
Method Description
NewClient(opts) Creates a new HTTP client with the given options
NewRequest(ctx, method, ref, body) Builds a request against BaseURL, with the User-Agent applied
ResolveURL(ref) The URL NewRequest would request for ref
Do(req) Performs an HTTP request, applying the User-Agent and Propagator
DoRequestWithRetry(ctx, req, retryOpts) Performs an HTTP request with automatic retry
InjectContext(ctx, req) Applies the configured Propagator to a request sent some other way
GetBaseURL() Returns the base URL
GetHTTPClient() Returns the underlying *http.Client

ref is either an absolute URL, used as it stands, or a path joined to BaseURL with exactly one slash between the two; its query and fragment survive. A relative ref with no BaseURL is an error rather than a request to nowhere.

Propagation
Symbol Description
Propagator Inject(ctx, http.Header) — the hook Do applies to every attempt
PropagatorFunc Adapts a plain function to Propagator
MultiPropagator(ps...) Applies each in order; nil entries are skipped, and no live entry yields nil
otelprop.Global() OpenTelemetry's global propagator, resolved at each injection
otelprop.New(p) A specific propagation.TextMapPropagator
Helpers
Function Description
DefaultOptions() Options with the defaults in the table above
DefaultRetryOptions() RetryOptions with the defaults in the table above
(*Options).Validate() Check a configuration without building a client
(*RetryOptions).CalculateRetryDelay(attempt) The backoff delay for an attempt, before jitter
(*RetryOptions).IsRetryableError(err, statusCode) Whether a failure is worth retrying
(*RetryOptions).IsRetryableErrorCtx(ctx, err, statusCode) The same, distinguishing a per-attempt timeout from the caller's deadline

Project Structure

http-kit/
├── doc.go             # Package documentation
├── client.go          # Client, Options, TLS/mTLS, request building
├── propagator.go      # The Propagator hook
├── retry.go           # Retry policy, backoff, body replay, Retry-After
├── otelprop/          # OpenTelemetry propagation — the only package that links otel
│   └── otelprop.go
├── example_test.go    # Runnable examples, verified by go test
├── regression_test.go # Gate: the root package must stay standard-library-only
├── CHANGELOG.md
├── SECURITY.md
├── go.mod             # Module definition
└── LICENSE            # Apache 2.0 license

Security Features

Feature Description
TLS Verification Supports custom CA certificates for server verification
mTLS Authentication Client certificate support for mutual TLS
Server Name Verification Configurable TLS server name for SNI
Secure Defaults TLS verification enabled by default; TLS 1.2 is the minimum version
No Silent Downgrade Transport together with TLS options is rejected, rather than dropping the TLS config
Replay Safety POST/PATCH are attempted once unless an Idempotency-Key is present
Bounded Backoff A Retry-After header cannot exceed MaxRetryDelay, and an out-of-range value saturates instead of wrapping negative
Explicit Propagation Headers are sent only when you configure a Propagator; see SECURITY.md on pointing one at a third party

Upgrade Notes (v2.0.0)

The import path changed, and one method was removed. Everything else is additive.

  • The module path is github.com/soulteary/http-kit/v2. Go encodes the major version in the import path, so every user must update it — including services that never traced anything.

    -import "github.com/soulteary/http-kit"
    +import httpkit "github.com/soulteary/http-kit/v2"
    
    go get github.com/soulteary/http-kit/v2
    
  • Client.InjectTraceContext(ctx, req) is gone. It called otel.GetTextMapPropagator directly, which is why every user of this package linked OpenTelemetry. Set a propagator on the client instead, and delete the per-request call:

     client, _ := httpkit.NewClient(&httpkit.Options{
         BaseURL: "https://api.example.com",
    +    Propagator: otelprop.Global(),
     })
    
     req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    -client.InjectTraceContext(ctx, req)
     resp, err := client.DoRequestWithRetry(ctx, req, retryOpts)
    

    It was not kept as a deprecated shim, and not only because a shim would have to import the dependency it was meant to remove. Keeping the name would have been worse: your code would still compile and would silently stop propagating anything until Options.Propagator was set. A compiler error is the point.

    If you are a library calling http-kit, consider accepting a httpkit.Propagator from your own caller and passing it through, rather than importing otelprop yourself — then the application decides, and your users who do not trace do not link OpenTelemetry either.

  • Client.InjectContext(ctx, req) is the direct replacement for a request you send some other way. It applies whatever Options.Propagator holds, and is a no-op when that is nil.

  • BaseURL is no longer required. NewClient used to reject a client without one, even though nothing in the package read it except GetBaseURL(). If you passed a placeholder to get past that check, delete it. If you asserted on the "base URL is required" error, that assertion now fails.

  • Client.NewRequest(ctx, method, ref, body) replaces http.NewRequest(method, client.GetBaseURL()+"/path", body). It joins with exactly one slash, keeps the query string, applies the User-Agent and leaves GetBody populated so the body stays replayable by DoRequestWithRetry.

  • Do no longer panics on a request with a nil Header. A *http.Request built as a struct literal has one, and setting the User-Agent on it panicked before anything was sent.

Upgrade Notes (v1.5.0)

Two of these change whether a request is sent at all, and one can turn a working configuration into a startup error. No API was removed; one method was added.

  • Transport plus any TLS option is now an error. Setting Transport silently discarded every TLS option — the branch building the TLS config was never reached, so an mTLS client certificate was never presented and nothing reported it. NewClient now returns an error. If you hit this, move the TLS configuration onto your transport.
  • TLSClientCert without TLSClientKey is now an error. It used to build a TLS config with no certificate in it.
  • Retried requests replay their body. The same *http.Request was reused across attempts, and the first Do consumes and closes req.Body — so a retried POST or PUT sent an empty body, or failed with ContentLength=N with Body length 0. Retries now rewind through req.GetBody; a request whose body cannot be replayed is attempted once.
  • POST and PATCH are no longer retried by default. They were, so a request that had already reached the server was duplicated. Add an Idempotency-Key header to opt a non-idempotent request back in. If you relied on automatic POST retries, set that header.
  • Permanent failures are no longer retried. Certificate verification failures, an unsupported URL scheme and a cancelled context were all retried, which only delayed the failure.
  • The backoff is actually exponential. It computed RetryDelay × (attempt+1) × BackoffMultiplier, which grows linearly whatever the multiplier — 200ms, 400ms, 600ms for a multiplier of 2 — despite the field name. It is now RetryDelay × BackoffMultiplier^attempt, with up to 20% jitter. Expect different (and larger) delays at higher attempt numbers, and don't assert on exact values.
  • Retry-After is honoured, capped by MaxRetryDelay. An explicit Retry-After: 0 and an elapsed HTTP-date both mean "retry now". An out-of-range value saturates instead of wrapping to a negative duration that would bypass the ceiling entirely.
  • The context aborts an in-flight attempt. The ctx argument was only used for the sleeps between attempts and never attached to the request.
  • An empty Method is treated as GET. net/http documents that for client requests; it was classified non-idempotent, so a 408, 429 or 5xx on what is in fact a GET was not retried.
  • The TLS transport keeps the standard defaults. It was a bare &http.Transport{}: no Proxy (so HTTPS_PROXY stopped working), no ForceAttemptHTTP2, and 2 idle connections per host. It is cloned from http.DefaultTransport now, with a TLS 1.2 floor.
  • Requirements said Go 1.26; go.mod requires 1.27.0.

Test Coverage

Run tests with coverage:

go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out

License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

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

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 NewClient

func NewClient(opts *Options) (*Client, error)

NewClient creates a new generic HTTP client

func (*Client) Do

func (c *Client) Do(req *http.Request) (*http.Response, error)

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

func (c *Client) GetBaseURL() string

GetBaseURL returns the base URL

func (*Client) GetHTTPClient

func (c *Client) GetHTTPClient() *http.Client

GetHTTPClient returns the underlying http.Client

func (*Client) InjectContext

func (c *Client) InjectContext(ctx context.Context, req *http.Request)

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

func (c *Client) ResolveURL(ref string) (string, error)

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

func DefaultOptions

func DefaultOptions() *Options

DefaultOptions returns default options

func (*Options) Validate

func (o *Options) Validate() error

Validate validates the options

type Propagator

type Propagator interface {
	Inject(ctx context.Context, h http.Header)
}

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

type PropagatorFunc func(ctx context.Context, h http.Header)

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

func (r *RetryOptions) IsRetryableErrorCtx(ctx context.Context, err error, statusCode int) bool

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.

Directories

Path Synopsis
Package otelprop propagates OpenTelemetry context on http-kit requests.
Package otelprop propagates OpenTelemetry context on http-kit requests.

Jump to

Keyboard shortcuts

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