feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 21:50:07 +07:00
co-authored by Claude Sonnet 5
commit 273cd54415
35 changed files with 3722 additions and 0 deletions
+128
View File
@@ -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)
}
+99
View File
@@ -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)
}
}