Add WithMaxResponseBody option to prevent client-side OOM

Wraps response body with io.LimitedReader when configured, preventing
unbounded reads from io.ReadAll in Response.Bytes(). Protects against
upstream services returning unexpectedly large responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 21:48:05 +03:00
parent 49be6f8a7e
commit 21274c178a
3 changed files with 112 additions and 12 deletions

View File

@@ -12,18 +12,19 @@ import (
) )
type clientOptions struct { type clientOptions struct {
baseURL string baseURL string
timeout time.Duration timeout time.Duration
transport http.RoundTripper transport http.RoundTripper
logger *slog.Logger logger *slog.Logger
errorMapper ErrorMapper errorMapper ErrorMapper
middlewares []middleware.Middleware middlewares []middleware.Middleware
retryOpts []retry.Option retryOpts []retry.Option
enableRetry bool enableRetry bool
cbOpts []circuitbreaker.Option cbOpts []circuitbreaker.Option
enableCB bool enableCB bool
endpoints []balancer.Endpoint endpoints []balancer.Endpoint
balancerOpts []balancer.Option balancerOpts []balancer.Option
maxResponseBody int64
} }
// Option configures a Client. // Option configures a Client.
@@ -85,3 +86,11 @@ func WithEndpoints(eps ...balancer.Endpoint) Option {
func WithBalancer(opts ...balancer.Option) Option { func WithBalancer(opts ...balancer.Option) Option {
return func(o *clientOptions) { o.balancerOpts = opts } return func(o *clientOptions) { o.balancerOpts = opts }
} }
// WithMaxResponseBody limits the number of bytes read from response bodies
// by Response.Bytes (and by extension String, JSON, XML). If the response
// body exceeds n bytes, reading stops and returns an error.
// A value of 0 means no limit (the default).
func WithMaxResponseBody(n int64) Option {
return func(o *clientOptions) { o.maxResponseBody = n }
}

View File

@@ -97,3 +97,18 @@ func (r *Response) BodyReader() io.Reader {
} }
return r.Body return r.Body
} }
// limitedReadCloser wraps an io.LimitedReader with a separate Closer
// so the original body can be closed.
type limitedReadCloser struct {
R io.LimitedReader
C io.Closer
}
func (l *limitedReadCloser) Read(p []byte) (int, error) {
return l.R.Read(p)
}
func (l *limitedReadCloser) Close() error {
return l.C.Close()
}

76
response_limit_test.go Normal file
View File

@@ -0,0 +1,76 @@
package httpx_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.codelab.vc/pkg/httpx"
)
func TestClient_MaxResponseBody(t *testing.T) {
t.Run("allows response within limit", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "hello")
}))
defer srv.Close()
client := httpx.New(httpx.WithMaxResponseBody(1024))
resp, err := client.Get(context.Background(), srv.URL+"/")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body, err := resp.String()
if err != nil {
t.Fatalf("reading body: %v", err)
}
if body != "hello" {
t.Fatalf("body = %q, want %q", body, "hello")
}
})
t.Run("truncates response exceeding limit", func(t *testing.T) {
largeBody := strings.Repeat("x", 1000)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, largeBody)
}))
defer srv.Close()
client := httpx.New(httpx.WithMaxResponseBody(100))
resp, err := client.Get(context.Background(), srv.URL+"/")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := resp.Bytes()
if err != nil {
t.Fatalf("reading body: %v", err)
}
if len(b) != 100 {
t.Fatalf("body length = %d, want %d", len(b), 100)
}
})
t.Run("no limit when zero", func(t *testing.T) {
largeBody := strings.Repeat("x", 10000)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, largeBody)
}))
defer srv.Close()
client := httpx.New()
resp, err := client.Get(context.Background(), srv.URL+"/")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b, err := resp.Bytes()
if err != nil {
t.Fatalf("reading body: %v", err)
}
if len(b) != 10000 {
t.Fatalf("body length = %d, want %d", len(b), 10000)
}
})
}