httpx

package module
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

httpx

Everything net/http makes you wire by hand — timeouts, groups, middleware, JSON errors — without ever hiding net/http from you.

Go Reference

Status: v0, released. Production-track at Wigata InTech. Semver v0 applies: the API can still move between minor versions until v1.0.0, which lands only after surviving production use.

TL;DR

go get github.com/Wigata-Intech/w-tools/httpx
  • A server that's production-safe by default: every timeout on, graceful shutdown in one call
  • Route groups with shared prefixes and middleware over the stdlib ServeMux — every method routable, including RFC 10008 QUERY
  • JSON in and out: size-capped Bind, and errors as RFC 9457 application/problem+json by default
  • A standard middleware set: RealIP, RequestID, Trace (W3C traceparent), Recover, Logger — with request/response body logging that plugs into your logger's redaction — plus the gates: CORS, RateLimit (pluggable Limiter), and Idempotency (at-most-once execution per Idempotency-Key, pluggable Store)
  • BFF-ready HTML rendering (Renderer — templ satisfies it natively, html/template via the built-in adapter) and ErrorMap for one-line domain-error responses
  • An outbound client: pooling tuned for services (not the stdlib's 2 idle conns/host), a timeout you can't turn off, a circuit-breaker hook, trace propagation, and opt-in logging where redaction follows your logger
  • Handlers stay plain http.HandlerFunc — nothing to learn, nothing to eject from
  • Zero dependencies, permanently

What problem this solves

Since Go 1.22, ServeMux routes by method and pattern natively — you don't need a framework for routing anymore. But the stdlib still leaves real work to every service: http.Server ships with no timeouts (slowloris-open by default) and no shutdown wiring, there are no route groups sharing a prefix and middleware chain, and JSON boilerplate — capped decoding, a consistent error shape — gets reinvented per repo.

httpx fills exactly that list, and nothing more. It is deliberately not a framework: no custom handler signature, no context reinvention, no routing engine of its own.

How it solves it

s := httpx.New(httpx.Config{Addr: ":8080"}) // production timeouts on by default

api := s.Group("/api/v1")
api.Get("/orders/{id}", getOrder)     // r.PathValue("id"), stdlib-native
api.Post("/orders", createOrder)
api.Query("/orders/search", search)   // HTTP QUERY, RFC 10008 — filters in the body

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
_ = s.Run(ctx) // serves until SIGTERM, then drains gracefully

Inside a handler:

func createOrder(w http.ResponseWriter, r *http.Request) {
    var in OrderInput
    if err := httpx.Bind(r, &in); err != nil { // JSON, capped at 1 MiB by default
        httpx.Error(w, http.StatusBadRequest, "invalid order payload")
        return
    }
    httpx.JSON(w, http.StatusCreated, in)
}

Errors default to RFC 9457: {"type":"about:blank","title":"Bad Request","status":400,"detail":"..."} — and services with their own error format swap it via ErrorWriter.

Using the middleware

Middleware wires in canonical order — outermost first, so the logger sees the real client IP, the IDs, and the panic-turned-500 with its latency:

s.Use(
    middleware.RealIP(middleware.RealIPConfig{TrustedProxies: proxies}),
    middleware.RequestID(middleware.RequestIDConfig{}), // reuses inbound X-Request-ID, mints otherwise
    middleware.Trace(),                                 // W3C traceparent in, ids in ctx — no OTel dependency
    middleware.Logger(middleware.LoggerConfig{Log: log.Slog()}),
    middleware.Recover(middleware.RecoverConfig{Log: log.Slog()}),
)

Your own middleware plugs into the same slots — the chain type is the ecosystem's func(http.Handler) http.Handler, so anything written for that convention drops in unchanged. Per-middleware behavior and gotchas: middleware/README.md.

Using the client

The outbound half: pooling tuned for services, a timeout you can't turn off, a breaker seam, trace propagation, redaction-inheriting logging.

c := client.New(client.Config{Log: log.Slog(), Breaker: breaker})
resp, err := c.Get(ctx, "https://api.upstream.example/orders")

Build one client per upstream at boot and reuse it — the pool is the point. Details: client/README.md.

Recipe: pprof on an internal debug server

net/http/pprof mounts on httpx as-is — no adapter, no import side effects. Run it as a second, internal-only server in the same process: your public server keeps its strict timeouts and middleware chain, while the debug listener stays unreachable from outside and tolerant of long profile streams (a WriteTimeout shorter than ?seconds=30 would cut a CPU profile mid-capture):

debug := httpx.New(httpx.Config{
    Addr:         "127.0.0.1:6060",  // never behind the public proxy
    WriteTimeout: 2 * time.Minute,   // must outlast ?seconds=N profile streams
})
debug.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) // also serves heap, goroutine, allocs, mutex, block
debug.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
debug.HandleFunc("/debug/pprof/profile", pprof.Profile)
debug.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
debug.HandleFunc("/debug/pprof/trace", pprof.Trace)
go func() { _ = debug.Run(ctx) }() // same ctx: drains with the main server

