feat: simplify HTTP client proxy pools and authenticated JSON calls
This commit is contained in:
156
httpc/client.go
156
httpc/client.go
@@ -3,9 +3,7 @@
|
||||
package httpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,18 +20,6 @@ import (
|
||||
var ErrClosed = errors.New("httpc: closed")
|
||||
var ErrResponseTooLarge = errors.New("httpc: response exceeds limit")
|
||||
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
MaxRetries int `json:"maxRetries"`
|
||||
Backoff time.Duration `json:"backoff"`
|
||||
MaxBackoff time.Duration `json:"maxBackoff"`
|
||||
MaxConnsPerHost int `json:"maxConnsPerHost"`
|
||||
MaxResponseBytes int64 `json:"maxResponseBytes"`
|
||||
DisableRequestID bool `json:"disableRequestId"`
|
||||
DisableLogging bool `json:"disableLogging"`
|
||||
Transport http.RoundTripper `json:"-"` // Borrowed. The caller owns its lifecycle.
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config Config
|
||||
client *http.Client
|
||||
@@ -41,43 +27,19 @@ type Client struct {
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// New creates a reusable client. Close it after callers and response streams drain.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.Timeout < 0 || cfg.Backoff < 0 || cfg.MaxBackoff < 0 || cfg.MaxRetries < 0 || cfg.MaxRetries > 5 || cfg.MaxConnsPerHost < 0 || cfg.MaxResponseBytes < 0 {
|
||||
return nil, errors.New("httpc: invalid configuration")
|
||||
normalized, err := normalizeConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
if cfg.Backoff == 0 {
|
||||
cfg.Backoff = 100 * time.Millisecond
|
||||
}
|
||||
if cfg.MaxBackoff == 0 {
|
||||
cfg.MaxBackoff = 2 * time.Second
|
||||
}
|
||||
if cfg.Backoff > cfg.MaxBackoff {
|
||||
return nil, errors.New("httpc: backoff exceeds maxBackoff")
|
||||
}
|
||||
if cfg.MaxConnsPerHost == 0 {
|
||||
cfg.MaxConnsPerHost = 64
|
||||
}
|
||||
if cfg.MaxResponseBytes == 0 {
|
||||
cfg.MaxResponseBytes = 1 << 20
|
||||
}
|
||||
if cfg.MaxResponseBytes > 1<<30 || cfg.MaxConnsPerHost > 65536 {
|
||||
return nil, errors.New("httpc: response or connection limit exceeds supported capacity")
|
||||
}
|
||||
c := &Client{config: cfg}
|
||||
c := &Client{config: normalized}
|
||||
transport := cfg.Transport
|
||||
if transport == nil {
|
||||
// Own a private pool; never mutate the process-wide DefaultTransport.
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("httpc: provide Transport when DefaultTransport is replaced")
|
||||
c.owned, err = ownedTransport(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.owned = base.Clone()
|
||||
c.owned.MaxConnsPerHost = cfg.MaxConnsPerHost
|
||||
c.owned.MaxIdleConnsPerHost = cfg.MaxConnsPerHost
|
||||
c.owned.MaxIdleConns = cfg.MaxConnsPerHost * 2
|
||||
transport = c.owned
|
||||
}
|
||||
c.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
@@ -114,7 +76,10 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
||||
}
|
||||
replayable := (base.Method == "" || base.Method == "GET" || base.Method == "HEAD" || base.Method == "OPTIONS") && (base.Body == nil || base.Body == http.NoBody || base.GetBody != nil)
|
||||
for attempt := 0; ; attempt++ {
|
||||
req := base.Clone(ctx)
|
||||
req := base
|
||||
if attempt > 0 {
|
||||
req = base.Clone(ctx)
|
||||
}
|
||||
if attempt > 0 && base.Body != nil && base.Body != http.NoBody {
|
||||
body, err := base.GetBody()
|
||||
if err != nil {
|
||||
@@ -133,23 +98,9 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
||||
logx.InfoContext(ctx, "http.client", "method", req.Method, "host", req.URL.Hostname(), "status", status, "attempt", attempt+1, "durationMs", time.Since(started).Seconds()*1000, "failed", err != nil)
|
||||
}
|
||||
retry := replayable && attempt < c.config.MaxRetries && ctx.Err() == nil && (err != nil || status == 429 || status == 502 || status == 503 || status == 504)
|
||||
delay := c.config.Backoff
|
||||
for i := 0; i < attempt && delay < c.config.MaxBackoff; i++ {
|
||||
if delay > c.config.MaxBackoff/2 {
|
||||
delay = c.config.MaxBackoff
|
||||
} else {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
if response != nil && response.Header.Get("Retry-After") != "" {
|
||||
if value, ok := retryAfter(response.Header.Get("Retry-After")); ok {
|
||||
delay = value
|
||||
}
|
||||
} else if delay > 1 {
|
||||
delay = delay/2 + time.Duration(rand.Int63n(int64(delay/2)+1))
|
||||
}
|
||||
if delay > c.config.MaxBackoff {
|
||||
retry = false
|
||||
var delay time.Duration
|
||||
if retry {
|
||||
delay, retry = c.retryDelay(attempt, response)
|
||||
}
|
||||
if !retry {
|
||||
if err != nil {
|
||||
@@ -160,9 +111,7 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
||||
return response, nil
|
||||
}
|
||||
if response != nil {
|
||||
if response.ContentLength >= 0 && response.ContentLength <= 32<<10 {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
}
|
||||
drainSmallBody(response, 32<<10)
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
@@ -204,48 +153,6 @@ type StatusError struct{ StatusCode int }
|
||||
|
||||
func (e *StatusError) Error() string { return fmt.Sprintf("httpc: HTTP status %d", e.StatusCode) }
|
||||
|
||||
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
|
||||
// Error bodies, URLs, credentials and query strings are not included in logs.
|
||||
func (c *Client) JSON(ctx context.Context, method, url string, input, output any) error {
|
||||
if ctx == nil {
|
||||
return errors.New("httpc: nil context")
|
||||
}
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if input != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := c.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return &StatusError{StatusCode: response.StatusCode}
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, c.config.MaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > c.config.MaxResponseBytes {
|
||||
return ErrResponseTooLarge
|
||||
}
|
||||
if output == nil || len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, output)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.closed.Store(true)
|
||||
if c.owned != nil {
|
||||
@@ -253,3 +160,34 @@ func (c *Client) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// retryDelay is deliberately outside the success path: no jitter/random work
|
||||
// for calls that cannot or do not need to retry.
|
||||
func (c *Client) retryDelay(attempt int, response *http.Response) (time.Duration, bool) {
|
||||
delay := c.config.Backoff
|
||||
for i := 0; i < attempt && delay < c.config.MaxBackoff; i++ {
|
||||
if delay > c.config.MaxBackoff/2 {
|
||||
delay = c.config.MaxBackoff
|
||||
} else {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
if response != nil {
|
||||
if value, ok := retryAfter(response.Header.Get("Retry-After")); ok {
|
||||
return value, value <= c.config.MaxBackoff
|
||||
}
|
||||
}
|
||||
if delay > 1 {
|
||||
delay = delay/2 + time.Duration(rand.Int63n(int64(delay/2)+1))
|
||||
}
|
||||
return delay, delay <= c.config.MaxBackoff
|
||||
}
|
||||
|
||||
// Drain only a known small error body. Unknown/large bodies are closed promptly;
|
||||
// never allocate or consume an unbounded error document just to retain a pool slot.
|
||||
func drainSmallBody(response *http.Response, limit int64) {
|
||||
limit = min(limit, 32<<10)
|
||||
if response.Body != nil && response.ContentLength >= 0 && response.ContentLength <= limit {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, limit+1))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user