mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-09 20:06:50 +00:00
Each package doc now explains what problem it solves and why it exists, with the public interface as the only "how" detail. Implementation notes removed from doc comments.
201 lines
5.1 KiB
Go
201 lines
5.1 KiB
Go
// Package httpclient provides a secure, resilient HTTP client for upstream API
|
|
// calls. It exists because [http.DefaultClient] has no timeouts, no retry
|
|
// logic, and follows redirects from HTTPS to HTTP — none of which are
|
|
// acceptable for a server making calls to GitHub, Gitea, etc. on behalf of
|
|
// users.
|
|
//
|
|
// Create a [Client] with [New], then use [Client.Do] or [Client.Get]. All
|
|
// calls require [context.Context] and will automatically retry transient
|
|
// failures (429, 502, 503, 504) with backoff.
|
|
package httpclient
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Client wraps http.Client with retry logic and resilience defaults.
|
|
type Client struct {
|
|
inner *http.Client
|
|
userAgent string
|
|
retries int
|
|
baseDelay time.Duration
|
|
maxDelay time.Duration
|
|
}
|
|
|
|
// Option configures a Client.
|
|
type Option func(*Client)
|
|
|
|
// WithUserAgent sets the User-Agent header for all requests.
|
|
func WithUserAgent(ua string) Option {
|
|
return func(c *Client) { c.userAgent = ua }
|
|
}
|
|
|
|
// WithRetries sets the maximum number of retries for transient errors.
|
|
func WithRetries(n int) Option {
|
|
return func(c *Client) { c.retries = n }
|
|
}
|
|
|
|
// WithBaseDelay sets the initial delay for exponential backoff.
|
|
func WithBaseDelay(d time.Duration) Option {
|
|
return func(c *Client) { c.baseDelay = d }
|
|
}
|
|
|
|
// New creates a Client with secure, resilient defaults.
|
|
func New(opts ...Option) *Client {
|
|
transport := &http.Transport{
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 10 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).DialContext,
|
|
TLSClientConfig: &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
},
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: 30 * time.Second,
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
|
|
c := &Client{
|
|
inner: &http.Client{
|
|
Transport: transport,
|
|
Timeout: 60 * time.Second,
|
|
CheckRedirect: checkRedirect,
|
|
},
|
|
userAgent: "Webi/2.0 (+https://webinstall.dev)",
|
|
retries: 3,
|
|
baseDelay: 1 * time.Second,
|
|
maxDelay: 30 * time.Second,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// maxRedirects is the redirect depth limit.
|
|
const maxRedirects = 10
|
|
|
|
// checkRedirect prevents HTTPS→HTTP downgrades and limits redirect depth.
|
|
func checkRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= maxRedirects {
|
|
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
|
}
|
|
if len(via) > 0 && via[0].URL.Scheme == "https" && req.URL.Scheme == "http" {
|
|
return errors.New("refused redirect from https to http")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Do executes a request with automatic retries for transient errors.
|
|
// It sets the User-Agent header if not already present.
|
|
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
|
if req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
}
|
|
|
|
var resp *http.Response
|
|
var err error
|
|
|
|
for attempt := 0; attempt <= c.retries; attempt++ {
|
|
if attempt > 0 {
|
|
delay := c.backoff(attempt, resp)
|
|
select {
|
|
case <-req.Context().Done():
|
|
return nil, req.Context().Err()
|
|
case <-time.After(delay):
|
|
}
|
|
|
|
// Close previous response body before retry.
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
resp, err = c.inner.Do(req)
|
|
if err != nil {
|
|
// Retry on transient network errors.
|
|
if req.Context().Err() != nil {
|
|
return nil, req.Context().Err()
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !isRetryable(resp.StatusCode) {
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
// Exhausted retries — return whatever we have.
|
|
if err != nil {
|
|
return nil, fmt.Errorf("after %d retries: %w", c.retries, err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// Get is a convenience wrapper around Do for GET requests.
|
|
func (c *Client) Get(ctx context.Context, url string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.Do(req)
|
|
}
|
|
|
|
// isRetryable returns true for HTTP status codes that indicate a transient error.
|
|
func isRetryable(status int) bool {
|
|
switch status {
|
|
case http.StatusTooManyRequests, // 429
|
|
http.StatusBadGateway, // 502
|
|
http.StatusServiceUnavailable, // 503
|
|
http.StatusGatewayTimeout: // 504
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// backoff calculates the delay before the next retry attempt.
|
|
// Uses exponential backoff with jitter, and respects Retry-After headers.
|
|
func (c *Client) backoff(attempt int, resp *http.Response) time.Duration {
|
|
// Check Retry-After header from previous response.
|
|
if resp != nil {
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if seconds, err := strconv.Atoi(ra); err == nil {
|
|
d := time.Duration(seconds) * time.Second
|
|
if d > 0 && d < 5*time.Minute {
|
|
return d
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exponential backoff: baseDelay * 2^attempt + jitter
|
|
delay := c.baseDelay
|
|
for i := 1; i < attempt; i++ {
|
|
delay *= 2
|
|
if delay > c.maxDelay {
|
|
delay = c.maxDelay
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add jitter: ±25%
|
|
jitter := time.Duration(float64(delay) * 0.5 * rand.Float64())
|
|
delay = delay + jitter - (delay / 4)
|
|
|
|
return delay
|
|
}
|