Never expose pprof publicly — heap dumps can contain secrets held in memory, and CPU profiling is a free denial-of-service lever.

Why it matters

Because there's no lock-in in either direction: anything written for net/http drops into httpx unchanged, and anything written for httpx runs under bare net/http — ejecting costs you a router file, not a rewrite. Because the defaults are the safe ones: the dangerous zero values (no timeout, unbounded bodies) are not expressible. And because it speaks current standards from day one — RFC 10008 QUERY routed, bound, and validated per the spec's server rules (a QUERY without a Content-Type is rejected, as the RFC requires); RFC 9457 for every error body.

What it costs

Read it as a price list. Measured on a MacBook Pro — Apple M2 Pro (10 cores), 16 GB RAM, macOS 26.5.2, go1.26.6.

cd httpx && go test -run='^$' -bench=. -benchmem ./...
Raw output
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/httpx
cpu: Apple M2 Pro
BenchmarkServeMuxBaseline-10    	11014020	       108.7 ns/op	      18 B/op	       2 allocs/op
BenchmarkGroupRoute-10          	10988310	       113.5 ns/op	      18 B/op	       2 allocs/op
ok  	github.com/Wigata-Intech/w-tools/httpx	3.080s
ok  	github.com/Wigata-Intech/w-tools/httpx/client	0.422s
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/httpx/middleware
cpu: Apple M2 Pro
BenchmarkBareHandler-10               	 6723567	       173.8 ns/op	     512 B/op	       3 allocs/op
BenchmarkLogger-10                    	 1000000	      1164 ns/op	     673 B/op	       8 allocs/op
BenchmarkLoggerCapture-10             	  442272	      2873 ns/op	    2293 B/op	      40 allocs/op
BenchmarkCanonicalChain-10            	  400852	      3054 ns/op	    1907 B/op	      32 allocs/op
BenchmarkCanonicalChainParallel-10    	  429691	      2825 ns/op	    1911 B/op	      32 allocs/op
BenchmarkRateLimitParallel-10         	 3063607	       375.0 ns/op	     512 B/op	       3 allocs/op
ok  	github.com/Wigata-Intech/w-tools/httpx/middleware	8.270s
Situation ns/op allocs/op Meaning for you
Raw ServeMux routing ~109 2 The stdlib baseline
The same route through nested groups ~114 2 Grouping is free — parity within noise, identical allocations, because groups are registration-time sugar
Request floor (build + bare handler) ~174 3 What the middleware numbers subtract
Logger middleware, capture off ~1,164 8 ~1µs per request — almost all of it the JSON access line itself
Logger with request-body capture ~2,873 40 The opt-in costs ~1.7µs more: capture, parse, structured attr
Full canonical chain (RealIP → RequestID → Trace → Logger → Recover) ~3,054 32 Your whole production identity stack: ~3µs of overhead per request

The practical takeaway: the expensive thing in the stack is writing a log line, not the middleware machinery around it — and even the everything-on chain costs less than 0.3% of a 1ms handler.

Under concurrency the chain holds flat (~2.8–3.1µs/op from 1 to 8 parallel callers — throughput scales with cores) and RateLimit's single mutex stays sub-microsecond at 8 concurrent clients (~364 ns/op). Parallel variants of these benchmarks ship in the suite; run them with -cpu 1,4,8.

Idempotency adds ~3.3µs for the winning request (claim, capture, store) and serves a duplicate's replay in ~3.6µs without touching the handler — both invisible next to any real handler. Measured on the same machine, go1.26.6:

