Resilient HTTP client with automatic retries, OpenTelemetry instrumentation, and middleware support built on Resty.
The rest package provides a production-ready HTTP client with built-in resilience patterns, observability, and extensibility through middleware. Built on top of go-resty, it adds OpenTelemetry tracing, metrics, and customizable request/response processing.
- Automatic Retries: Configurable retry logic with exponential backoff
- OpenTelemetry Integration: Distributed tracing and metrics
- Middleware System: Extensible request/response processing
- Timeout Management: Request-level timeout configuration
- Thread-Safe: Concurrent-safe middleware management
- Flexible API: Support for all HTTP methods
go get github.com/jasoet/pkg/v2/restpackage main
import (
"context"
"github.com/jasoet/pkg/v2/rest"
)
func main() {
// Create client with default config
client := rest.NewClient()
// Make request
ctx := context.Background()
response, err := client.MakeRequestWithTrace(
ctx,
"GET",
"https://api.example.com/users",
"",
nil,
)
if err != nil {
panic(err)
}
fmt.Println(response.String())
}import (
"time"
"github.com/jasoet/pkg/v2/rest"
)
config := rest.Config{
RetryCount: 3,
RetryWaitTime: 1 * time.Second,
RetryMaxWaitTime: 5 * time.Second,
Timeout: 30 * time.Second,
}
client := rest.NewClient(
rest.WithRestConfig(config),
)import (
"github.com/jasoet/pkg/v2/rest"
"github.com/jasoet/pkg/v2/otel"
)
// Setup OTel
otelConfig := otel.NewConfig("my-service").
WithTracerProvider(tracerProvider).
WithMeterProvider(meterProvider)
// Create client with OTel
client := rest.NewClient(
rest.WithOTelConfig(otelConfig),
)
// All requests are automatically traced
response, err := client.MakeRequestWithTrace(ctx, "GET", url, "", nil)type Config struct {
RetryCount int // Number of retry attempts
RetryWaitTime time.Duration // Initial retry wait time
RetryMaxWaitTime time.Duration // Maximum retry wait time
Timeout time.Duration // Request timeout
// Optional: Enable OpenTelemetry (nil = disabled)
OTelConfig *otel.Config
}DefaultRestConfig() returns:
- RetryCount: 1
- RetryWaitTime: 2 seconds
- RetryMaxWaitTime: 10 seconds
- Timeout: 30 seconds// Set configuration
WithRestConfig(config Config)
// Add single middleware
WithMiddleware(middleware Middleware)
// Set multiple middlewares
WithMiddlewares(middlewares ...Middleware)
// Enable OpenTelemetry
WithOTelConfig(cfg *otel.Config)// Make HTTP request with tracing
MakeRequestWithTrace(
ctx context.Context,
method string,
url string,
body string,
headers map[string]string,
) (*resty.Response, error)
// Get underlying Resty client
GetRestClient() *resty.Client
// Get current configuration
GetRestConfig() *Config
// Middleware management
AddMiddleware(middleware Middleware)
SetMiddlewares(middlewares ...Middleware)
GetMiddlewares() []MiddlewareLogs request and response details:
client := rest.NewClient(
rest.WithMiddleware(rest.NewLoggingMiddleware()),
)
// Logs:
// - Method, URL
// - Status code
// - Duration
// - ErrorsPlaceholder middleware for testing:
client := rest.NewClient(
rest.WithMiddleware(rest.NewNoOpMiddleware()),
)Automatically added when OTelConfig is provided:
- OTelTracingMiddleware - Distributed tracing
- OTelMetricsMiddleware - HTTP client metrics
- OTelLoggingMiddleware - Structured logging
Implement the Middleware interface:
type Middleware interface {
BeforeRequest(
ctx context.Context,
method string,
url string,
body string,
headers map[string]string,
) context.Context
AfterRequest(ctx context.Context, info RequestInfo)
}Example:
type AuthMiddleware struct {
apiKey string
}
func (m *AuthMiddleware) BeforeRequest(
ctx context.Context,
method string,
url string,
body string,
headers map[string]string,
) context.Context {
headers["Authorization"] = "Bearer " + m.apiKey
return ctx
}
func (m *AuthMiddleware) AfterRequest(
ctx context.Context,
info RequestInfo,
) {
// Process response
}
// Usage
client := rest.NewClient(
rest.WithMiddleware(&AuthMiddleware{apiKey: "secret"}),
)When OTelConfig is provided, all requests are traced:
otelConfig := otel.NewConfig("my-client").
WithTracerProvider(tracerProvider)
client := rest.NewClient(
rest.WithOTelConfig(otelConfig),
)
// Creates span for each request
response, _ := client.MakeRequestWithTrace(ctx, "GET", url, "", nil)Each HTTP request span includes:
Span Attributes:
http.method: "GET" | "POST" | "PUT" | "DELETE" | ...
http.url: "https://api.example.com/users"
http.status_code: 200
http.duration_ms: 150
pkg.rest.client.name: "my-client"
pkg.rest.retry.max_count: 3
pkg.rest.timeout_ms: 30000Automatic HTTP client metrics:
Metrics:
http.client.request.duration: Histogram of request durations
http.client.request.count: Counter of total requests
http.client.request.active: Gauge of active requests
Attributes:
http.method: "GET"
http.status_code: 200
service.name: "my-client"// GET
response, _ := client.MakeRequestWithTrace(ctx, "GET", url, "", headers)
// POST
response, _ := client.MakeRequestWithTrace(ctx, "POST", url, `{"key":"value"}`, headers)
// PUT
response, _ := client.MakeRequestWithTrace(ctx, "PUT", url, body, headers)
// DELETE
response, _ := client.MakeRequestWithTrace(ctx, "DELETE", url, "", headers)
// PATCH
response, _ := client.MakeRequestWithTrace(ctx, "PATCH", url, body, headers)
// HEAD
response, _ := client.MakeRequestWithTrace(ctx, "HEAD", url, "", headers)
// OPTIONS
response, _ := client.MakeRequestWithTrace(ctx, "OPTIONS", url, "", headers)headers := map[string]string{
"Authorization": "Bearer token",
"Content-Type": "application/json",
"X-API-Key": "secret",
}
response, _ := client.MakeRequestWithTrace(ctx, "GET", url, "", headers)body := `{
"name": "John Doe",
"email": "john@example.com"
}`
response, _ := client.MakeRequestWithTrace(ctx, "POST", url, body, headers)import (
"github.com/jasoet/pkg/v2/config"
"github.com/jasoet/pkg/v2/rest"
)
type AppConfig struct {
REST rest.Config `yaml:"rest"`
}
yamlConfig := `
rest:
retryCount: 3
retryWaitTime: 1s
retryMaxWaitTime: 5s
timeout: 30s
`
cfg, _ := config.LoadString[AppConfig](yamlConfig)
client := rest.NewClient(rest.WithRestConfig(cfg.REST))For advanced Resty features:
client := rest.NewClient()
// Get Resty client
restyClient := client.GetRestClient()
// Use Resty directly
restyClient.R().
SetHeader("X-Custom", "value").
SetQueryParam("page", "1").
Get("https://api.example.com/users")response, err := client.MakeRequestWithTrace(ctx, "GET", url, "", nil)
if err != nil {
// Network error, timeout, or other client error
log.Printf("Request failed: %v", err)
return
}
// Check HTTP status
if response.StatusCode() != 200 {
log.Printf("HTTP error: %d - %s", response.StatusCode(), response.String())
return
}
// Process response
fmt.Println(response.String())// ✅ Good: Context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
response, err := client.MakeRequestWithTrace(ctx, "GET", url, "", nil)// ✅ Good: Reasonable retry config
config := rest.Config{
RetryCount: 3, // Retry up to 3 times
RetryWaitTime: 1 * time.Second, // Start with 1s
RetryMaxWaitTime: 10 * time.Second, // Cap at 10s
Timeout: 30 * time.Second,
}// ✅ Good: Observability enabled
client := rest.NewClient(
rest.WithOTelConfig(otelConfig),
)
// ❌ Bad: No observability
client := rest.NewClient()// ✅ Good: Singleton client
var httpClient = rest.NewClient(/* config */)
func fetchUser(id string) {
httpClient.MakeRequestWithTrace(/* ... */)
}
// ❌ Bad: New client per request
func fetchUser(id string) {
client := rest.NewClient() // Creates new connection pool
client.MakeRequestWithTrace(/* ... */)
}// ✅ Good: Centralized auth
type AuthMiddleware struct { /* ... */ }
client := rest.NewClient(
rest.WithMiddleware(&AuthMiddleware{}),
rest.WithMiddleware(&RateLimitMiddleware{}),
)
// All requests get auth + rate limitingThe package includes comprehensive tests with 93% coverage:
# Run tests
go test ./rest -v
# With coverage
go test ./rest -coverimport (
"github.com/jasoet/pkg/v2/rest"
"net/http/httptest"
)
func TestMyCode(t *testing.T) {
// Mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
// Use no-op middleware for testing
client := rest.NewClient(
rest.WithMiddleware(rest.NewNoOpMiddleware()),
)
response, err := client.MakeRequestWithTrace(
context.Background(),
"GET",
server.URL,
"",
nil,
)
assert.NoError(t, err)
assert.Equal(t, 200, response.StatusCode())
}Problem: Requests timing out
Solutions:
// 1. Increase timeout
config := rest.Config{
Timeout: 60 * time.Second, // Longer timeout
// ...
}
// 2. Use context timeout
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()Problem: Client not retrying failed requests
Solutions:
// 1. Check retry configuration
config := rest.Config{
RetryCount: 3, // Must be > 0
RetryWaitTime: 1 * time.Second,
RetryMaxWaitTime: 5 * time.Second,
}
// 2. Verify error is retryable
// Resty retries on network errors and 5xx status codes
// Does NOT retry on 4xx client errorsProblem: No spans appearing
Solutions:
// 1. Verify OTel config is provided
client := rest.NewClient(
rest.WithOTelConfig(otelConfig), // Must be set
)
// 2. Check tracer provider
if otelConfig.IsTracingEnabled() {
// Tracing is enabled
}
// 3. Ensure context propagation
ctx, span := tracer.Start(ctx, "parent-span")
defer span.End()
client.MakeRequestWithTrace(ctx, /* ... */) // Propagates context- Connection Pooling: Reuses HTTP connections via Resty
- Low Overhead: Minimal middleware overhead (~microseconds)
- Efficient Retries: Exponential backoff prevents thundering herd
Benchmark (typical request):
BenchmarkRequest-8 1000 ~1ms/op (including network)
BenchmarkMiddleware-8 10000 ~5µs/op (middleware overhead)
See examples/ directory for:
- Basic HTTP requests
- OpenTelemetry integration
- Custom middleware
- Error handling
- Retry configuration
- Authentication patterns
MIT License - see LICENSE for details.