Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ A flexible HTTP client with automatic retry logic using exponential backoff, bui
- **Request Options**: Flexible request configuration with `WithBody()`, `WithJSON()`, `WithHeader()`, and `WithHeaders()`
- **Jitter Support**: Optional random jitter to prevent thundering herd problem
- **Retry-After Header**: Respects HTTP `Retry-After` header for rate limiting (RFC 2616)
- **Observability**: Built-in support for metrics collection, distributed tracing, and structured logging (zero dependencies, interface-driven)
- **Observability**: Built-in support for metrics collection, distributed tracing, and structured logging (uses standard library `log/slog` by default, interface-driven for custom implementations)
- **Flexible Configuration**: Use functional options to customize retry behavior
- **Context Support**: Respects context cancellation and timeouts
- **Custom Retry Logic**: Pluggable retry checker for custom retry conditions
Expand Down Expand Up @@ -86,12 +86,16 @@ func main() {
// - 2.0x exponential multiplier
// - Jitter enabled (±25% randomization)
// - Retry-After header respected (HTTP standard compliant)
// - Structured logging to stderr using log/slog (INFO level)
client, err := retry.NewClient()
if err != nil {
log.Fatal(err)
}

// Simple GET request
// Retry operations will be automatically logged to stderr:
// 2024/02/14 10:00:00 WARN request failed, will retry method=GET attempt=1 reason=5xx
// 2024/02/14 10:00:00 INFO retrying request method=GET attempt=2 delay=1s
resp, err := client.Get(context.Background(), "https://api.example.com/data")
if err != nil {
log.Fatal(err)
Expand Down
49 changes: 43 additions & 6 deletions docs/OBSERVABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ The library provides three observability interfaces:
- **Logger**: Outputs structured logs for debugging and monitoring

All observability features are:
- **Optional**: Disabled by default (no-op implementations)
- **Zero-dependency**: Interfaces only, you provide implementations
- **Flexible**: Metrics and tracing disabled by default (no-op implementations), **logging enabled by default** using `log/slog`
- **Zero-dependency**: Uses Go standard library only, you can provide custom implementations
- **Zero-overhead when disabled**: Uses Null Object Pattern for negligible performance impact
- **Thread-safe**: All callbacks must be thread-safe

Expand Down Expand Up @@ -219,19 +219,46 @@ type Logger interface {
}
```

### Built-in slog Adapter
### Default Behavior

For convenience, the library provides an adapter for Go's standard `log/slog`:
**By default, the client uses `log/slog` with `slog.Default()`**, which outputs structured logs to stderr at INFO level.

This means retry operations are logged automatically without any configuration:

```go
// Logging is enabled by default - no configuration needed
client, _ := retry.NewClient()

// Outputs to stderr:
// 2024/02/14 10:00:00 WARN request failed, will retry method=GET attempt=1 reason=5xx
// 2024/02/14 10:00:00 INFO retrying request method=GET attempt=2 delay=1s
```

### Customizing the Logger

You can provide a custom logger using `WithLogger()`:

```go
import "log/slog"

// Use JSON output instead of text
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))

