httpx API

httpx

package

API reference for the httpx package.

F
function

writeSelfSigned

writeSelfSigned generates a throwaway self-signed certificate for host and
writes cert and key PEM files into the test temp dir.

Parameters

host
string

Returns

certFile
string
keyFile
string
core/httpx/testhelper_test.go:19-60
func writeSelfSigned(t *testing.T, host string) (certFile, keyFile string)

{
	t.Helper()

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	tmpl := x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: host},
		DNSNames:     []string{host},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
	if err != nil {
		t.Fatal(err)
	}

	dir := t.TempDir()
	certFile = filepath.Join(dir, "cert.pem")
	keyFile = filepath.Join(dir, "key.pem")

	certOut, _ := os.Create(certFile)
	defer certOut.Close()
	if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
		t.Fatal(err)
	}

	keyDER, err := x509.MarshalECPrivateKey(key)
	if err != nil {
		t.Fatal(err)
	}
	keyOut, _ := os.Create(keyFile)
	defer keyOut.Close()
	if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
		t.Fatal(err)
	}
	return certFile, keyFile
}
I
interface

Hooks

Hooks is the request lifecycle extension contract.

core/httpx/vhost.go:23-30
type Hooks interface

Example

type Auth struct{ ... }
func (a Auth) BeforeRequest(r *http.Request)              { ... }
func (a Auth) HandleRequest(w, r) bool                    { ... }
func (a Auth) AfterRequest(w, r)                          { ... }

Methods

BeforeRequest
Method

Parameters

func BeforeRequest(...)
HandleRequest
Method

Returns

handled bool
func HandleRequest(...)
AfterRequest
Method
func AfterRequest(...)
S
struct

VirtualHost

VirtualHost binds one host name to a handler and its lifecycle hooks.

Host matching is case-insensitive and ignores the request port: a virtual
host registered as “example.com” answers “example.com”, “EXAMPLE.com” and
“example.com:8443”.

core/httpx/vhost.go:37-45
type VirtualHost struct

Fields

Name Type Description
Host string
Handler http.Handler
Hooks []Hooks
S
struct

VHostMux

VHostMux dispatches requests to virtual hosts by Host header. It is the
counterpart of a path router for the multi-tenant shape: instead of
path-based routes for one application, it routes host names to independent
handlers in one process.

core/httpx/vhost.go:51-55
type VHostMux struct

Methods

AddVirtualHost registers a virtual host. Registering the same host twice replaces the previous one. Host is normalized (lowercased, port stripped).

Parameters

func (*VHostMux) AddVirtualHost(vh VirtualHost)
{
	m.mu.Lock()
	defer m.mu.Unlock()
	m.hosts[normalizeHost(vh.Host)] = &vh
}

RemoveVirtualHost unregisters the virtual host for host, returning whether one existed.

Parameters

host string

Returns

bool
func (*VHostMux) RemoveVirtualHost(host string) bool
{
	m.mu.Lock()
	defer m.mu.Unlock()
	key := normalizeHost(host)
	if _, ok := m.hosts[key]; !ok {
		return false
	}
	delete(m.hosts, key)
	return true
}
Hosts
Method

Hosts returns the sorted list of registered host names.

Returns

[]string
func (*VHostMux) Hosts() []string
{
	m.mu.RLock()
	defer m.mu.RUnlock()
	hosts := make([]string, 0, len(m.hosts))
	for h := range m.hosts {
		hosts = append(hosts, h)
	}
	sort.Strings(hosts)
	return hosts
}
ServeHTTP
Method

ServeHTTP implements http.Handler.

func (*VHostMux) ServeHTTP(w http.ResponseWriter, r *http.Request)
{
	m.mu.RLock()
	vh, ok := m.hosts[normalizeHost(r.Host)]
	fallback := m.fallback
	m.mu.RUnlock()

	if !ok {
		if fallback != nil {
			fallback.ServeHTTP(w, r)
			return
		}
		http.NotFound(w, r)
		return
	}

	for _, h := range vh.Hooks {
		h.BeforeRequest(r)
	}

	handled := false
	for _, h := range vh.Hooks {
		if h.HandleRequest(w, r) {
			handled = true
			break
		}
	}

	if !handled && vh.Handler != nil {
		vh.Handler.ServeHTTP(w, r)
	}

	for i := len(vh.Hooks) - 1; i >= 0; i-- {
		vh.Hooks[i].AfterRequest(w, r)
	}
}

