Skip to content
thedevsaddamPublic

About

A small, fluent Go HTTP client built on net/http. Zero dependencies, typed JSON, safe retries.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

httpx 🚀

A small, fluent Go HTTP client built on net/http. Zero dependencies, typed JSON, safe retries.

Go Go Reference Release Dependencies Build Status License

Why httpx?

net/http is excellent but verbose: every call repeats the same request building, status checks, body limits, and cleanup. Bigger client libraries remove that boilerplate but bring dependencies and opinions with them.

httpx sits in between: a thin, fluent layer over net/http that you can read in an afternoon.

  • Zero dependencies. Standard library only, so no third-party dependencies to audit and no version conflicts.
  • Typed JSON with generics. httpx.ToJSON[T](...) decodes the response and closes the body for you.
  • Safe-by-default retries. Retries are off until you enable them, then limited to idempotent methods with replayable bodies. POST retries need an explicit .Retryable().
  • Bounded reads. JSON and error-body helpers cap response size (10 MiB by default) and return typed *HTTPError values.
  • Built-in diagnostics. Per-request httptrace timings, structured logging, and tracing hooks.
  • Still net/http underneath. Bring your own *http.Client or *http.Transport whenever you need to.

Before and after

Plain net/http:

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/v1/users/123", nil)
if err != nil {
    return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

resp, err := http.DefaultClient.Do(req)
if err != nil {
    return nil, err
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode > 299 {
    b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
    return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, b)
}

var user User
if err := json.NewDecoder(io.LimitReader(resp.Body, 10<<20)).Decode(&user); err != nil {
    return nil, err
}
return &user, nil

With httpx:

client := httpx.New(
    httpx.WithBaseURL("https://api.example.com/v1"),
    httpx.WithAuth(httpx.NewBearerAuth(token)),
    httpx.WithRetryPolicy(httpx.StandardRetryPolicy()),
)

user, err := httpx.ToJSON[User](client.GET("/users/123").Do(ctx))

Same behavior (status check, bounded read, body closed), plus retries with backoff, in two statements.

Features

  • Fluent builders for GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, and QUERY
  • Typed JSON decoding with ToJSON[T]
  • Non-2xx handling via HTTPError
  • Typed JSON error bodies with ErrorInto
  • Configurable timeouts and connection pooling
  • Optional retries with bounded backoff and idempotency-key support
  • Request and response hooks for instrumentation
  • Bearer, Basic, and API key authentication
  • Multipart uploads, form submissions, raw bodies, and query parameters
  • Request logging and httptrace diagnostics
  • Request inspection with ToText() and ToCURL()
  • Streaming response copies via Response.CopyTo

Requirements

  • Go 1.22 or newer

Installation

go get github.com/thedevsaddam/httpx

Quick start

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/thedevsaddam/httpx"
)

