feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func noSleep(context.Context, time.Duration) error { return nil }
|
||||
|
||||
func TestRetrierRetriesOn5xxAnd429(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1:
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
case 2:
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := Retrier{MaxAttempts: 4, BaseDelay: time.Millisecond, Sleep: noSleep}
|
||||
resp, err := r.Do(context.Background(), srv.Client(), func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 || calls != 3 {
|
||||
t.Fatalf("status=%d calls=%d", resp.StatusCode, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrierNoRetryOn4xx(t *testing.T) {
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := Retrier{MaxAttempts: 4, BaseDelay: time.Millisecond, Sleep: noSleep}
|
||||
resp, err := r.Do(context.Background(), srv.Client(), func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if calls != 1 || resp.StatusCode != 404 {
|
||||
t.Fatalf("calls=%d status=%d", calls, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrierExhaustedReturnsLastResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := Retrier{MaxAttempts: 2, BaseDelay: time.Millisecond, Sleep: noSleep}
|
||||
resp, err := r.Do(context.Background(), srv.Client(), func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 503 {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrierNetworkError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close()
|
||||
r := Retrier{MaxAttempts: 3, BaseDelay: time.Millisecond, Sleep: noSleep}
|
||||
_, err := r.Do(context.Background(), http.DefaultClient, func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodGet, url, nil)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffCapped(t *testing.T) {
|
||||
r := Retrier{BaseDelay: time.Second, MaxDelay: 5 * time.Second}
|
||||
if got := r.backoff(1); got != time.Second {
|
||||
t.Errorf("attempt1 = %v", got)
|
||||
}
|
||||
if got := r.backoff(10); got != 5*time.Second {
|
||||
t.Errorf("attempt10 = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user