Cybersecurity-Projects/PROJECTS/advanced/monitor-the-situation-dashb.../backend/internal/httpx/client.go

226 lines
5.0 KiB
Go

// ©AngelaMos | 2026
// client.go
package httpx
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/cenkalti/backoff/v4"
"golang.org/x/time/rate"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit"
)
const (
defaultUserAgent = "monitor-the-situation/1.0"
defaultRequestTimeout = 30 * time.Second
defaultMaxRetryElapsed = 60 * time.Second
defaultInitialRetryBackoff = 500 * time.Millisecond
defaultMaxRetryBackoff = 10 * time.Second
defaultAPIKeyHeader = "X-API-Key"
clientErrorBodyLimit = 4096
)
type Config struct {
Name string
BaseURL string
Headers http.Header
BearerToken string
APIKey string
APIKeyHeader string
Rate rate.Limit
Burst int
ConsecutiveFailureBudget uint32
BreakerTimeout time.Duration
RequestTimeout time.Duration
MaxRetryElapsed time.Duration
UserAgent string
}
type Client struct {
cfg Config
limiter *ratelimit.Limiter[*http.Response]
hc *http.Client
}
type StatusError struct {
Code int
Body string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("client error %d: %s", e.Code, e.Body)
}
func New(cfg Config) *Client {
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = defaultRequestTimeout
}
if cfg.MaxRetryElapsed <= 0 {
cfg.MaxRetryElapsed = defaultMaxRetryElapsed
}
if cfg.UserAgent == "" {
cfg.UserAgent = defaultUserAgent
}
limiter := ratelimit.New[*http.Response](ratelimit.Config{
Name: cfg.Name,
Rate: cfg.Rate,
Burst: cfg.Burst,
ConsecutiveFailureBudget: cfg.ConsecutiveFailureBudget,
BreakerTimeout: cfg.BreakerTimeout,
})
return &Client{
cfg: cfg,
limiter: limiter,
hc: &http.Client{Timeout: cfg.RequestTimeout},
}
}
func (c *Client) GetJSON(
ctx context.Context,
path string,
query url.Values,
out any,
) error {
resp, err := c.Get(ctx, path, query)
if err != nil {
return err
}
defer drainAndClose(resp)
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *Client) Get(
ctx context.Context,
path string,
query url.Values,
) (*http.Response, error) {
target, err := c.resolveURL(path, query)
if err != nil {
return nil, err
}
bo := backoff.NewExponentialBackOff()
bo.MaxElapsedTime = c.cfg.MaxRetryElapsed
bo.InitialInterval = defaultInitialRetryBackoff
bo.MaxInterval = defaultMaxRetryBackoff
var resp *http.Response
op := func() error {
r, opErr := c.limiter.Do(
ctx,
func(rctx context.Context) (*http.Response, error) {
return c.do(rctx, target)
},
)
if opErr != nil {
return opErr
}
switch {
case r.StatusCode == http.StatusTooManyRequests:
retryAfter := parseRetryAfter(r.Header.Get("Retry-After"))
drainAndClose(r)
if retryAfter > 0 {
select {
case <-ctx.Done():
return backoff.Permanent(ctx.Err())
case <-time.After(retryAfter):
}
}
return errors.New("upstream 429")
case r.StatusCode >= 500 && r.StatusCode <= 599:
drainAndClose(r)
return fmt.Errorf("upstream %d", r.StatusCode)
case r.StatusCode >= 400:
body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit))
drainAndClose(r)
return backoff.Permanent(
&StatusError{Code: r.StatusCode, Body: string(body)},
)
}
resp = r
return nil
}
if err := backoff.Retry(op, backoff.WithContext(bo, ctx)); err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) do(
ctx context.Context,
target string,
) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", c.cfg.UserAgent)
for k, vs := range c.cfg.Headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
if c.cfg.BearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.BearerToken)
}
if c.cfg.APIKey != "" {
header := c.cfg.APIKeyHeader
if header == "" {
header = defaultAPIKeyHeader
}
req.Header.Set(header, c.cfg.APIKey)
}
return c.hc.Do(req)
}
func (c *Client) resolveURL(path string, query url.Values) (string, error) {
base, err := url.Parse(c.cfg.BaseURL)
if err != nil {
return "", err
}
rel, err := url.Parse(path)
if err != nil {
return "", err
}
resolved := base.ResolveReference(rel)
if query != nil {
resolved.RawQuery = query.Encode()
}
return resolved.String(), nil
}
func parseRetryAfter(v string) time.Duration {
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
d := time.Until(t)
if d > 0 {
return d
}
}
return 0
}
func drainAndClose(r *http.Response) {
if r == nil || r.Body == nil {
return
}
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}