client, _ := retry.NewClient(
retry.WithLogger(retry.NewSlogAdapter(logger)),
retry.WithLogger(&slogAdapter{logger: logger}),
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slogAdapter type is not exported (lowercase 's') and cannot be used by external code. The documentation should reference the existing public SlogAdapter type from logger_slog.go instead, using retry.NewSlogAdapter(logger) which is the correct public API.

Suggested change
retry.WithLogger(&slogAdapter{logger: logger}),
retry.WithLogger(retry.NewSlogAdapter(logger)),

Copilot uses AI. Check for mistakes.
)
```

### Disabling Logging

To suppress all log output, use `WithNoLogging()`:

```go
// Disable all logging output
client, _ := retry.NewClient(
retry.WithNoLogging(),
)
```

Expand Down Expand Up @@ -291,8 +318,18 @@ See `_example/observability/combined/main.go` for using all three features toget

## Performance Considerations

### When Disabled (Default)
### Default Configuration

By default:
- **Logging enabled**: Uses `log/slog.Default()` with minimal overhead for INFO+ levels
- **Metrics disabled**: No-op implementation with zero overhead
- **Tracing disabled**: No-op implementation with zero overhead

The default slog logger has very low overhead, especially when logs are not at Debug level. For performance-critical applications where even minimal logging overhead is unacceptable, use `WithNoLogging()`.

### When Observability is Disabled

When using `WithNoLogging()` or not enabling metrics/tracing:
- **Zero overhead**: No-op implementations use empty inline functions
- **No allocations**: Null objects are package-level singletons
- **No branches**: No `if != nil` checks in hot path
Expand Down
27 changes: 25 additions & 2 deletions logger.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package retry

import "log/slog"

// Logger defines the structured logging interface (slog-compatible)
type Logger interface {
Debug(msg string, args ...any)
Expand All @@ -16,5 +18,26 @@ func (nopLogger) Info(string, ...any) {}
func (nopLogger) Warn(string, ...any) {}
func (nopLogger) Error(string, ...any) {}

// defaultLogger is the package-level singleton (internal use, not exported)
var defaultLogger = nopLogger{}
// slogAdapter wraps slog.Logger to implement our Logger interface
type slogAdapter struct {
logger *slog.Logger
}

func (l *slogAdapter) Debug(msg string, args ...any) {
l.logger.Debug(msg, args...)
}

func (l *slogAdapter) Info(msg string, args ...any) {
l.logger.Info(msg, args...)
}

func (l *slogAdapter) Warn(msg string, args ...any) {
l.logger.Warn(msg, args...)
}

func (l *slogAdapter) Error(msg string, args ...any) {
l.logger.Error(msg, args...)
}

// defaultLogger uses slog.Default() which outputs to stderr with INFO level
var defaultLogger Logger = &slogAdapter{logger: slog.Default()}
Comment on lines +21 to +43
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is now duplication between the internal slogAdapter type and the public SlogAdapter type in logger_slog.go. The implementation is identical. Consider removing the internal slogAdapter and using the public SlogAdapter for both the default logger and user-facing API to avoid code duplication and potential maintenance issues.

Suggested change
// slogAdapter wraps slog.Logger to implement our Logger interface
type slogAdapter struct {
logger *slog.Logger
}
func (l *slogAdapter) Debug(msg string, args ...any) {
l.logger.Debug(msg, args...)
}
func (l *slogAdapter) Info(msg string, args ...any) {
l.logger.Info(msg, args...)
}
func (l *slogAdapter) Warn(msg string, args ...any) {
l.logger.Warn(msg, args...)
}
func (l *slogAdapter) Error(msg string, args ...any) {
l.logger.Error(msg, args...)
}
// defaultLogger uses slog.Default() which outputs to stderr with INFO level
var defaultLogger Logger = &slogAdapter{logger: slog.Default()}
// defaultLogger uses slog.Default() which outputs to stderr with INFO level
var defaultLogger Logger = &SlogAdapter{logger: slog.Default()}

Copilot uses AI. Check for mistakes.
96 changes: 95 additions & 1 deletion logger_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package retry

import (
"context"
"net/http"
"net/http/httptest"
"sync"
Expand Down Expand Up @@ -71,7 +72,7 @@ func TestClient_WithLogger(t *testing.T) {
t.Fatalf("Failed to create client: %v", err)
}

req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
Expand Down Expand Up @@ -102,3 +103,96 @@ func TestClient_WithLogger(t *testing.T) {
)
}
}

func TestClient_DefaultLogger(t *testing.T) {
// This test verifies that the default logger is slog, not nopLogger
// We can't easily capture slog output in tests, but we can verify the client is created
// successfully and uses the default logger (which is slogAdapter wrapping slog.Default())

attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()

// Create client without WithLogger option - should use default slog
client, err := NewClient(
WithMaxRetries(3),
WithInitialRetryDelay(10*time.Millisecond),
WithJitter(false),
)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}

// Verify the default logger is slogAdapter (not nopLogger)
if _, ok := client.logger.(*slogAdapter); !ok {
t.Errorf("Expected default logger to be *slogAdapter, got %T", client.logger)
}

req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}

// Verify retry happened
if attempts != 2 {
t.Errorf("Expected 2 attempts, got %d", attempts)
}
}

func TestClient_WithNoLogging(t *testing.T) {
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 2 {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()

// Create client with WithNoLogging() - should disable all logging
client, err := NewClient(
WithMaxRetries(3),
WithInitialRetryDelay(10*time.Millisecond),
WithJitter(false),
WithNoLogging(),
)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}

// Verify the logger is nopLogger
if _, ok := client.logger.(nopLogger); !ok {
t.Errorf("Expected logger to be nopLogger with WithNoLogging(), got %T", client.logger)
}

req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}

// Verify retry happened (even though logging is disabled)
if attempts != 2 {
t.Errorf("Expected 2 attempts, got %d", attempts)
}
}
12 changes: 11 additions & 1 deletion option.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ func WithTracer(tracer Tracer) Option {

// WithLogger sets the structured logger for observability.
// The logger will output structured logs for request lifecycle events.
// If nil is provided, logging will be disabled (no-op).
// By default, the client uses slog.Default() which outputs to stderr at INFO level.
// Use WithNoLogging() to disable logging entirely.
func WithLogger(logger Logger) Option {
return func(c *Client) {
if logger != nil {
Expand All @@ -140,6 +141,15 @@ func WithLogger(logger Logger) Option {
}
}

// WithNoLogging disables all logging output.
// By default, the client uses slog.Default() which outputs to stderr.
// Use this option if you want to suppress all log messages.
func WithNoLogging() Option {
return func(c *Client) {
c.logger = nopLogger{}
}
}

// RequestOption is a function that configures an HTTP request
type RequestOption func(*http.Request)

Expand Down
Loading