Fields

Name Type Description
mu sync.RWMutex
hosts map[string]*VirtualHost
fallback http.Handler
T
type

Option

Option configures a VHostMux.

core/httpx/vhost.go:58-58
type Option options.Option[VHostMux]
F
function

WithFallback

WithFallback sets the handler used when no virtual host matches the request
host. Without a fallback, unmatched hosts get 404.

Parameters

Returns

core/httpx/vhost.go:62-64
func WithFallback(h http.Handler) Option

{
	return func(m *VHostMux) { m.fallback = h }
}
F
function

NewVHostMux

NewVHostMux creates an empty mux.

Parameters

opts
...Option

Returns

core/httpx/vhost.go:67-73
func NewVHostMux(opts ...Option) *VHostMux

{
	m := &VHostMux{hosts: make(map[string]*VirtualHost)}
	for _, opt := range opts {
		opt(m)
	}
	return m
}
F
function

normalizeHost

normalizeHost lowercases and strips any port from a host value.

Parameters

host
string

Returns

string
core/httpx/vhost.go:146-152
func normalizeHost(host string) string

{
	host = strings.ToLower(strings.TrimSpace(host))
	if h, _, err := net.SplitHostPort(host); err == nil {
		return h
	}
	return host
}
S
struct

CertResolver

CertResolver maps host names to TLS key pairs, providing SNI-based
certificate selection for a listener that terminates TLS for many host
names.

core/httpx/vhost.go:157-160
type CertResolver struct

Methods

Add
Method

Add loads a certificate and key from disk and registers them for host. Host is normalized the same way as VHostMux hosts.

Parameters

host string
certFile string
keyFile string

Returns

error
func (*CertResolver) Add(host, certFile, keyFile string) error
{
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return fmt.Errorf("httpx: certificate for %s: %w", host, err)
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	r.certs[normalizeHost(host)] = &cert
	return nil
}
Remove
Method

Remove drops the certificate for host, returning whether one existed.

Parameters

host string

Returns

bool
func (*CertResolver) Remove(host string) bool
{
	r.mu.Lock()
	defer r.mu.Unlock()
	key := normalizeHost(host)
	if _, ok := r.certs[key]; !ok {
		return false
	}
	delete(r.certs, key)
	return true
}

GetCertificate implements the tls.Config.GetCertificate callback: it returns the certificate registered for the SNI server name, or nil (which fails the handshake) when no host name matches.

Parameters

Returns

func (*CertResolver) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
{
	r.mu.RLock()
	defer r.mu.RUnlock()
	cert, ok := r.certs[normalizeHost(hello.ServerName)]
	if !ok {
		return nil, fmt.Errorf("httpx: no certificate for %q", hello.ServerName)
	}
	return cert, nil
}

Fields

Name Type Description
mu sync.RWMutex
certs map[string]*tls.Certificate
F
function

NewCertResolver

NewCertResolver creates an empty resolver.

Returns

core/httpx/vhost.go:163-165
func NewCertResolver() *CertResolver

{
	return &CertResolver{certs: make(map[string]*tls.Certificate)}
}
S
struct

recordingHooks

core/httpx/vhost_test.go:12-16
type recordingHooks struct

Methods

BeforeRequest
Method

Parameters

func (recordingHooks) BeforeRequest(r *http.Request)
{
	*h.events = append(*h.events, h.name+":before")
}
HandleRequest
Method

Returns

bool
func (recordingHooks) HandleRequest(w http.ResponseWriter, r *http.Request) bool
{
	*h.events = append(*h.events, h.name+":handle")
	return h.handle
}
AfterRequest
Method
func (recordingHooks) AfterRequest(w http.ResponseWriter, r *http.Request)
{
	*h.events = append(*h.events, h.name+":after")
}

