// 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) }