
Комбинация трех простых техник дает максимум эффекта:
Предохранитель следит за окном запросов (например, последние 10 секунд) и долей неуспехов. Если:
он «размыкает» цепь на короткое время. Все новые вызовы в этот момент сразу получают быстрый отказ или «мягкий» фолбэк (например, ответ из кэша), не добивая зависимость. Затем предохранитель переходит в «полуоткрытое» состояние: пропускает несколько пробных запросов. Если те успешны — возвращаемся в «закрытое» (нормальная работа), если нет — снова «открытое».
Чем это полезно:
Ограничение параллелизма — это семафор на вызовы в зависимости. Например, одновременно держим не больше 50 запросов к платежному провайдеру. Остальные — сразу отклоняем (сброс нагрузки) или ставим в крохотную очередь с таймаутом. Ключевое: не допускать длинных очередей, которые превращают секунды проблем в минуты деградации.
Простой принцип:
Стартовые настройки:
Пусть лучше 5–10% запросов получат быстрый отказ в пике, чем 100% пользователей увидят зависания и отмены.
Ниже — рабочий пример: предохранитель с окном ошибок, полуоткрытым состоянием и ограничением параллельности для HTTP‑клиента. В проде добавьте метрики (счетчики ошибок, отказов по лимиту, состояние предохранителя) и конфиг через переменные окружения.
package main
import (
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// Состояния предохранителя
type breakerState int
const (
stateClosed breakerState = iota
stateOpen
stateHalfOpen
)
type bucket struct {
success int
failure int
start time.Time
}
type rollingWindow struct {
buckets []bucket
bucketDur time.Duration
mu sync.Mutex
}
func newRollingWindow(window time.Duration, buckets int) *rollingWindow {
if buckets <= 0 {
buckets = 10
}
bd := window / time.Duration(buckets)
if bd <= 0 {
bd = time.Second
}
rw := &rollingWindow{
buckets: make([]bucket, buckets),
bucketDur: bd,
}
now := time.Now()
for i := range rw.buckets {
rw.buckets[i].start = now.Add(-time.Duration(len(rw.buckets)-i) * bd)
}
return rw
}
func (rw *rollingWindow) rotate() {
now := time.Now()
last := &rw.buckets[len(rw.buckets)-1]
if now.Sub(last.start) < rw.bucketDur {
return
}
shift := int(now.Sub(last.start) / rw.bucketDur)
if shift > len(rw.buckets) {
shift = len(rw.buckets)
}
for i := 0; i < shift; i++ {
// сдвигаем влево, крайний — новый пустой
copy(rw.buckets, rw.buckets[1:])
rw.buckets[len(rw.buckets)-1] = bucket{start: last.start.Add(time.Duration(i+1) * rw.bucketDur)}
}
// обновим времена после сдвига
for i := range rw.buckets {
rw.buckets[i].start = now.Add(time.Duration(i-len(rw.buckets)) * rw.bucketDur)
}
}
func (rw *rollingWindow) addSuccess() {
rw.mu.Lock()
defer rw.mu.Unlock()
rw.rotate()
rw.buckets[len(rw.buckets)-1].success++
}
func (rw *rollingWindow) addFailure() {
rw.mu.Lock()
defer rw.mu.Unlock()
rw.rotate()
rw.buckets[len(rw.buckets)-1].failure++
}
func (rw *rollingWindow) counts() (succ, fail int) {
rw.mu.Lock()
defer rw.mu.Unlock()
rw.rotate()
for _, b := range rw.buckets {
succ += b.success
fail += b.failure
}
return
}
// Настройки предохранителя
type BreakerOptions struct {
Window time.Duration // окно анализа
Buckets int // число «ведер» в окне
FailureThreshold float64 // порог ошибок (0.0..1.0)
MinimumRequests int // минимум обращений для принятия решения
OpenTimeout time.Duration // сколько держать «открытое» состояние
ProbeRequests int // сколько параллельных проб в «полуоткрытом» состоянии
ProbeSuccesses int // сколько успешных проб подряд, чтобы закрыть
OnStateChange func(from, to breakerState)
}
type Breaker struct {
mu sync.Mutex
state breakerState
lastChange time.Time
rw *rollingWindow
opts BreakerOptions
halfOpenInFlight int
halfOpenSuccess int
}
func NewBreaker(opts BreakerOptions) *Breaker {
if opts.Window == 0 {
opts.Window = 10 * time.Second
}
if opts.Buckets <= 0 {
opts.Buckets = 10
}
if opts.FailureThreshold <= 0 {
opts.FailureThreshold = 0.5
}
if opts.MinimumRequests <= 0 {
opts.MinimumRequests = 20
}
if opts.OpenTimeout <= 0 {
opts.OpenTimeout = 30 * time.Second
}
if opts.ProbeRequests <= 0 {
opts.ProbeRequests = 1
}
if opts.ProbeSuccesses <= 0 {
opts.ProbeSuccesses = 1
}
return &Breaker{
state: stateClosed,
lastChange: time.Now(),
rw: newRollingWindow(opts.Window, opts.Buckets),
opts: opts,
}
}
func (b *Breaker) setState(to breakerState) {
if b.state == to {
return
}
from := b.state
b.state = to
b.lastChange = time.Now()
b.halfOpenInFlight = 0
b.halfOpenSuccess = 0
if b.opts.OnStateChange != nil {
go b.opts.OnStateChange(from, to)
}
}
// Allow решает, можно ли сейчас делать вызов.
func (b *Breaker) Allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case stateOpen:
if time.Since(b.lastChange) >= b.opts.OpenTimeout {
b.setState(stateHalfOpen)
// падаем дальше в half-open логику
} else {
return false
}
}
if b.state == stateHalfOpen {
if b.halfOpenInFlight < b.opts.ProbeRequests {
b.halfOpenInFlight++
return true
}
return false
}
return true // closed
}
func (b *Breaker) OnSuccess() {
b.rw.addSuccess()
b.mu.Lock()
defer b.mu.Unlock()
if b.state == stateClosed {
// проверим, не пора ли закрыть (ничего не делаем — и так закрыт)
return
}
if b.state == stateHalfOpen {
b.halfOpenInFlight--
b.halfOpenSuccess++
if b.halfOpenSuccess >= b.opts.ProbeSuccesses {
b.setState(stateClosed)
}
}
}
func (b *Breaker) OnFailure() {
b.rw.addFailure()
b.mu.Lock()
defer b.mu.Unlock()
if b.state == stateHalfOpen {
b.halfOpenInFlight--
// любая неуспешная проба — обратно в open
b.setState(stateOpen)
return
}
// closed — проверим пороги
succ, fail := b.rw.counts()
total := succ + fail
if total < b.opts.MinimumRequests {
return
}
frac := 0.0
if total > 0 {
frac = float64(fail) / float64(total)
}
if frac >= b.opts.FailureThreshold {
b.setState(stateOpen)
}
}
// Транспорт с предохранителем и ограничением параллелизма
type CBTransport struct {
Base http.RoundTripper
Breaker *Breaker
Semaphore chan struct{} // ограничение одновременных запросов
}
func (t *CBTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
base = http.DefaultTransport
}
if t.Breaker != nil {
if !t.Breaker.Allow() {
// быстрый отказ — «предохранитель открыт»
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Status: http.StatusText(http.StatusServiceUnavailable),
Body: io.NopCloser(strings.NewReader("circuit open")),
Request: req,
Header: make(http.Header),
}, nil
}
}
// Ограничение параллелизма
if t.Semaphore != nil {
select {
case t.Semaphore <- struct{}{}:
defer func() { <-t.Semaphore }()
default:
// сброс нагрузки — слишком много одновременных
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Status: http.StatusText(http.StatusTooManyRequests),
Body: io.NopCloser(strings.NewReader("load shedding")),
Request: req,
Header: make(http.Header),
}, nil
}
}
resp, err := base.RoundTrip(req)
if err != nil {
if t.Breaker != nil {
t.Breaker.OnFailure()
}
return nil, err
}
// Считаем неуспехами сетевые/серверные ошибки и 429/408
status := resp.StatusCode
failed := status >= 500 || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout
if t.Breaker != nil {
if failed {
t.Breaker.OnFailure()
} else {
t.Breaker.OnSuccess()
}
}
return resp, nil
}
func main() {
br := NewBreaker(BreakerOptions{
Window: 10 * time.Second,
Buckets: 10,
FailureThreshold: 0.6,
MinimumRequests: 20,
OpenTimeout: 20 * time.Second,
ProbeRequests: 2,
ProbeSuccesses: 2,
OnStateChange: func(from, to breakerState) {
log.Printf("breaker state: %v -> %v", from, to)
},
})
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &CBTransport{
Base: http.DefaultTransport,
Breaker: br,
Semaphore: make(chan struct{}, 50), // максимум 50 одновременных вызовов
},
}
// Демонстрация: часть запросов — успешные, часть — ошибки
urls := []string{
"https://httpbin.org/status/200",
"https://httpbin.org/status/500",
"https://httpbin.org/delay/2", // иногда упрется в таймаут клиента
}
for i := 0; i < 200; i++ {
u := urls[i%len(urls)]
resp, err := client.Get(u)
if err != nil {
log.Printf("req %d %s error: %v", i, u, err)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
log.Printf("req %d %s -> %d", i, u, resp.StatusCode)
time.Sleep(100 * time.Millisecond)
}
}
Как это работает:
Вместо синтетического 503/429 вы можете подставлять «мягкий» ответ — из локального кэша, упрощенную версию, старые данные. Это особенно полезно для GET‑запросов, где «вчерашние данные лучше, чем ошибка». В транспорт легко добавить колбэк для фолбэка.
Не складывайте все вызовы в один «котел». Для каждой ключевой зависимости держите отдельный семафор и предохранитель:
Так деградация аналитики не заблокирует платежи. В HTTP‑шлюзе эту идею дополняют лимиты per‑route/per‑upstream.
Пример на уровне NGINX (дополнительный внешний контур):
# Ограничение скорости запросов и одновременных соединений
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $server_name zone=perhost:10m;
server {
listen 443 ssl;
location /payments/ {
limit_req zone=perip burst=20 nodelay;
limit_conn perhost 50;
proxy_pass http://payments_upstream;
}
}
Что мониторить:
Как выкатывать безопасно:
Итог: предохранитель + ограничение параллелизма + контролируемый сброс нагрузки — простая и мощная защита от каскадных отказов. Вы стабилизируете SLA, сокращаете аварии и экономите на инфраструктуре, вместо того чтобы латать последствия шторма запросов.