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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.0] - 2026-04-03

### Added

- **httpclient** — `WithMaxResponseBody(n int64)` option to configure the maximum allowed response body size (default 10 MB via `DefaultMaxResponseBody`)
- **httpclient** — Response reads in `executeRequest` are now capped with `io.LimitReader`; responses exceeding the limit return an error instead of allocating unbounded memory

## [0.13.1] - 2026-04-02

### Fixed
Expand Down
42 changes: 27 additions & 15 deletions httpclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,30 @@ type Client struct {
mu sync.RWMutex

// config fields
timeout time.Duration
maxRetries int
retryDelay time.Duration
maxRetryDelay time.Duration
logger *slog.Logger
cb *CircuitBreaker
transport http.RoundTripper
timeout time.Duration
maxRetries int
retryDelay time.Duration
maxRetryDelay time.Duration
maxResponseBody int64
logger *slog.Logger
cb *CircuitBreaker
transport http.RoundTripper
}

// DefaultMaxResponseBody is the default maximum response body size (10 MB).
const DefaultMaxResponseBody = 10 << 20

// New creates a new HTTP client with the given base URL and options.
func New(baseURL string, opts ...Option) *Client {
c := &Client{
baseURL: baseURL,
headers: make(map[string]string),
timeout: 30 * time.Second,
maxRetries: 3,
retryDelay: time.Second,
maxRetryDelay: 10 * time.Second,
logger: slog.Default(),
baseURL: baseURL,
headers: make(map[string]string),
timeout: 30 * time.Second,
maxRetries: 3,
retryDelay: time.Second,
maxRetryDelay: 10 * time.Second,
maxResponseBody: DefaultMaxResponseBody,
logger: slog.Default(),
}

for _, opt := range opts {
Expand Down Expand Up @@ -239,10 +244,17 @@ func (c *Client) executeRequest(ctx context.Context, method, path string, body a
}

defer func() { _ = resp.Body.Close() }()
responseBody, err := io.ReadAll(resp.Body)
maxBody := c.maxResponseBody
if maxBody <= 0 {
maxBody = DefaultMaxResponseBody
}
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if int64(len(responseBody)) > maxBody {
return nil, fmt.Errorf("response body exceeds maximum size of %d bytes", maxBody)
}

response := &Response{
StatusCode: resp.StatusCode,
Expand Down
6 changes: 6 additions & 0 deletions httpclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func WithCircuitBreaker(threshold int, timeout time.Duration) Option {
}
}

// WithMaxResponseBody sets the maximum response body size in bytes.
// Responses larger than this are rejected. Default: 10 MB.
func WithMaxResponseBody(n int64) Option {
return func(c *Client) { c.maxResponseBody = n }
}

// WithTransport sets the HTTP transport.
func WithTransport(t http.RoundTripper) Option {
return func(c *Client) { c.transport = t }
Expand Down
Loading