Circuit breaker на Go: пишем свой за 100 строк и разбираем, почему gobreaker работает иначе
- пятница, 1 мая 2026 г. в 00:00:08
Когда сервис, от которого вы зависите, начинает отвечать по 10 секунд вместо 50 миллисекунд, ваш сервис тоже начинает отвечать по 10 секунд. Горутины висят в ожидании, пул соединений забивается, таймауты каскадируются вверх.
С Circuit breaker если количество ошибок превысило порог, он перестаёт отправлять запросы к сбойному сервису. Вместо ожидания таймаута вызывающий код получает ошибку мгновенно. Через какое‑то время breaker пробует один запрос: прошёл — цепь замыкается, нет — ждём дальше.
Напишем свой за 100 строк, а потом сравним с gobreaker от Sony.
Closed: нормальная работа, все запросы проходят, breaker считает ошибки. Если количество ошибок за период превысило порог, переход в Open.
Open: все запросы отклоняются мгновенно. После таймаута переход в Half‑Open.
Half‑Open: breaker пропускает один запрос. Успешен, переход в Closed. Нет, обратно в Open.
package breaker import ( "errors" "sync" "time" ) var ErrCircuitOpen = errors.New("circuit breaker is open") type State int const ( Closed State = iota Open HalfOpen ) type CircuitBreaker struct { mu sync.Mutex state State failures int successes int threshold int timeout time.Duration halfOpenMax int lastFailure time.Time } func New(threshold int, timeout time.Duration) *CircuitBreaker { return &CircuitBreaker{ state: Closed, threshold: threshold, timeout: timeout, halfOpenMax: 1, } } func (cb *CircuitBreaker) Execute(fn func() error) error { cb.mu.Lock() state := cb.currentState() if state == Open { cb.mu.Unlock() return ErrCircuitOpen } cb.mu.Unlock() err := fn() cb.mu.Lock() defer cb.mu.Unlock() if err != nil { cb.onFailure() return err } cb.onSuccess() return nil } func (cb *CircuitBreaker) currentState() State { if cb.state == Open && time.Since(cb.lastFailure) > cb.timeout { cb.state = HalfOpen cb.successes = 0 } return cb.state } func (cb *CircuitBreaker) onFailure() { cb.failures++ cb.lastFailure = time.Now() switch cb.state { case Closed: if cb.failures >= cb.threshold { cb.state = Open } case HalfOpen: cb.state = Open cb.failures = 0 } } func (cb *CircuitBreaker) onSuccess() { switch cb.state { case Closed: cb.failures = 0 case HalfOpen: cb.successes++ if cb.successes >= cb.halfOpenMax { cb.state = Closed cb.failures = 0 } } }
cb := breaker.New(5, 30*time.Second) err := cb.Execute(func() error { resp, err := http.Get("https://api.example.com/data") if err != nil { return err } if resp.StatusCode >= 500 { return fmt.Errorf("server error: %d", resp.StatusCode) } return nil }) if errors.Is(err, breaker.ErrCircuitOpen) { return cachedResponse, nil }
Пять ошибок подряд, breaker открывается. Следующие 30 секунд все вызовы возвращают ErrCircuitOpen мгновенно. Через 30 секунд пропускает один запрос. Прошёл, работаем нормально.
Наша реализация работает, но в реале с ней будут проблемы.
Скользящее окно. Наш breaker считает ошибки с момента последнего сброса. Пять ошибок за час, и breaker откроется, хотя ситуация не аварийная. Gobreaker считает ошибки за скользящее окно в N секунд, старые ошибки вытесняются.
settings := gobreaker.Settings{ Interval: 60 * time.Second, ReadyToTrip: func(counts gobreaker.Counts) bool { return counts.ConsecutiveFailures > 5 }, }
ReadyToTrip — это функция, которая решает, когда открывать breaker. Можно считать по абсолютному числу, по проценту, по количеству подряд. Наша реализация жёстко зашита на «N ошибок», gobreaker даёт гибкость.
Half‑Open с несколькими запросами. Мы пропускаем один запрос и по нему решаем. Gobreaker позволяет настроить MaxRequests: сколько запросов пропустить в Half‑Open перед решением. Один запрос — это шумно, если он случайно упал по таймауту, breaker откроется обратно, хотя сервис уже восстановился. Три‑пять запросов дают более стабильное решение.
Callback‑и на переходах. Gobreaker вызывает OnStateChange при смене состояния, это нужно для метрик:
settings := gobreaker.Settings{ OnStateChange: func(name string, from, to gobreaker.State) { log.Printf("breaker %s: %s -> %s", name, from, to) }, }
Two‑step execution. Gobreaker предлагает Allow() + Done() для случаев, когда вызов не помещается в одну функцию (открываете стрим, закрываете потом):
generation, err := cb.Allow() if err != nil { return err } result, err := callDownstream() cb.Done(generation, err)
Circuit breaker не заменяет retry и timeout, он дополняет их. Типичная цепочка: retry с экспоненциальным backoff оборачивает вызов, circuit breaker оборачивает retry, timeout оборачивает всё.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() err := cb.Execute(func() error { return retry.Do(func() error { return callService(ctx) }, retry.Attempts(3), retry.Delay(100*time.Millisecond)) })
Без circuit breaker retry будет долбить сбойный сервис до таймаута. Без retry circuit breaker будет открываться на каждый одиночный сбой. Без timeout вся цепочка может висеть бесконечно.
Если у вас один downstream и он упал, а отдать вместо него нечего (нет fallback, нет кеша), breaker не поможет, вы всё равно вернёте ошибку. Если downstream отвечает быстро, но с 4xx ошибками, это не повод размыкать цепь: downstream работает, просто входные данные невалидны.
Breaker имеет смысл, когда есть альтернатива (кеш, другой инстанс, дефолтное значение) и когда ошибки downstream вызваны его перегрузкой, а не вашими запросами.
Наша реализация за 100 строк покрывает 80% случаев. Для оставшихся 20% берите gobreaker, добавляйте скользящее окно и метрики.

Курс «Go‑разработчик. Продвинутый уровень» — для тех, кто уже пишет на Go и хочет глубже понимать, как язык работает под капотом: от интерфейсов и планировщика до конкурентности, архитектуры и надежности production‑сервисов.
В преддверии старта курса пройдут бесплатные открытые уроки от преподавателей‑практиков OTUS. На них можно познакомиться с экспертами, протестировать формат обучения и задать вопросы.
4 мая в 20:00. «Интерфейсы в Golang изнутри». Записаться
Разберетесь, как интерфейсы устроены внутри: iface, itab, _type и data
18 мая в 20:00. «Go внутри: планировщик». Записаться
Обсудим, почему горутины легче потоков, когда они переключаются и как Go обходит ограничения ОС
✍️ Также можно пройти бесплатное вступительное тестирование — оно поможет оценить текущий уровень подготовки и понять, насколько программа курса «Go‑разработчик. Продвинутый уровень» подходит под ваши задачи. |