type Todo struct {
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func main() {
	client := httpx.New(
		httpx.WithBaseURL("https://jsonplaceholder.typicode.com"),
		httpx.WithTimeout(10*time.Second),
	)

	todo, err := httpx.ToJSON[Todo](
		client.GET("/todos/1").Do(context.Background()),
	)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Todo #%d: %s\n", todo.ID, todo.Title)
}

ToJSON[T] decodes a successful JSON response and closes the response body automatically.

Common request examples

GET with query parameters

resp, err := client.GET("/users").
	QueryParam("status", "active").
	QueryParam("limit", "20").
	Header("Accept", "application/json").
	Do(ctx)
if err != nil {
	return err
}
defer resp.Close()

if !resp.IsSuccess() {
	return fmt.Errorf("request failed: %s", resp.Status)
}

POST JSON

type CreateUser struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

user, err := httpx.ToJSON[User](
	client.POST("/users").
		JSON(CreateUser{Name: "Jane Doe", Email: "jane@example.com"}).
		Do(ctx),
)
if err != nil {
	return err
}

fmt.Println("Created user:", user.ID)

POST URL-encoded form

Use Form to send application/x-www-form-urlencoded data, such as OAuth tokens or simple form submissions.

resp, err := client.POST("/oauth/token").
	Form(map[string]string{
		"grant_type":    "client_credentials",
		"client_id":     "my-client",
		"client_secret": "my-secret",
	}).
	Do(ctx)
if err != nil {
	return err
}
defer resp.Close()

if !resp.IsSuccess() {
	return fmt.Errorf("token request failed: %s", resp.Status)
}

Form URL-encodes the values and sets Content-Type: application/x-www-form-urlencoded.

If you need multiple values for a single key, use FormValues instead:

resp, err := client.POST("/search").
	FormValues(url.Values{
		"tag": {"golang", "backend"},
	}).
	Do(ctx)

Use either Form or FormValues, not both in the same request.

Streaming responses

Do returns a response whose body can be read directly. Use CopyTo to stream a large response to a writer without buffering the entire body in memory:

resp, err := client.GET("/exports/archive").Do(ctx)
if err != nil {
	return err
}
if !resp.IsSuccess() {
	defer resp.Close()
	return fmt.Errorf("download failed: %s", resp.Status)
}

file, err := os.Create("archive.zip")
if err != nil {
	_ = resp.Close()
	return err
}
defer file.Close()

if _, err := resp.CopyTo(file); err != nil {
	return err
}

WithMaxResponseBytes applies to ToJSON and HTTP error body reads. It does not limit manual streaming.

Multipart uploads and raw bodies

Multipart upload

resp, err := client.POST("/upload").
	Field("user_id", "12345").
	Fields(url.Values{"tag": {"tag-1", "tag-2"}}).                         // repeated values
	File("avatar", "./avatar.png").                                        // from file path
	FileReader("attachments", bytes.NewReader(report), "report.pdf").      // from memory
	FileReader("attachments", logFile, "app.log").                         // from any reader
	FileReader("attachments", pdfReader, "report.pdf", "application/pdf"). // with content type
	Do(ctx)
if err != nil {
	return err
}
defer resp.Close()

if !resp.IsSuccess() {
	return fmt.Errorf("upload failed: %s", resp.Status)
}

The multipart implementation assembles the payload in memory. Consider this when uploading large files.

Raw request body

resp, err := client.POST("/messages").
	Header("Content-Type", "text/plain").
	Body(strings.NewReader("hello")).
	Do(ctx)
if err != nil {
	return err
}
defer resp.Close()

Handling HTTP errors

When ToJSON[T] receives a non-2xx response, it returns an *httpx.HTTPError. Use errors.As to inspect it:

user, err := httpx.ToJSON[User](client.GET("/users/{id}").PathParam("id", "1").Do(ctx))
if err != nil {
	var httpErr *httpx.HTTPError
	if errors.As(err, &httpErr) {
		log.Printf("HTTP %d for %s: %s", httpErr.StatusCode, httpErr.URL, httpErr.Body)
	}
	return err
}
_ = user

HTTPError.Body is a bounded excerpt, not necessarily the full response. Avoid logging it if it could contain secrets or personal information.

Decoding structured error bodies

Many APIs describe failures in a JSON body, such as {"code": "...", "message": "..."}. Pass a pointer to your own struct with ErrorInto and httpx fills it in when the response is not 2xx:

type APIError struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

var apiErr APIError
user, err := httpx.ToJSON[User](
	client.GET("/users/123").ErrorInto(&apiErr).Do(ctx),
)
if err != nil {
	if httpErr, ok := errors.AsType[*httpx.HTTPError](err); ok {
		if httpErr.Decoded {
			return fmt.Errorf("api error %s: %s (HTTP %d)", apiErr.Code, apiErr.Message, httpErr.StatusCode)
		}
		// Not JSON (an HTML page from a proxy, plain text, ...): use the raw excerpt.
		return fmt.Errorf("HTTP %d: %s", httpErr.StatusCode, httpErr.Body)
	}
	// network error, timeout, context.Canceled, context.DeadlineExceeded, and so on
	return err
}
_ = user

How it behaves:

  • The error is still an *httpx.HTTPError, so existing errors.As checks keep working. ErrorInto only adds the decoded struct and the Decoded flag.
  • Check httpErr.Decoded before reading your struct. It is true only when the body was decoded without error.
  • Only JSON responses are decoded: Content-Type of application/json or any +json type (such as application/problem+json). A missing Content-Type counts as JSON. HTML, plain text and other types are skipped and left in HTTPError.Body.
  • The whole error body is read for decoding, up to WithMaxResponseBytes (10 MiB by default). A body larger than that is not decoded. HTTPError.Body stays a short excerpt either way.
  • A 2xx response never touches your struct, and requests without ErrorInto behave as before.
  • It works with ToJSON, ToBytes and ToString. Pass a non-nil pointer; anything else makes Do return an error.
var apiErr APIError

// Any of these fills apiErr on a non-2xx response:
user, err := httpx.ToJSON[User](client.GET(u).ErrorInto(&apiErr).Do(ctx))
raw,  err := httpx.ToBytes(client.GET(u).ErrorInto(&apiErr).Do(ctx))
text, err := httpx.ToString(client.GET(u).ErrorInto(&apiErr).Do(ctx))

Request Inspection

httpx provides ToText() and ToCURL() to inspect outgoing HTTP requests without sending them. These methods are useful for debugging, troubleshooting API integrations, and reproducing requests.

ToText()

ToText() returns a human-readable representation of the outgoing HTTP request, including its method, URL, headers, and body.

req := httpx.New().
    POST("https://api.example.com/users").
    Header("Authorization", "Bearer your-token").
    JSON(map[string]any{
        "name": "John Doe",
        "email": "john@example.com",
    })

text, err := req.ToText(ctx)
if err != nil {
    log.Fatal(err)
}

fmt.Println(text)

Example output:

POST /users HTTP/1.1
Host: api.example.com
Authorization: Bearer your-token
Content-Type: application/json

{"email":"john@example.com","name":"John Doe"}

ToCURL()

ToCURL() returns an equivalent cURL command that you can copy into a terminal to reproduce the request.

curl, err := req.ToCURL(ctx)
if err != nil {
    log.Fatal(err)
}

fmt.Println(curl)

Example output:

curl -X 'POST' 'https://api.example.com/users' \
  -H 'Authorization: Bearer your-token' \
  -H 'Content-Type: application/json' \
  -d '{"email":"john@example.com","name":"John Doe"}'

Notes

  • Both methods build the outgoing request without sending it.
  • ToText() is useful for inspecting requests during development.
  • ToCURL() is useful for reproducing requests in a terminal. Redact secrets before sharing.
  • Inspecting a request buffers its body in memory, so avoid it for large uploads or streaming bodies.

Warning

ToText(), ToCURL(), and .Debug() output is not redacted. It can include Authorization headers, cookies, API keys, and personal data. Don't log or share it in production.

Client configuration

Configure the client once and reuse it across requests:

client := httpx.New(
	httpx.WithBaseURL("https://api.example.com/v1"),
	httpx.WithClientName("billing-service"),
	httpx.WithTimeout(10*time.Second),
	httpx.WithHeader("Content-Type", "application/json"),
	httpx.WithHeader("Accept", "application/json"),
	httpx.WithAuth(httpx.NewBearerAuth("<YOUR_TOKEN>")),
	httpx.WithMaxResponseBytes(5<<20), // 5 MiB for helper reads
	httpx.WithRetryPolicy(httpx.StandardRetryPolicy()),
	httpx.WithPoolConfig(httpx.PoolConfig{
		MaxIdleConns:          100,
		MaxIdleConnsPerHost:   20,
		MaxConnsPerHost:       50,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ResponseHeaderTimeout: 5 * time.Second,
	}),
)

Configuration options

Option Description
WithBaseURL(url) Base URL for relative request paths.
WithTimeout(d) Default request timeout; 30 seconds by default.
WithHeader(key, value) Default request header.
WithQueryParam(key, value) Default query parameter.
WithClientName(name) Sets the X-Client-Name header.
WithAuth(auth) Default authentication provider.
WithLogger(logger) Structured request logging.
WithTracer(tracer) Injects distributed-tracing context into requests.
WithPoolConfig(cfg) Configures the standard transport connection pool and timeouts.
WithMaxResponseBytes(n) Maximum body size read by JSON and HTTP-error helpers; default is 10 MiB.
WithRetryPolicy(policy) Enables bounded retries. Retries are disabled by default.
WithRequestHook(hook) Called before each network attempt.
WithResponseHook(hook) Called after response headers arrive.
WithTransport(transport) Supplies a custom *http.Transport.
WithHTTPClient(client) Supplies a custom *http.Client.

For PoolConfig, zero values keep the transport defaults. Pool settings apply when the client uses a standard *http.Transport.

Retries

MaxAttempts includes the initial request. StandardRetryPolicy() makes three total attempts, retries network errors and HTTP 429/502/503/504, and uses capped exponential backoff with full jitter. It also honors the Retry-After header, capped by MaxBackoff.

client := httpx.New(
	httpx.WithRetryPolicy(httpx.StandardRetryPolicy()),
)

For a custom policy, set Jitter: true to randomize each delay between zero and its exponential backoff cap, and RespectRetryAfter: true to honor Retry-After on retryable responses:

client := httpx.New(
	httpx.WithRetryPolicy(httpx.RetryPolicy{
		MaxAttempts:          3,
		InitialBackoff:       100 * time.Millisecond,
		MaxBackoff:           time.Second,
		RetryableStatusCodes: []int{429, 502, 503, 504},
		RetryNetworkErrors:   true,
		Jitter:               true,
		RespectRetryAfter:    true,
	}),
)

By default, retries are restricted to supported idempotent methods (GET, HEAD, OPTIONS, DELETE, and QUERY) and replayable request bodies. To explicitly opt a request such as POST into retries, use Retryable() and set an idempotency key when appropriate.

resp, err := client.POST("/orders").
	IdempotencyKey("order-12345").
	JSON(payload).
	Retryable().
	Do(ctx)

For operations that create payments, orders, or other side effects, prefer a server-supported idempotency key; the client cannot guarantee deduplication by itself.

Hooks

Use hooks for metrics, correlation IDs, or instrumentation:

client := httpx.New(
	httpx.WithRequestHook(func(ctx context.Context, req *http.Request) {
		req.Header.Set("X-Request-Source", "billing-service")
	}),
	httpx.WithResponseHook(func(ctx context.Context, resp *http.Response) {
		// Record status and latency in your metrics system.
	}),
)

Hooks may run concurrently, so protect any shared state they access.

Authentication

// Bearer token
client := httpx.New(httpx.WithAuth(httpx.NewBearerAuth("your-token")))

// Basic authentication
client = httpx.New(httpx.WithAuth(httpx.NewBasicAuth("username", "password")))

// API key authentication
client = httpx.New(httpx.WithAuth(httpx.NewAPIKeyAuth("X-API-Key", "your-api-key")))

You can implement the httpx.Auth interface to provide custom authentication. A request can override the client default with .Auth(auth).

Logging and tracing

Implement httpx.Logger to connect your preferred structured logger:

type MyLogger struct{}

func (l *MyLogger) Debug(ctx context.Context, msg string, kv ...any) {}
func (l *MyLogger) Info(ctx context.Context, msg string, kv ...any)  {}
func (l *MyLogger) Error(ctx context.Context, msg string, kv ...any) {}

Implement httpx.Tracer to inject tracing headers:

type MyTracer struct{}

func (t *MyTracer) Inject(ctx context.Context, req *http.Request) {
	// Inject trace context into req.
}

Call .Debug() on an individual request to log timing and connection diagnostics. Read the trace snapshot with resp.Trace() or the formatted report with resp.Debug(). Trace timings are best-effort, and TotalTime ends when response headers arrive, not when the body has been fully read.

Concurrency and response lifecycle

  • Reuse a configured client for concurrent requests; the underlying net/http client and transport support concurrent use.
  • Build a separate request builder for each concurrent operation. Do not mutate and send the same builder concurrently.
  • Hooks and custom auth, logger, and tracer implementations must be safe for concurrent use if shared by requests.
  • Always close response bodies when reading responses directly. ToJSON[T] closes the body automatically.
  • Response.Close() closes the body and attempts to drain a small amount to improve connection reuse.

Testing

Run the test suite and static checks from the module root:

go test ./...
go test -race ./...
go vet ./...

API reference

API Description
New(options...) Creates a client with pooling enabled by default.
client.GET(url) / POST / PUT / PATCH / DELETE / HEAD / OPTIONS / QUERY Creates a request builder.
builder.Header(key, value) / Headers(map) Sets request headers.
builder.QueryParam(key, value) / QueryParams(map) Adds query parameters.
builder.PathParam(key, value) Fills a {key} placeholder in the URL path.
builder.JSON(value) Encodes a JSON request body.
builder.Form(map) / FormValues(url.Values) Sends a URL-encoded form body. Use one, not both.
builder.Body(reader) Sets a raw request body.
builder.Field(key, value) / Fields(url.Values) Adds multipart form fields.
builder.File(field, path) Adds a multipart file from a path.
builder.FileReader(field, reader, filename[, contentType]) Adds a multipart file from any reader.
builder.Auth(auth) / Timeout(d) Overrides request authentication or timeout.
builder.Retryable() Opts a non-idempotent request (such as POST) into the retry policy.
builder.IdempotencyKey(key) Sets the Idempotency-Key header.
builder.ErrorInto(&v) Decodes a JSON error body into v on non-2xx responses.
builder.Debug() Enables request diagnostics.
builder.ToText(ctx) / ToCURL(ctx) Returns the request as text or a cURL command without sending it.
builder.Do(ctx) Executes the request.
resp.IsSuccess() Reports whether the status code is 2xx.
resp.CopyTo(writer) Streams the response body to a writer and closes it.
resp.Close() Closes the response body.
resp.Trace() / resp.Debug() Returns trace information or a formatted report.
ToJSON[T](resp, err) Decodes JSON and closes the response body.
ToBytes(resp, err) / ToString(resp, err) Reads the body as bytes or a string and closes it.

License

The project is licensed under the MIT License.

About

A small, fluent Go HTTP client built on net/http. Zero dependencies, typed JSON, safe retries.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages