package httpclient import ( "context" "errors" "fmt" "io" "net/http" "net/url" "time" "golang.org/x/time/rate" ) type Client struct { httpClient *http.Client baseURL string limiter *rate.Limiter retryCount int } type Options struct { BaseURL string Timeout time.Duration RetryCount int RPS float64 Burst int } func New(opts Options) *Client { if opts.Timeout == 0 { opts.Timeout = 10 * time.Second } if opts.RPS <= 0 { opts.RPS = 20 } if opts.Burst <= 0 { opts.Burst = 40 } return &Client{ httpClient: &http.Client{Timeout: opts.Timeout}, baseURL: opts.BaseURL, retryCount: opts.RetryCount, limiter: rate.NewLimiter(rate.Limit(opts.RPS), opts.Burst), } } type Request struct { Method string Path string Query url.Values Header http.Header } func (c *Client) Do(ctx context.Context, req Request) (int, []byte, http.Header, error) { if err := c.limiter.Wait(ctx); err != nil { return 0, nil, nil, err } fullURL := c.baseURL + req.Path if len(req.Query) > 0 { fullURL += "?" + req.Query.Encode() } var lastErr error for attempt := 0; attempt <= c.retryCount; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return 0, nil, nil, ctx.Err() case <-time.After(time.Duration(attempt) * 500 * time.Millisecond): } } httpReq, err := http.NewRequestWithContext(ctx, req.Method, fullURL, nil) if err != nil { return 0, nil, nil, err } for k, vs := range req.Header { for _, v := range vs { httpReq.Header.Add(k, v) } } resp, err := c.httpClient.Do(httpReq) if err != nil { lastErr = err continue } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { lastErr = readErr continue } if shouldRetry(resp.StatusCode) && attempt < c.retryCount { lastErr = fmt.Errorf("retryable status %d", resp.StatusCode) continue } return resp.StatusCode, body, resp.Header, nil } if lastErr == nil { lastErr = errors.New("unknown http error") } return 0, nil, nil, lastErr } func shouldRetry(status int) bool { switch status { case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true } return false }