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:
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
15
response.go
15
response.go
@@ -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
76
response_limit_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user