Skip to content

Commit e27cba7

Browse files
authored
Feat/http client adapter (#969)
* feat: add support for customizable HTTP policies in network options * refactor: simplify `defaultHTTPClient` creation logic and add `NewWithClient` constructor with tests * refactor: rename `responseValidatingTransport` to `nilResponseGuardTransport` and consolidate response body handling into `Policy` * refactor: enhance HTTP client and policy tests with improved error handling and typed-nil transport case handling * feat: add support for custom HTTP transport in network options with `WithHTTPTransport`
1 parent 6b5fc6f commit e27cba7

22 files changed

Lines changed: 1216 additions & 236 deletions

pkg/net/http/client.go

Lines changed: 65 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -38,43 +38,86 @@ func New(options ...PolicyOption) (Client, error) {
3838
}
3939

4040
dialer := newPolicyDialer(policy)
41-
transport := newResponseValidatingTransport(
42-
newPolicyTransport(dialer, policy.maxResponseHeaderSize),
43-
)
4441

45-
return &defaultHTTPClient{
42+
return newDefaultHTTPClient(policy, stdhttp.Client{
43+
Transport: newPolicyTransport(dialer, policy.MaxResponseHeaderSize()),
44+
}), nil
45+
}
46+
47+
// NewWithTransport constructs a policy-aware client that uses transport. The
48+
// supplied transport is shared with the returned Client, so closing idle
49+
// connections affects its connection pool.
50+
//
51+
// A nil transport, including a typed-nil *net/http.Transport, selects Ferret's
52+
// policy-aware transport. A non-nil transport remains responsible for proxy
53+
// behavior, DNS and concrete-address enforcement, and response-header limits.
54+
func NewWithTransport(
55+
transport stdhttp.RoundTripper,
56+
options ...PolicyOption,
57+
) (Client, error) {
58+
return NewWithClient(&stdhttp.Client{Transport: transport}, options...)
59+
}
60+
61+
// NewWithClient constructs a policy-aware client from a standard-library
62+
// client. It snapshots the supplied client's fields without mutating it.
63+
// Policy timeout and redirect settings take precedence over the corresponding
64+
// client fields.
65+
//
66+
// A nil Transport is replaced with Ferret's policy-aware transport. A non-nil
67+
// Transport is preserved and remains responsible for proxy behavior, DNS and
68+
// concrete-address enforcement, and response-header limits. The transport and
69+
// cookie jar remain shared with the supplied client; closing idle connections
70+
// through the returned Client affects the shared transport pool.
71+
func NewWithClient(client *stdhttp.Client, options ...PolicyOption) (Client, error) {
72+
if client == nil {
73+
return nil, ErrNilClient
74+
}
75+
76+
policy, err := NewPolicy(options...)
77+
if err != nil {
78+
return nil, err
79+
}
80+
81+
stdClient := *client
82+
transport, isStandardTransport := stdClient.Transport.(*stdhttp.Transport)
83+
84+
if stdClient.Transport == nil || (isStandardTransport && transport == nil) {
85+
dialer := newPolicyDialer(policy)
86+
stdClient.Transport = newPolicyTransport(dialer, policy.MaxResponseHeaderSize())
87+
}
88+
89+
return newDefaultHTTPClient(policy, stdClient), nil
90+
}
91+
92+
// newDefaultHTTPClient snapshots and normalizes a standard-library client so
93+
// the stored client can be reused safely across concurrent requests.
94+
func newDefaultHTTPClient(policy *Policy, client stdhttp.Client) *defaultHTTPClient {
95+
result := &defaultHTTPClient{
4696
policy: policy,
47-
client: stdhttp.Client{
48-
Transport: transport,
49-
Timeout: policy.timeout,
50-
},
51-
}, nil
97+
client: client,
98+
}
99+
result.client.Transport = newNilResponseGuardTransport(result.client.Transport)
100+
result.client.Timeout = policy.Timeout()
101+
result.client.CheckRedirect = policy.CheckRedirect
102+
103+
return result
52104
}
53105

