httpkit

package module
v1.5.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: Apache-2.0 Imports: 19 Imported by: 3

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 OpenTelemetry tracing integration.

Features

  • TLS/mTLS Support - Full TLS configuration including CA certificates, client certificates for mutual TLS authentication
  • Automatic Retry - Configurable retry logic with exponential backoff for transient failures
  • OpenTelemetry Integration - Built-in trace context propagation for distributed tracing
  • Configurable Options - Flexible client configuration with sensible defaults
  • Zero External Dependencies - Only depends on OpenTelemetry for tracing (optional)

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0)
  • go.opentelemetry.io/otel for trace-context propagation

Installation

go get github.com/soulteary/http-kit

Quick Start

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

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

// Make a request
req, _ := http.NewRequest("GET", client.GetBaseURL()+"/users", nil)
resp, err := client.Do(req)
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, _ := http.NewRequest("GET", client.GetBaseURL()+"/data", nil)
resp, err := client.DoRequestWithRetry(context.Background(), 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
OpenTelemetry Tracing
import (
    "github.com/soulteary/http-kit"
    "go.opentelemetry.io/otel"
)

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

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

// Inject trace context into request headers
req, _ := http.NewRequest("GET", client.GetBaseURL()+"/data", nil)
client.InjectTraceContext(ctx, req)

resp, err := client.Do(req)

API Reference

Client Options
Option Type Default Description
BaseURL string Required Base URL for all requests
Timeout time.Duration 10s Request timeout
UserAgent string "" User-Agent header value
Transport http.RoundTripper nil Custom HTTP transport
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
Do(req) Performs an HTTP request
DoRequestWithRetry(ctx, req, retryOpts) Performs an HTTP request with automatic retry
InjectTraceContext(ctx, req) Injects OpenTelemetry trace context into request headers
GetBaseURL() Returns the base URL
GetHTTPClient() Returns the underlying *http.Client
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/
├── client.go       # HTTP client with TLS/mTLS support
├── client_test.go  # Client tests
├── retry.go        # Retry policy, backoff, body replay, Retry-After
├── retry_test.go   # Retry tests
├── retry_body_test.go # Body-replay and drain regression tests
├── 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

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

Index

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

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.

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

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

InjectTraceContext injects OpenTelemetry trace context into request headers

type Options

type Options struct {
	BaseURL            string
	Timeout            time.Duration
	UserAgent          string
	Transport          http.RoundTripper
	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 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".

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 added in v1.5.0

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.

Jump to

Keyboard shortcuts

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