HTTP client library for Go with retryable requests, automatic body draining, base URL resolution, and structured logging.
go get github.com/rakunlabs/ok- Retryable HTTP with exponential backoff and jitter, configurable per-client and per-request
- Automatic body drain/close on every request to ensure connection reuse
- Base URL resolution transparently applied at the transport layer
- Default headers and per-request context headers
- HTTP/2 support including h2c (unencrypted HTTP/2)
- Custom transport chain with user-provided RoundTripper wrappers
- Full TLS configuration with client certs, custom CA, and insecure skip verify
- Structured logging via
log/slogwith a pluggableLoggerinterface - Environment variable overrides (opt-in)
- Config struct with
cfgandjsontags for file/env deserialization - Easy to test with custom transports and
httptest
package main
import (
"fmt"
"net/http"
"github.com/rakunlabs/ok"
)
func main() {
client, err := ok.New(
ok.WithBaseURL("https://api.example.com"),
)
if err != nil {
panic(err)
}
req, _ := http.NewRequest(http.MethodGet, "/users", nil)
var users []struct {
Name string `json:"name"`
}
if err := client.Do(req, ok.ResponseFuncJSON(&users)); err != nil {
panic(err)
}
fmt.Println(users)
}Use ok.New with functional options:
client, err := ok.New(
ok.WithBaseURL("https://api.example.com/v1"),
ok.WithTimeout(30 * time.Second),
ok.WithRetryMax(3),
ok.WithHeaderSet("Authorization", "Bearer token"),
)The Do method executes a request and passes the response to a callback. The response body is always drained and closed after the callback returns, so you never need to close it yourself:
req, _ := http.NewRequest(http.MethodGet, "/resource", nil)
err := client.Do(req, func(resp *http.Response) error {
// Process the response here.
// Body is automatically drained and closed after this function returns.
fmt.Println(resp.StatusCode)
return nil
})ResponseFuncJSON checks for a 2xx status code and decodes the body:
var result MyStruct
err := client.Do(req, ok.ResponseFuncJSON(&result))Pass nil to only validate the status code without decoding:
err := client.Do(req, ok.ResponseFuncJSON(nil))If you have a plain *http.Client, use the package-level Do:
httpClient := &http.Client{}
err := ok.Do(httpClient, req, ok.ResponseFuncJSON(&result))The Config struct can be populated from configuration files or environment and converted to options. Fields use *bool for optional booleans to distinguish "not set" from false.
cfg := &ok.Config{
BaseURL: "https://api.example.com",
Timeout: 30 * time.Second,
RetryMax: 3,
}
client, err := cfg.New(
ok.WithHeaderSet("X-Custom", "value"), // additional options
)Or convert to an option for composition:
client, err := ok.New(
cfg.ToOption(),
ok.WithLogger(ok.NoopLogger{}),
)Config fields
| Field | Type | Tag | Description |
|---|---|---|---|
BaseURL |
string |
cfg:"base_url" |
Base URL for all requests |
Header |
map[string][]string |
cfg:"header" |
Default headers |
Timeout |
time.Duration |
cfg:"timeout" |
Overall client timeout |
EnableBaseURLCheck |
*bool |
cfg:"enable_base_url_check" |
Validate base URL has scheme and host |
EnableEnvValues |
*bool |
cfg:"enable_env_values" |
Enable environment variable reading |
InsecureSkipVerify |
*bool |
cfg:"insecure_skip_verify" |
Skip TLS certificate verification |
DisableRetry |
*bool |
cfg:"disable_retry" |
Disable automatic retry |
RetryMax |
int |
cfg:"retry_max" |
Max retry attempts (default: 4) |
RetryWaitMin |
time.Duration |
cfg:"retry_wait_min" |
Min wait between retries (default: 1s) |
RetryWaitMax |
time.Duration |
cfg:"retry_wait_max" |
Max wait between retries (default: 30s) |
RetryTimeout |
time.Duration |
cfg:"retry_timeout" |
Per-attempt timeout (0 = disabled) |
Proxy |
string |
cfg:"proxy" |
Proxy URL (ignored with HTTP/2) |
HTTP2 |
*bool |
cfg:"http2" |
Enable HTTP/2 including h2c |
TLS |
*TLSConfig |
cfg:"tls" |
TLS certificate paths |
Retry is enabled by default with exponential backoff and jitter. The retry transport buffers the request body and re-sends it on each attempt.
| Setting | Default |
|---|---|
| Max retries | 4 |
| Min wait | 1s |
| Max wait | 30s |
| Backoff | Exponential with full jitter |
| Retry policy | 5xx, 429, timeout, connection errors |
client, err := ok.New(
ok.WithDisableRetry(true),
)client, err := ok.New(
ok.WithRetryPolicy(func(ctx context.Context, resp *http.Response, err error) (bool, error) {
if resp != nil && resp.StatusCode == http.StatusConflict {
return true, nil // retry on 409
}
return ok.DefaultRetryPolicy(ctx, resp, err)
}),
)Override retry behavior for a single request using context:
ctx := ok.CtxWithRetryPolicy(context.Background(),
ok.OptionRetry.WithRetryDisable(),
)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "/resource", body)Force retry on specific status codes:
ctx := ok.CtxWithRetryPolicy(context.Background(),
ok.OptionRetry.WithRetryEnabledStatusCodes(http.StatusConflict),
ok.OptionRetry.WithRetryDisabledStatusCodes(http.StatusTooManyRequests),
)Set a timeout for each individual attempt (distinct from the overall client timeout):
client, err := ok.New(
ok.WithTimeout(2 * time.Minute), // overall timeout
ok.WithRetryTimeout(10 * time.Second), // per-attempt timeout
)Per-attempt timeout is skipped when HTTP/2 is enabled.
Enable HTTP/2 including h2c (unencrypted HTTP/2):
client, err := ok.New(
ok.WithHTTP2(true),
ok.WithBaseURL("http://localhost:8080"),
)When HTTP/2 is enabled:
- Proxy settings are ignored
- Per-attempt retry timeout is skipped
client, err := ok.New(
ok.WithInsecureSkipVerify(true),
)Using the Config struct:
cfg := &ok.Config{
TLS: &ok.TLSConfig{
CertFile: "/path/to/client.pem",
KeyFile: "/path/to/client-key.pem",
CAFile: "/path/to/ca.pem",
},
}
client, err := cfg.New()Or generate a *tls.Config directly:
tlsCfg := ok.TLSConfig{
CertFile: "client.pem",
KeyFile: "client-key.pem",
CAFile: "ca.pem",
}
cfg, err := tlsCfg.Generate()
if err != nil {
panic(err)
}
client, err := ok.New(ok.WithTLSConfig(cfg))Applied to every request (only if the request doesn't already set that header):
client, err := ok.New(
ok.WithHeaderSet("Authorization", "Bearer token"),
ok.WithHeaderSet("Accept", "application/json"),
ok.WithUserAgent("myapp/1.0"),
)header := http.Header{}
header.Set("X-Trace-Id", "abc-123")
ctx := ok.CtxWithHeader(context.Background(), header)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/resource", nil)For tracing propagation or other per-request modifications:
client, err := ok.New(
ok.WithInject(func(ctx context.Context, req *http.Request) {
// e.g., OpenTelemetry propagation
propagator.Inject(ctx, propagation.HeaderCarrier(req.Header))
}),
)Add RoundTripper wrappers to the transport chain:
client, err := ok.New(
ok.WithRoundTripper(func(ctx context.Context, rt http.RoundTripper) (http.RoundTripper, error) {
return myCustomTransport{Base: rt}, nil
}),
)Or provide a base transport:
client, err := ok.New(
ok.WithBaseTransport(myTransport),
)The transport chain is built innermost to outermost:
Base Transport (*http.Transport)
-> Per-Attempt Timeout Transport
-> Retry Transport
-> TransportOK (base URL, headers, inject)
-> User RoundTripper Wrappers
Default logger is slog.Default(). All retry attempts are logged at WARN level.
Any type implementing the Logger interface works:
type Logger interface {
Error(msg string, keysAndValues ...any)
Warn(msg string, keysAndValues ...any)
Info(msg string, keysAndValues ...any)
Debug(msg string, keysAndValues ...any)
}Since *slog.Logger satisfies this interface, you can pass any slog logger directly:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
client, err := ok.New(
ok.WithLogger(logger),
)client, err := ok.New(
ok.WithLogger(ok.NoopLogger{}),
)client, err := ok.New(
ok.WithRetryLog(false),
)Environment variable reading is disabled by default. Enable it with:
client, err := ok.New(
ok.WithEnableEnvValues(true),
)Or enable globally:
ok.EnableEnvValues = true| Variable | Description |
|---|---|
OK_BASE_URL |
Base URL (only if not explicitly set) |
OK_INSECURE_SKIP_VERIFY |
Skip TLS verification (true/false) |
OK_TIMEOUT |
Client timeout (e.g., 30s, 1m) |
OK_RETRY_DISABLE |
Disable retry (true/false) |
Environment variables have lower precedence than options set in code.
Use httptest.NewServer or provide a custom http.RoundTripper via WithBaseTransport for unit testing:
func TestMyAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer server.Close()
client, err := ok.New(
ok.WithBaseURL(server.URL),
ok.WithDisableRetry(true),
)
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "/api/status", nil)
var result map[string]string
if err := client.Do(req, ok.ResponseFuncJSON(&result)); err != nil {
t.Fatal(err)
}
if result["status"] != "ok" {
t.Errorf("got %q, want %q", result["status"], "ok")
}
}All options
| Option | Default | Description |
|---|---|---|
WithBaseURL(url) |
"" |
Base URL for resolving relative request URLs |
WithEnableBaseURLCheck(bool) |
false |
Validate base URL has scheme and host |
WithHeader(http.Header) |
empty | Set default headers (cloned) |
WithHeaderAdd(key, value) |
- | Add a default header value |
WithHeaderSet(key, value) |
- | Set a default header (replaces existing) |
WithHeaderDel(key) |
- | Remove a default header |
WithUserAgent(ua) |
"" |
Set User-Agent header |
WithHTTPClient(client) |
nil |
Use a custom *http.Client as base |
WithBaseTransport(rt) |
nil |
Set the innermost transport |
WithDisableRetry(bool) |
false |
Disable automatic retry |
WithRetryMax(n) |
4 |
Maximum retry attempts |
WithRetryWaitMin(d) |
1s |
Minimum backoff wait |
WithRetryWaitMax(d) |
30s |
Maximum backoff wait |
WithRetryTimeout(d) |
0 |
Per-attempt timeout |
WithRetryPolicy(fn) |
DefaultRetryPolicy |
Custom retry policy |
WithBackoff(fn) |
DefaultBackoff |
Custom backoff function |
WithRetryLog(bool) |
true |
Log retry attempts |
WithTimeout(d) |
0 |
Overall client timeout |
WithInsecureSkipVerify(bool) |
false |
Skip TLS verification |
WithTLSConfig(cfg) |
nil |
Custom *tls.Config |
WithRoundTripper(fn) |
nil |
Add transport wrapper |
WithProxy(url) |
"" |
Proxy URL |
WithHTTP2(bool) |
false |
Enable HTTP/2 + h2c |
WithInject(fn) |
nil |
Pre-request hook |
WithLogger(logger) |
slog.Default() |
Set logger |
WithEnableEnvValues(bool) |
false |
Enable env var reading |