Fields

Name Type Description
name string
handle bool
events *[]string
F
function

TestVHostMuxDispatchesByHostIgnoringCaseAndPort

Parameters

core/httpx/vhost_test.go:31-54
func TestVHostMuxDispatchesByHostIgnoringCaseAndPort(t *testing.T)

{
	mux := NewVHostMux()
	called := false
	mux.AddVirtualHost(VirtualHost{
		Host: "Example.COM",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			called = true
			w.WriteHeader(http.StatusTeapot)
		}),
	})

	for _, host := range []string{"example.com", "EXAMPLE.com:8443"} {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/", nil)
		req.Host = host
		mux.ServeHTTP(rec, req)
		if rec.Code != http.StatusTeapot {
			t.Errorf("host %q: got %d, want %d", host, rec.Code, http.StatusTeapot)
		}
	}
	if !called {
		t.Error("virtual host handler was not called")
	}
}
F
function

TestVHostMuxUnknownHost

Parameters

core/httpx/vhost_test.go:56-74
func TestVHostMuxUnknownHost(t *testing.T)

{
	mux := NewVHostMux()
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Host = "unknown.example"
	mux.ServeHTTP(rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("got %d, want 404", rec.Code)
	}

	mux = NewVHostMux(WithFallback(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadGateway)
	})))
	rec = httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	if rec.Code != http.StatusBadGateway {
		t.Fatalf("fallback: got %d, want 502", rec.Code)
	}
}
F
function

TestVHostMuxHookOrderAndShortCircuit

Parameters

core/httpx/vhost_test.go:76-103
func TestVHostMuxHookOrderAndShortCircuit(t *testing.T)

{
	var events []string
	mux := NewVHostMux()
	mux.AddVirtualHost(VirtualHost{
		Host: "a.example",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			events = append(events, "vhost:handler")
		}),
		Hooks: []Hooks{
			recordingHooks{name: "first", handle: true, events: &events},
			recordingHooks{name: "second", events: &events},
		},
	})

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Host = "a.example"
	mux.ServeHTTP(rec, req)

	want := []string{
		"first:before", "second:before",
		"first:handle",                // short-circuits: no second:handle, no site:handler
		"second:after", "first:after", // reverse order
	}
	if !reflect.DeepEqual(events, want) {
		t.Fatalf("got %v, want %v", events, want)
	}
}
F
function

TestVHostMuxHooksPassThroughToHandler

Parameters

core/httpx/vhost_test.go:105-125
func TestVHostMuxHooksPassThroughToHandler(t *testing.T)

{
	var events []string
	mux := NewVHostMux()
	mux.AddVirtualHost(VirtualHost{
		Host: "a.example",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			events = append(events, "vhost:handler")
		}),
		Hooks: []Hooks{recordingHooks{name: "p", events: &events}},
	})

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Host = "a.example"
	mux.ServeHTTP(rec, req)

	want := []string{"p:before", "p:handle", "vhost:handler", "p:after"}
	if !reflect.DeepEqual(events, want) {
		t.Fatalf("got %v, want %v", events, want)
	}
}
F
function

TestVHostMuxRemoveSite

Parameters

core/httpx/vhost_test.go:127-139
func TestVHostMuxRemoveSite(t *testing.T)

{
	mux := NewVHostMux()
	mux.AddVirtualHost(VirtualHost{Host: "a.example", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})})
	if got := mux.Hosts(); !reflect.DeepEqual(got, []string{"a.example"}) {
		t.Fatalf("hosts: %v", got)
	}
	if !mux.RemoveVirtualHost("A.example:443") {
		t.Fatal("RemoveVirtualHost should report removal with normalized host")
	}
	if mux.RemoveVirtualHost("a.example") {
		t.Fatal("RemoveVirtualHost should report false for unknown host")
	}
}
F
function

TestCertResolverSNI

Parameters

core/httpx/vhost_test.go:141-162
func TestCertResolverSNI(t *testing.T)