54106
func (d *defaultHTTPClient) Do(ctx context.Context, req *Request) (*Response, error) {
55107
if ctx == nil {
56108
ctx = context.Background()
57109
}
58110

59-
p := d.policy
60-
if p == nil {
61-
p = &Policy{}
62-
}
63-
64111
stdReq, err := toStdRequest(ctx, req)
65112
if err != nil {
66113
return nil, err
67114
}
68-
if err := p.Prepare(stdReq); err != nil {
115+
116+
if err := d.policy.Prepare(stdReq); err != nil {
69117
return nil, err
70118
}
71119

72-
client := d.client
73-
client.Transport = newResponseValidatingTransport(client.Transport)
74-
client.Timeout = p.timeout
75-
client.CheckRedirect = d.checkRedirect
76-
77-
res, err := client.Do(stdReq)
120+
res, err := d.client.Do(stdReq)
78121
if err != nil {
79122
var policyErr *PolicyError
80123
if errors.As(err, &policyErr) {
@@ -84,28 +127,9 @@ func (d *defaultHTTPClient) Do(ctx context.Context, req *Request) (*Response, er
84127
return nil, err
85128
}
86129

87-
return fromStdResponse(res, p)
130+
return fromStdResponse(res, d.policy)
88131
}
89132

90133
func (d *defaultHTTPClient) CloseIdleConnections() {
91134
d.client.CloseIdleConnections()
92135
}
93-
94-
func (d *defaultHTTPClient) checkRedirect(req *stdhttp.Request, via []*stdhttp.Request) error {
95-
p := d.policy
96-
97-
if p == nil {
98-
p = &Policy{}
99-
}
100-
101-
if !p.followRedirects {
102-
return stdhttp.ErrUseLastResponse
103-
}
104-
105-
limit := p.maxRedirects
106-
if len(via) > limit {
107-
return &RedirectLimitError{Limit: limit}
108-
}
109-
110-
return p.eval(req, PolicyTargetRedirect)
111-
}

pkg/net/http/client_benchmark_test.go

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,16 @@ func benchmarkClientDoPolicy(b *testing.B, policy *Policy) {
8888
}
8989

9090
func benchmarkClientDoPolicyRequest(b *testing.B, policy *Policy, req *Request) {
91-
client := &defaultHTTPClient{
92-
policy: policy,
93-
client: stdhttp.Client{
94-
Transport: newResponseValidatingTransport(testRoundTripper(func(*stdhttp.Request) (*stdhttp.Response, error) {
95-
return &stdhttp.Response{
96-
StatusCode: stdhttp.StatusOK,
97-
Status: "200 OK",
98-
Header: make(stdhttp.Header),
99-
Body: io.NopCloser(strings.NewReader("ok")),
100-
}, nil
101-
})),
102-
},
103-
}
91+
client := newDefaultHTTPClient(policy, stdhttp.Client{
92+
Transport: testRoundTripper(func(*stdhttp.Request) (*stdhttp.Response, error) {
93+
return &stdhttp.Response{
94+
StatusCode: stdhttp.StatusOK,
95+
Status: "200 OK",
96+
Header: make(stdhttp.Header),
97+
Body: io.NopCloser(strings.NewReader("ok")),
98+
}, nil
99+
}),
100+
})
104101

105102
b.ReportAllocs()
106103
b.ResetTimer()
@@ -114,13 +111,14 @@ func benchmarkClientDoPolicyRequest(b *testing.B, policy *Policy, req *Request)
114111

115112
func BenchmarkReadResponseBodyBounded(b *testing.B) {
116113
body := strings.Repeat("x", 4<<10)
114+
policy := newTestPolicy(b, WithMaxResponseSize(8<<10))
117115

118116
b.ReportAllocs()
119117
b.SetBytes(int64(len(body)))
120118
b.ResetTimer()
121119

122120
for b.Loop() {
123-
if _, err := readResponseBody(strings.NewReader(body), 8<<10); err != nil {
121+
if _, err := policy.ReadResponseBody(strings.NewReader(body)); err != nil {
124122
b.Fatal(err)
125123
}
126124
}

0 commit comments

Comments
 (0)