A small, fluent Go HTTP client built on net/http. Zero dependencies, typed JSON, safe retries.
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.
POSTretries need an explicit.Retryable(). - Bounded reads. JSON and error-body helpers cap response size (10 MiB by default) and return typed
*HTTPErrorvalues. - Built-in diagnostics. Per-request
httptracetimings, structured logging, and tracing hooks. - Still
net/httpunderneath. Bring your own*http.Clientor*http.Transportwhenever you need to.
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, nilWith 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.
- Fluent builders for
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS, andQUERY - 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
httptracediagnostics - Request inspection with
ToText()andToCURL() - Streaming response copies via
Response.CopyTo
- Go 1.22 or newer
go get github.com/thedevsaddam/httpxpackage 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.
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)
}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)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.
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.
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.
resp, err := client.POST("/messages").
Header("Content-Type", "text/plain").
Body(strings.NewReader("hello")).
Do(ctx)
if err != nil {
return err
}
defer resp.Close()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
}
_ = userHTTPError.Body is a bounded excerpt, not necessarily the full response. Avoid logging it if it could contain secrets or personal information.
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
}
_ = userHow it behaves:
- The error is still an
*httpx.HTTPError, so existingerrors.Aschecks keep working.ErrorIntoonly adds the decoded struct and theDecodedflag. - Check
httpErr.Decodedbefore reading your struct. It istrueonly when the body was decoded without error. - Only JSON responses are decoded:
Content-Typeofapplication/jsonor any+jsontype (such asapplication/problem+json). A missingContent-Typecounts as JSON. HTML, plain text and other types are skipped and left inHTTPError.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.Bodystays a short excerpt either way. - A 2xx response never touches your struct, and requests without
ErrorIntobehave as before. - It works with
ToJSON,ToBytesandToString. Pass a non-nil pointer; anything else makesDoreturn 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))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() 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() 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"}'- 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.
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,
}),
)| 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.
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.
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.
// 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).
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.
- Reuse a configured client for concurrent requests; the underlying
net/httpclient 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.
Run the test suite and static checks from the module root:
go test ./...
go test -race ./...
go vet ./...| 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. |
The project is licensed under the MIT License.