{
	certFile, keyFile := writeSelfSigned(t, "a.example")

	r := NewCertResolver()
	if err := r.Add("A.example", certFile, keyFile); err != nil {
		t.Fatal(err)
	}

	cert, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "a.example"})
	if err != nil || cert == nil {
		t.Fatalf("SNI lookup failed: %v", err)
	}
	if _, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example"}); err == nil {
		t.Fatal("expected error for unknown SNI name")
	}
	if !r.Remove("a.example") {
		t.Fatal("expected Remove to succeed")
	}
	if _, err := r.GetCertificate(&tls.ClientHelloInfo{ServerName: "a.example"}); err == nil {
		t.Fatal("expected error after removal")
	}
}
F
function

TestCertResolverRejectsBadPair

Parameters

core/httpx/vhost_test.go:164-171
func TestCertResolverRejectsBadPair(t *testing.T)

{
	r := NewCertResolver()
	if err := r.Add("a.example", "/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
		t.Fatal("expected error for missing files")
	} else if got := err.Error(); !containsSub(got, "a.example") {
		t.Errorf("error %q does not name the host", got)
	}
}
F
function

containsSub

Parameters

s
string
sub
string

Returns

bool
core/httpx/vhost_test.go:173-175
func containsSub(s, sub string) bool

{
	return len(sub) == 0 || (len(s) >= len(sub) && fmt.Sprintf("%s", s) != "" && indexOf(s, sub) >= 0)
}
F
function

indexOf

Parameters

s
string
sub
string

Returns

int
core/httpx/vhost_test.go:177-184
func indexOf(s, sub string) int

{
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return i
		}
	}
	return -1
}
T
type

Middleware

Middleware wraps an http.RoundTripper.

core/httpx/client.go:13-13
type Middleware func(next http.RoundTripper) http.RoundTripper
S
struct

Client

Client is an extensible HTTP client with middleware and optional resiliency hooks.

core/httpx/client.go:16-28
type Client struct

Methods

WithRetry
Method

WithRetry attaches retry options to the client.

Parameters

opts ...func(*resiliency.RetryOptions)

Returns

func (*Client) WithRetry(opts ...func(*resiliency.RetryOptions)) *Client
{
	c.retryOpts = opts
	return c
}
WithBreaker
Method

WithBreaker attaches a circuit breaker to the client.

Returns

func (*Client) WithBreaker(b *resiliency.CircuitBreaker) *Client
{
	c.breaker = b
	return c
}
transport
Method

transport returns the cached composed middleware chain, building it on first use.

func (*Client) transport() http.RoundTripper
{
	c.buildOnce.Do(func() {
		rt := c.rt
		for i := len(c.mw) - 1; i >= 0; i-- {
			rt = c.mw[i](rt)
		}
		c.builtTransport = rt
	})
	return c.builtTransport
}
Do
Method

Do sends a request applying middleware and optional resiliency behaviors. When retry options are set, requests with a body must provide req.GetBody so the body can be re-read on each attempt. Requests without a body (e.g. GET) are retried unconditionally.

Parameters

Returns

error
func (*Client) Do(req *http.Request) (*http.Response, error)
{
	if len(c.retryOpts) > 0 && req.Body != nil && req.GetBody == nil {
		return nil, fmt.Errorf("httpx: retry requires req.GetBody to be set when request has a body")
	}

	rt := c.transport()
	var resp *http.Response
	attemptNumber := 0

	fn := func() error {
		// Clone the request and reset the body for each attempt so retries
		// send the complete payload rather than an already-consumed reader.
		attempt := req.Clone(req.Context())
		if attemptNumber > 0 && req.GetBody != nil {
			body, err := req.GetBody()
			if err != nil {
				return err
			}
			attempt.Body = body
		}
		attemptNumber++
		client := *c.client
		client.Transport = rt
		r, err := client.Do(attempt)
		if err != nil && r != nil && r.Body != nil {
			_ = r.Body.Close()
			r = nil
		}
		resp = r
		return err
	}

	var err error

	if c.breaker != nil {
		if len(c.retryOpts) > 0 {
			err = c.breaker.Execute(func() error {
				return resiliency.Retry(req.Context(), fn, c.retryOpts...)
			})
		} else {
			err = c.breaker.Execute(fn)
		}
		return resp, err
	}

	if len(c.retryOpts) > 0 {
		err = resiliency.Retry(req.Context(), fn, c.retryOpts...)
		return resp, err
	}

	err = fn()
	return resp, err
}

Fields

Name Type Description
client *http.Client
rt http.RoundTripper
mw []Middleware
retryOpts []func(*resiliency.RetryOptions)
breaker *resiliency.CircuitBreaker
builtTransport http.RoundTripper
buildOnce sync.Once
F
function

New

New creates a new Client wrapping c (or a default client with 15s timeout if nil).
Middleware options (WithRetry, WithBreaker) must be configured before the first
call to Do; the transport chain is assembled once on first use and cannot be
modified afterward.

Parameters

mw
...Middleware

Returns

core/httpx/client.go:34-45
func New(c *http.Client, mw ...Middleware) *Client

{
	if c == nil {
		c = &http.Client{Timeout: 15 * time.Second}
	}

	rt := c.Transport
	if rt == nil {
		rt = http.DefaultTransport
	}

	return &Client{client: c, rt: rt, mw: mw}
}
T
type

roundTripperFunc

core/httpx/middleware.go:11-11
type roundTripperFunc func(*http.Request) (*http.Response, error)
F
function

cloneRequest

Parameters

Returns

core/httpx/middleware.go:17-24
func cloneRequest(r *http.Request) *http.Request

{
	cloned := r.Clone(r.Context())
	cloned.Header = make(http.Header, len(r.Header))
	for k, v := range r.Header {
		cloned.Header[k] = append([]string(nil), v...)
	}
	return cloned
}
F
function

originalRequestURL

Parameters

request

Returns

core/httpx/middleware.go:40-49
func originalRequestURL(request *http.Request) *url.URL

{
	if request == nil {
		return nil
	}
	original := request.URL
	for response := request.Response; response != nil && response.Request != nil; response = response.Request.Response {
		original = response.Request.URL
	}
	return original
}
F
function

sameOrigin

Parameters

left
right

Returns

bool
core/httpx/middleware.go:51-57
func sameOrigin(left, right *url.URL) bool

{
	if left == nil || right == nil {
		return false
	}
	return strings.EqualFold(left.Scheme, right.Scheme) &&
		strings.EqualFold(left.Host, right.Host)
}
F
function

Logging

Logging returns a Middleware that logs each request and its duration.

Parameters

logger

Returns

core/httpx/middleware.go:60-75
func Logging(logger *slog.Logger) Middleware

{
	return func(next http.RoundTripper) http.RoundTripper {
		return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
			start := time.Now()
			resp, err := next.RoundTrip(r)
			dur := time.Since(start)
			requestURL := safeLogURL(r.URL)
			if err != nil {
				logger.Error("http request failed", "method", r.Method, "url", requestURL, "duration", dur, "error", err)
			} else {
				logger.Info("http request", "method", r.Method, "url", requestURL, "status", resp.StatusCode, "duration", dur)
			}
			return resp, err
		})
	}
}
F
function

safeLogURL

Parameters

value

Returns

string
core/httpx/middleware.go:77-87
func safeLogURL(value *url.URL) string

{
	if value == nil {
		return ""
	}
	safe := *value
	safe.User = nil
	safe.RawQuery = ""
	safe.ForceQuery = false
	safe.Fragment = ""
	return safe.String()
}
F
function

RequestID

RequestID returns a Middleware that injects a unique request ID into the given header if absent.

Parameters

header
string

Returns

core/httpx/middleware.go:90-101
func RequestID(header string) Middleware

{
	return func(next http.RoundTripper) http.RoundTripper {
		return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
			if r.Header.Get(header) == "" {
				cloned := cloneRequest(r)
				cloned.Header.Set(header, newRequestID())
				return next.RoundTrip(cloned)
			}
			return next.RoundTrip(r)
		})
	}
}
F
function

newRequestID

Returns

string
core/httpx/middleware.go:103-105
func newRequestID() string

{
	return time.Now().Format("20060102150405.000000")
}