fetch is a lightweight, zero-dependency HTTP client for Go that brings the
ergonomics of JavaScript's axios to idiomatic Go.
It wraps net/http with a fluent builder API, typed errors, interceptors, and
first-class JSON support — without hiding the standard library from you.
err := fetch.Get("https://jsonplaceholder.typicode.com/posts").Scan(&posts)
if err != nil {
log.Fatal("Fetch error:", err)
}
log.Printf("First post: %+v", posts)
// -------------> With Client <------------- //
client := fetch.New("https://api.example.com")
var users []User
err := client.Get("/users").
WithParam("page", "1").
WithBearerToken(token).
Scan(&users)
// POST with JSON body
resp, err := client.Post("/users", User{Name: "Alice"}).Do()
// PUT
resp, err := client.Put("/users/1", User{Name: "Alice Updated"}).Do()
// PATCH
resp, err := client.Patch("/users/1", map[string]string{"name": "Alice"}).Do()
// DELETE (body is optional — pass nil)
resp, err := client.Delete("/users/1", nil).Do()- Features
- Requirements
- Installation
- Quick Start
- Usage Guide
- API Reference
- Contributing
- Changelog
- License
- Fluent builder API — chain methods naturally, no boilerplate
- All HTTP verbs —
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS - Body formats — JSON, form-encoded, multipart, plain text, raw
io.Reader - Typed errors — non-2xx responses become
*FetchErrorwith status helpers - Interceptors — hook into every request and response (logging, auth, retries)
- First-class JSON —
Scan(&v)encodes the body and decodes the response in one call - Auth helpers —
WithBearerToken,WithBasicAuth - Zero external dependencies — only the Go standard library
- Context-aware — every request respects
context.Contextfor cancellation and deadlines - Safe for concurrent use — share one
Clientacross all goroutines
| Requirement | Version |
|---|---|
| Go | 1.22 or higher |
No external dependencies. go.sum will be empty.
go get github.com/rixotech/fetch-go@latestThen import it in your code:
import "github.com/rixotech/fetch-go"package main
import (
"context"
"fmt"
"log"
"time"
"github.com/rixotech/fetch-go"
)
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
func main() {
// Create a reusable client
client, err := fetch.New("https://jsonplaceholder.typicode.com",
fetch.WithTimeout(10*time.Second),
)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// GET — decode response into a struct
var post Post
if err := client.GetWithContext(ctx, "/posts/1").Scan(&post); err != nil {
log.Fatal(err)
}
fmt.Println(post.Title)
// POST — send JSON body, decode created resource
var created Post
err = client.PostWithContext(ctx, "/posts", Post{Title: "Hello", Body: "World"}).
Scan(&created)
if err != nil {
log.Fatal(err)
}
fmt.Println(created.ID)
}A Client is bound to a base URL and reused across requests. Create it once and share it freely — it is safe for concurrent use.
client, err := fetch.New("https://api.example.com")With options:
client, err := fetch.New("https://api.example.com",
// Total timeout per request (default: 30s)
fetch.WithTimeout(15*time.Second),
// Headers sent with every request
fetch.WithDefaultHeaders(map[string]string{
"Accept": "application/json",
"User-Agent": "my-app/1.0",
}),
// Supply your own *http.Client (custom TLS, proxy, cookie jar, etc.)
fetch.WithHTTPClient(myHTTPClient),
// Disable automatic *FetchError for non-2xx responses
fetch.WithoutErrorOnStatus(),
)Every method returns a *Request builder. Nothing is sent until you call Do() or Scan().
// GET
resp, err := client.Get("/users").Do()
// POST with JSON body
resp, err := client.Post("/users", User{Name: "Alice"}).Do()
// PUT
resp, err := client.Put("/users/1", User{Name: "Alice Updated"}).Do()
// PATCH
resp, err := client.Patch("/users/1", map[string]string{"name": "Alice"}).Do()
// DELETE (body is optional — pass nil)
resp, err := client.Delete("/users/1", nil).Do()
// HEAD
resp, err := client.Head("/users").Do()
// OPTIONS
resp, err := client.Options("/users").Do()
// With context
ctx := context.Background()
resp, err := client.GetWithContext(ctx, "/users").Do()
// POST with JSON body
resp, err := client.PostWithContext(ctx, "/users", User{Name: "Alice"}).Do()
// PUT
resp, err := client.PutWithContext(ctx, "/users/1", User{Name: "Alice Updated"}).Do()
// PATCH
resp, err := client.PatchWithContext(ctx, "/users/1", map[string]string{"name": "Alice"}).Do()
// DELETE (body is optional — pass nil)
resp, err := client.DeleteWithContext(ctx, "/users/1", nil).Do()// Set multiple at once
resp, err := client.Get("/search").
WithParams(map[string]string{
"q": "golang",
"page": "1",
"limit": "20",
}).
Do()
// Or set individually
resp, err := client.Get("/search").
WithParam("q", "golang").
WithParam("page", "1").
Do()
// Resulting URL: https://api.example.com/search?limit=20&page=1&q=golang// Set multiple headers
resp, err := client.Get("/data").
WithHeaders(map[string]string{
"X-Request-ID": "abc-123",
"X-Tenant-ID": "acme",
}).
Do()
// Set a single header
resp, err := client.Get("/data").
WithHeader("X-Request-ID", "abc-123").
Do()
// Bearer token shorthand
resp, err := client.Get("/profile").
WithBearerToken("your-jwt-token").
Do()
// HTTP Basic Auth
resp, err := client.Get("/admin").
WithBasicAuth("username", "password").
Do()Per-request headers always override client-level default headers for the same key.
JSON (default for Post, Put, Patch):
type CreateUserReq struct {
Name string `json:"name"`
Email string `json:"email"`
}
resp, err := client.Post("/users", CreateUserReq{
Name: "Alice",
Email: "alice@example.com",
}).Do()Override / replace the body at any point in the chain:
// JSON
resp, err := client.Post("/data", nil).
WithJSONBody(map[string]any{"key": "value"}).
Do()
// URL-encoded form
resp, err := client.Post("/login", nil).
WithFormBody(map[string]string{
"username": "alice",
"password": "s3cr3t",
}).
Do()
// Multipart form (file upload)
fileBytes, _ := os.ReadFile("avatar.png")
resp, err := client.Post("/upload", nil).
WithMultipartBody(map[string]any{
"avatar": fileBytes, // []byte → file field
"caption": "My photo", // string → text field
}).
Do()
// Plain text
resp, err := client.Post("/logs", nil).
WithTextBody("something happened at 12:00").
Do()
// Raw reader (e.g. a file, a buffer)
f, _ := os.Open("data.bin")
defer f.Close()
resp, err := client.Post("/upload", nil).
WithRawBody(f, "application/octet-stream").
Do()Scan — the fastest path for JSON APIs:
var user User
// Executes the request AND decodes the JSON body in one call.
err := client.Get("/users/1").Scan(&user)Do — when you need the response itself:
resp, err := client.Get("/users/1").Do()
if err != nil {
return err
}
// Decode options (call exactly one — each closes the body)
var user User
err = resp.JSON(&user) // JSON → struct
text, err := resp.Text() // body as string
raw, err := resp.Bytes() // body as []byte
// Status helpers
resp.IsOK() // 2xx
resp.IsRedirect() // 3xx
resp.IsClientError() // 4xx
resp.IsServerError() // 5xx
// Underlying *http.Response (body not yet read)
resp.StatusCode // int
resp.Status // "200 OK"
resp.Header // http.Header
resp.Raw // *http.ResponseBy default, any non-2xx response is returned as *FetchError. This means
you never need to check resp.IsOK() manually.
var user User
err := client.Get("/users/99").Scan(&user)
if err != nil {
if fe, ok := fetch.AsFetchError(err); ok {
// HTTP-level error
fmt.Println(fe.StatusCode) // e.g. 404
fmt.Println(fe.Status) // e.g. "404 Not Found"
fmt.Println(string(fe.Body)) // raw response body
switch {
case fe.IsNotFound():
// handle 404
case fe.IsUnauthorized():
// handle 401 — refresh token, redirect to login, etc.
case fe.IsForbidden():
// handle 403
case fe.IsServerError():
// handle 5xx
}
}
// Network / timeout / interceptor error
return err
}Opt out of automatic error wrapping (handle every status yourself):
client, _ := fetch.New("https://api.example.com",
fetch.WithoutErrorOnStatus(),
)
resp, err := client.Get("/maybe-404").Do()
if err != nil {
return err // only network errors reach here
}
if !resp.IsOK() {
// decide what to do with 4xx / 5xx
}Interceptors run on every request/response made by a client — ideal for cross-cutting concerns like logging, auth token injection, and metrics.
// ── Request interceptor ───────────────────────────────────────────────────
// Injects a correlation ID into every outgoing request.
client.UseRequest(func(req *http.Request) (*http.Request, error) {
req.Header.Set("X-Request-ID", uuid.New().String())
return req, nil
})
// Refreshes an expired bearer token transparently.
client.UseRequest(func(req *http.Request) (*http.Request, error) {
token, err := tokenStore.Valid()
if err != nil {
return nil, err // aborts the request
}
req.Header.Set("Authorization", "Bearer "+token)
return req, nil
})
// ── Response interceptor ──────────────────────────────────────────────────
// Structured request logging.
client.UseResponse(func(resp *http.Response) (*http.Response, error) {
log.Printf("[fetch] %s %s → %s",
resp.Request.Method,
resp.Request.URL.Path,
resp.Status,
)
return resp, nil
})
// Multiple interceptors are executed in registration order.
client.UseRequest(interceptorA, interceptorB, interceptorC)For scripts and one-off requests where creating a Client is overkill, use
the package-level functions. They work without a base URL.
// No client needed — just pass a full URL.
var post Post
err := fetch.Get("https://jsonplaceholder.typicode.com/posts/1").
Scan(&post)
err = fetch.Post("https://example.com/events",
map[string]string{"event": "signup"},
).Do()
// Adjust the default timeout globally.
fetch.SetDefaultTimeout(5 * time.Second)| Method | Description |
|---|---|
fetch.New(baseURL, ...Option) |
Create a new client |
client.UseRequest(...fn) |
Register request interceptor(s) |
client.UseResponse(...fn) |
Register response interceptor(s) |
| Option | Description | Default |
|---|---|---|
WithTimeout(d) |
Request timeout | 30s |
WithHTTPClient(c) |
Custom *http.Client |
built-in |
WithDefaultHeaders(m) |
Headers sent with every request | — |
WithoutErrorOnStatus() |
Disable *FetchError for non-2xx |
enabled |
| Method | Description |
|---|---|
WithParam(key, value) |
Set a single query parameter |
WithParams(map) |
Set multiple query parameters |
WithHeader(key, value) |
Set a single header |
WithHeaders(map) |
Set multiple headers |
WithBearerToken(token) |
Set Authorization: Bearer <token> |
WithBasicAuth(user, pass) |
Set HTTP Basic Auth header |
WithJSONBody(v) |
Set a JSON-encoded body |
WithFormBody(map) |
Set a URL-encoded form body |
WithMultipartBody(map) |
Set a multipart/form-data body |
WithTextBody(s) |
Set a plain-text body |
WithRawBody(r, contentType) |
Set an arbitrary io.Reader body |
Do() |
Execute → (*Response, error) |
Scan(v) |
Execute + JSON-decode → error |
| Method | Description |
|---|---|
JSON(v) |
Decode body as JSON into v |
XML(v) |
Decode body as XML into v |
Text() |
Read body as string |
Bytes() |
Read body as []byte |
IsOK() |
true for 2xx |
IsRedirect() |
true for 3xx |
IsClientError() |
true for 4xx |
IsServerError() |
true for 5xx |
| Field / Method | Description |
|---|---|
StatusCode int |
HTTP status code |
Status string |
HTTP status string (e.g. "404 Not Found") |
Body []byte |
Raw response body |
Header http.Header |
Response headers |
IsNotFound() |
true for 404 |
IsUnauthorized() |
true for 401 |
IsForbidden() |
true for 403 |
IsServerError() |
true for 5xx |
fetch.AsFetchError(err) |
Unwrap any error to *FetchError |
Contributions are welcome and appreciated. Please follow these steps.
git clone https://github.com/rixotech/fetch-go.git
cd fetchgit checkout -b feat/your-feature-name
# or for bug fixes:
git checkout -b fix/what-was-broken- Keep the public API backward compatible unless the change is intentional and documented.
- Add or update tests for every changed behaviour.
- Ensure all exported symbols have Go doc comments.
# All tests must pass
go test ./... -race -count=1
# No vet warnings
go vet ./...
# Format your code
gofmt -w .We follow Conventional Commits:
git commit -m "feat: add WithRetry option for automatic retries"
git commit -m "fix: nil pointer when base URL has trailing slash"
git commit -m "docs: add multipart upload example to README"
git commit -m "test: cover 401 interceptor refresh scenario"Push your branch and open a PR against main. Include:
- What the change does
- Why it is needed
- Any breaking changes (if applicable)
Open an issue at github.com/rixotech/fetch-go/issues and include:
- Go version (
go version) - OS and architecture
- A minimal code snippet that reproduces the bug
- Expected vs actual behaviour
Open a GitHub Discussion before opening a PR for large changes, so the design can be agreed on first.
- Fluent request builder with full HTTP verb support
- JSON, form, multipart, text, and raw body types
- Typed
*FetchErrorwith status helpers - Request and response interceptor chains
WithBearerTokenandWithBasicAuthauth helpersScan(v)one-liner for JSON decode- Package-level convenience API
Released under the MIT License. Copyright (c) 2026 RixoTech.
Made with ☕ and Go · Documentation · Report a Bug