Files
traefik-selectel/internal/httpx/retry.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

129 lines
3.4 KiB
Go

// Package httpx содержит общий HTTP-хелпер: ретраи с экспоненциальным backoff на 429/5xx.
package httpx
import (
"context"
"io"
"net/http"
"strconv"
"time"
)
// MaxBodyBytes — верхняя граница читаемого тела ответа.
const MaxBodyBytes = 16 << 20
// Retrier повторяет запросы при сетевых ошибках, 429 и 5xx (кроме 501).
type Retrier struct {
MaxAttempts int // общее число попыток (>=1)
BaseDelay time.Duration // задержка перед второй попыткой
MaxDelay time.Duration // верхняя граница задержки (в т.ч. Retry-After)
Sleep func(ctx context.Context, d time.Duration) error
}
// DefaultRetrier — разумные значения по умолчанию.
func DefaultRetrier() Retrier {
return Retrier{MaxAttempts: 4, BaseDelay: 500 * time.Millisecond, MaxDelay: 15 * time.Second}
}
// Do выполняет запрос, собираемый build (вызывается на каждой попытке).
// Возвращённый ответ (в т.ч. с кодом 5xx после исчерпания попыток) должен быть закрыт вызывающим.
func (r Retrier) Do(ctx context.Context, c *http.Client, build func() (*http.Request, error)) (*http.Response, error) {
attempts := r.MaxAttempts
if attempts < 1 {
attempts = 1
}
sleep := r.Sleep
if sleep == nil {
sleep = sleepCtx
}
var lastErr error
for attempt := 1; ; attempt++ {
req, err := build()
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
resp, err := c.Do(req)
var delay time.Duration
switch {
case err != nil:
if ctx.Err() != nil {
return nil, ctx.Err()
}
lastErr = err
delay = r.backoff(attempt)
case retryable(resp.StatusCode):
if attempt >= attempts {
return resp, nil
}
delay = r.backoff(attempt)
if ra := retryAfter(resp); ra > 0 {
delay = ra
if r.MaxDelay > 0 && delay > r.MaxDelay {
delay = r.MaxDelay
}
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
default:
return resp, nil
}
if attempt >= attempts {
return nil, lastErr
}
if err := sleep(ctx, delay); err != nil {
return nil, err
}
}
}
func (r Retrier) backoff(attempt int) time.Duration {
d := r.BaseDelay
for i := 1; i < attempt; i++ {
d *= 2
if r.MaxDelay > 0 && d >= r.MaxDelay {
return r.MaxDelay
}
}
return d
}
func retryable(code int) bool {
return code == http.StatusTooManyRequests || (code >= 500 && code != http.StatusNotImplemented)
}
func retryAfter(resp *http.Response) time.Duration {
v := resp.Header.Get("Retry-After")
if v == "" {
return 0
}
if sec, err := strconv.Atoi(v); err == nil && sec >= 0 {
return time.Duration(sec) * time.Second
}
return 0
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// ReadBody читает тело ответа с ограничением по размеру.
func ReadBody(resp *http.Response) ([]byte, error) {
return io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes))
}
// Snippet возвращает обрезанное тело для сообщений об ошибках.
func Snippet(b []byte, n int) string {
if len(b) > n {
b = b[:n]
}
return string(b)
}