$ cd middleware && go test -run='^$' -bench=Idempotency -benchmem .
BenchmarkIdempotencyFirst-10     	  341473	      3340 ns/op	    7304 B/op	      38 allocs/op
BenchmarkIdempotencyReplay-10    	  305167	      3602 ns/op	    7391 B/op	      35 allocs/op

The two wire-input parsers (RealIP's forwarding headers, the W3C traceparent) are fuzzed:

Fuzzing — commands and raw output
$ go test -run='^$' -fuzz=FuzzRealIP -fuzztime=10s .
$ go test -run='^$' -fuzz=FuzzTraceparent -fuzztime=10s .

The promises

As of v0:

  • We never wrap or rename what net/http defines. Handlers, ResponseWriter, request types, mux patterns — the stdlib shapes are the API, always.
  • Safe by default. Every timeout on from the zero config; body reads capped; "no timeout" is not a thing you can configure.
  • Fail loud at boot, not silent in production. Misregistration panics at startup exactly like ServeMux; nothing degrades silently.
  • Zero dependencies. The go.mod stays empty — that's a feature, and it's permanent.

Runnable programs live in examples/: a REST service with ErrorMap and QUERY search, a BFF page, and the redaction proof — the Logger middleware feeding a captured request body through w-tools/logger's rules, password [REDACTED] in the access line with nobody writing a careful log call. The examples run from a clone of the repo — the committed go.work resolves the sibling modules locally.

templ users need no adapter at all — a generated component is a Renderer:

_ = httpx.Render(w, r, http.StatusOK, pages.Dashboard(user)) // templ.Component satisfies Renderer structurally

(No templ program ships in examples/ — it would put a third-party dependency in the repo, and rule one is zero of those.)

Coming next: x/circuitbreaker, the experimental breaker that plugs into the client's Breaker hook — the full plan is in ROADMAP.md.

Documentation

Overview

Package httpx is a thin layer over net/http: a server with production timeouts and graceful shutdown, route groups over ServeMux, middleware chaining, JSON helpers with RFC 9457 errors and domain-error mapping, body binding, and HTML rendering for BFF services.

Handlers stay plain http.HandlerFunc and patterns are ServeMux patterns — anything written for net/http works here unchanged, and anything written for httpx works under bare net/http.

Index

Constants

View Source
const (
	DefaultReadHeaderTimeout = 5 * time.Second
	DefaultReadTimeout       = 10 * time.Second
	DefaultWriteTimeout      = 30 * time.Second
	DefaultIdleTimeout       = 120 * time.Second
	DefaultShutdownGrace     = 15 * time.Second
	DefaultMaxHeaderBytes    = 1 << 20        // 1 MiB
	DefaultMaxBind           = int64(1) << 20 // 1 MiB

	// DefaultMaxBody caps body capture.
	DefaultMaxBody = 64 << 10 // 64 KiB
)

Defaults applied by New and Bind wherever config is zero-valued. Every default exists because Go's own zero (usually "no limit") is the wrong one for production; the numbers live here so they are documented API.

View Source
const MethodQuery = "QUERY"

MethodQuery is the HTTP QUERY method (RFC 10008): safe, idempotent queries carried in the request body. Go rc-1.27 added net/http.MethodQuery with the identical value; when this module's floor reaches 1.27, this constant becomes an alias for it — no caller changes either way.

Variables

View Source
var (
	ErrNotJSON       = errors.New("httpx: bind: content type is not JSON")
	ErrNoContentType = errors.New("httpx: bind: QUERY requires an explicit Content-Type (RFC 10008)")
	ErrTrailingData  = errors.New("httpx: bind: unexpected data after JSON body")
)

Bind error sentinels, asserted with errors.Is. Size-limit violations surface as *http.MaxBytesError (use errors.As); an empty body is io.EOF.

Functions

func Bind

func Bind(r *http.Request, v any, opts ...BindOption) error

Bind decodes a JSON request body into v, capped at DefaultMaxBind bytes unless overridden. It reads the body, so it serves POST, PUT, PATCH and QUERY (RFC 10008) identically. A missing Content-Type is assumed JSON — except on QUERY, where RFC 10008 requires servers to fail requests without one (ErrNoContentType). An explicit non-JSON Content-Type is rejected with ErrNotJSON.

Bind holds no ResponseWriter, so exceeding the cap does not mark the connection for closure the way the stdlib's 413 path does; a handler that wants that behavior sets "Connection: close" itself.

func Error

func Error(w http.ResponseWriter, status int, detail string)

Error writes a minimal RFC 9457 response: the status, its canonical title, and the given detail.

func JSON

func JSON(w http.ResponseWriter, status int, v any)

JSON writes v as an application/json response with the given status. If v cannot be marshaled, a 500 Problem is written instead — the encoding failure surfaces before any header goes out, never as a half-written body.

func Render

func Render(w http.ResponseWriter, r *http.Request, status int, c Renderer) error

Render writes an HTML response, streaming c with the request's context so a canceled request stops rendering. By the time c can fail the status line is already gone, so the returned error is for logging — never for writing a second response.

Types

type BindOption

type BindOption func(*bindOptions)

BindOption adjusts a single Bind call.

func MaxBody

func MaxBody(n int64) BindOption

MaxBody overrides the default request-body cap (DefaultMaxBind) for one Bind call.

type Config

type Config struct {
	Addr string

	ReadHeaderTimeout time.Duration
	ReadTimeout       time.Duration
	WriteTimeout      time.Duration
	IdleTimeout       time.Duration
	MaxHeaderBytes    int
	ShutdownGrace     time.Duration
}

Config configures New. The zero value of every field is a production default (see the Default constants), not Go's dangerous zero — New(Config{Addr: ":8080"}) is a server with timeouts on.

type ErrorMap

type ErrorMap struct {
	// contains filtered or unexported fields
}

ErrorMap translates domain errors into Problem responses: register the service's error taxonomy once at startup, then handlers respond with one line. Build it before serving — it is read-only afterward, so the request path takes no locks.

func NewErrorMap

func NewErrorMap() *ErrorMap

NewErrorMap returns an empty map; unmapped errors respond as a bare 500.

func (*ErrorMap) Map

func (m *ErrorMap) Map(target error, p Problem)

Map registers a translation: when errors.Is(err, target), respond with p. Entries match in registration order; the first match wins. A Problemer anywhere in the error tree always wins over the registry — an error that describes itself cannot be overridden by registration.

func (*ErrorMap) Respond

func (m *ErrorMap) Respond(w http.ResponseWriter, err error)

Respond writes the Problem for err, checking in order: the error's own Problemer, the registry via errors.Is, then a bare 500 — deliberately without err.Error(), which leaks internals into responses.

type ErrorWriter

type ErrorWriter func(w http.ResponseWriter, r *http.Request, status int, detail string)

ErrorWriter swaps the RFC 9457 default anywhere httpx itself writes an error on a service's behalf (middleware such as Recover and RateLimit). Nil always means Problem JSON.

type Group

type Group struct {
	// contains filtered or unexported fields
}

Group registers routes under a shared prefix and middleware chain. Groups are registration-time sugar: at request time there is only the one underlying ServeMux, so grouping costs nothing per request.

func (*Group) Delete

func (g *Group) Delete(pattern string, h http.HandlerFunc)

Delete registers a DELETE handler for the pattern.

func (*Group) Get

func (g *Group) Get(pattern string, h http.HandlerFunc)

Get registers a GET handler for the pattern.

func (*Group) Group

func (g *Group) Group(prefix string, mw ...Middleware) *Group

Group returns a child group. Its prefix appends to the parent's and its chain extends the parent's; the parent is never mutated. Prefixes are joined verbatim, so pass them without a trailing slash: "/api", "/v1".

func (*Group) Handle

func (g *Group) Handle(pattern string, h http.Handler)

Handle is the escape hatch for anything the typed helpers don't cover. Its signature mirrors ServeMux.Handle exactly: the pattern may carry its own method token, as in g.Handle("PROPFIND /dav/{path...}", h).

func (*Group) HandleFunc

func (g *Group) HandleFunc(pattern string, h http.HandlerFunc)

HandleFunc is Handle for a plain handler func, mirroring ServeMux.

func (*Group) Head

func (g *Group) Head(pattern string, h http.HandlerFunc)

Head registers a HEAD handler for the pattern.

func (*Group) Options

func (g *Group) Options(pattern string, h http.HandlerFunc)

Options registers an OPTIONS handler for the pattern.

func (*Group) Patch

func (g *Group) Patch(pattern string, h http.HandlerFunc)

Patch registers a PATCH handler for the pattern.

func (*Group) Post

func (g *Group) Post(pattern string, h http.HandlerFunc)

Post registers a POST handler for the pattern.

func (*Group) Put

func (g *Group) Put(pattern string, h http.HandlerFunc)

Put registers a PUT handler for the pattern.

func (*Group) Query

func (g *Group) Query(pattern string, h http.HandlerFunc)

Query registers a QUERY (RFC 10008) handler for the pattern.

type Middleware

type Middleware = func(http.Handler) http.Handler

Middleware is the standard chain shape. A type alias on purpose: any func(http.Handler) http.Handler — yours, chi's, the ecosystem's — is assignment-compatible without conversion.

type Problem

type Problem struct {
	Type     string `json:"type,omitempty"`     // URI reference; default "about:blank"
	Title    string `json:"title,omitempty"`    // short, stable per Type; default from status code
	Status   int    `json:"status"`             // HTTP status; default 500
	Detail   string `json:"detail,omitempty"`   // occurrence-specific explanation
	Instance string `json:"instance,omitempty"` // URI of this occurrence
}

Problem is an RFC 9457 error response (application/problem+json). The helpers fill sensible defaults; fill the struct yourself for a richer error taxonomy — the struct is the API, Error is convenience.

func (Problem) Respond

func (p Problem) Respond(w http.ResponseWriter)

Respond writes the problem with its own status and defaults filled.

type Problemer

type Problemer interface {
	Problem() Problem
}

Problemer lets an error type carry its own Problem mapping; ErrorMap checks it before the registry, unwrapping as errors.As does.

type RenderFunc

type RenderFunc func(ctx context.Context, w io.Writer) error

RenderFunc adapts a plain function to Renderer, the way http.HandlerFunc adapts handlers.

func (RenderFunc) Render

func (f RenderFunc) Render(ctx context.Context, w io.Writer) error

Render calls f.

type Renderer

type Renderer interface {
	Render(ctx context.Context, w io.Writer) error
}

Renderer is anything that can stream itself as HTML. templ components satisfy it natively (identical method); other engines adapt in a few lines — httpx never imports one.

func Template

func Template(t *template.Template, name string, data any) Renderer

Template adapts html/template to Renderer. The context is ignored: html/template has no cancellation of its own.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server wraps http.Server and the root route Group — Get, Post, Group and the rest are promoted from the embedded root. Register routes, then call Run.

func New

func New(cfg Config) *Server

New returns a Server ready to register routes. Zero-valued config fields get the package defaults.

func (*Server) HTTPServer

func (s *Server) HTTPServer() *http.Server

HTTPServer exposes the underlying http.Server for needs httpx does not wrap, such as ListenAndServeTLS or connection-state hooks.

func (*Server) Run

func (s *Server) Run(ctx context.Context) error

Run serves until ctx is canceled, then shuts down gracefully within the configured ShutdownGrace. Signal wiring belongs to the caller:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := srv.Run(ctx)

It returns the shutdown error on a graceful stop (nil when the drain succeeded), or the serve error if the server could not run at all.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP makes Server a plain http.Handler, usable under httptest or mounted inside another server without Run.

func (*Server) Use

func (s *Server) Use(mw ...Middleware)

Use appends middleware wrapped around the entire mux — every route, and unmatched (404) requests too. Must be called before Run or ServeHTTP.

Directories

Path Synopsis
Package client is httpx's outbound side: an http.Client wrapper with production transport tuning, a mandatory timeout, a circuit-breaker hook, W3C traceparent propagation from the request context, and opt-in request/response logging that inherits the supplied logger's redaction.
Package client is httpx's outbound side: an http.Client wrapper with production transport tuning, a mandatory timeout, a circuit-breaker hook, W3C traceparent propagation from the request context, and opt-in request/response logging that inherits the supplied logger's redaction.
Package middleware is httpx's standard middleware set.
Package middleware is httpx's standard middleware set.

Jump to

Keyboard shortcuts

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