Опирается на правила:
R-RES-CB-1…R-RES-CB-6иR-RES-CB-X1…R-RES-CB-X3из Resilience Style Guide → раздел 4. Circuit Breaker.
Важно знать
@CircuitBreaker(name = "<system>")— на public-методе out-adapter. Не на generated client, не на handler, не на репозитории.- Sliding window — COUNT_BASED, не TIME_BASED. Size 50, minimumNumberOfCalls 10.
- Failure rate threshold: 50% — дефолт; 30% — для критичных систем (платежи).
waitDurationInOpenState: 30s. Затем half-open сpermittedNumberOfCallsInHalfOpenState: 3— все три должны пройти, иначе обратно в open.slowCallDurationThreshold=readTimeout / 2. Срабатывает раньше реального timeout — ловит «system is slow but not yet broken».- При open-state CB бросает
CallNotPermittedException. Адаптер маппит в port-specific exception, handler — в 503 / 409.- Не custom CB на try/catch +
AtomicInteger. Resilience4j отлажен, integrated с Micrometer.
Circuit Breaker — это выключатель: когда внешняя система явно лежит, новые вызовы перестают идти и сразу падают с CallNotPermittedException. Это спасает thread-pool от заполнения «висящими» запросами и даёт внешней системе шанс восстановиться без дополнительной нагрузки. Раскрытие раздела 4 гайда.
@CircuitBreaker на public-методе out-adapter
R-RES-CB-1: аннотация — на public-методе класса, который реализует port (например, PaymentPort). Не на generated client, не на handler-е, не на репозитории.
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final SberOrderServicesApi sberApi; // generated by openapi-generator
private final SberRequestMapper mapper;
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
@Bulkhead(name = "sber")
@Retry(name = "sber")
@Override
public RegisterResult register(RegisterCommand cmd) {
SberRegisterRequest req = mapper.toApiRequest(cmd);
SberRegisterResponse resp = executeCall(sberApi.register(req, null));
return mapper.toDomain(resp);
}
private RegisterResult registerFallback(RegisterCommand cmd, Throwable t) {
log.warn("Sber unavailable, queuing for retry", t);
Long taskId = taskQueue.enqueue(toRegisterTask(cmd));
return RegisterResult.queued(taskId);
}
}
Почему именно так:
- Не на generated
SberOrderServicesApi— это интерфейс openapi-generator. ЛюбаяcompileJavaперегенерирует, аннотации потеряются. - Не в
executeCall<T>helper'е сbackendNameстрокой — теряется compile-time check имени (@CircuitBreaker(name = backendName)нельзя). - Не на handler-е (
ConfirmOrderHandler) — handler оркеструет, port — это и есть граница защиты. - Не на репозитории — локальные операции не имеют транзиентов (см. Где какая защита).
Sliding window — COUNT_BASED
R-RES-CB-2: считаем по числу вызовов, не по времени.
resilience4j.circuitbreaker.instances.sber:
sliding-window-type: COUNT_BASED
sliding-window-size: 50
minimum-number-of-calls: 10
failure-rate-threshold: 30 # критичная система — порог ниже
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
slow-call-duration-threshold: 15s # = readTimeout/2 (readTimeout=30s)
slow-call-rate-threshold: 50
Почему COUNT_BASED, а не TIME_BASED:
- БД-нагруженные сервисы. Outbound идёт неравномерно: всплески после write-операций, паузы между. TIME_BASED окно (например, 30s) ничего не показывает в паузу.
- Предсказуемость threshold. «50% из 50 последних» — четкий критерий: 25 failures в 50 calls → open. «50% за 30 секунд» зависит от того, сколько было calls — может быть и 1 из 2.
Failure rate threshold — 30% для критичных, 50% для дефолта
R-RES-CB-3: дефолт 50% — Resilience4j-стандарт. Для платежей и других критичных систем — 30%: «лучше открыть CB раньше, чем продолжать давить на лежачую систему».
Рассуждение про 30% vs 50% для платежей:
- При 50% threshold half open сработает после 25 failures из 50. На реально лежачей Sber это 5 минут ожидания пользователей (25 × 10s read-timeout).
- При 30% threshold — 15 failures из 50. Это 2.5 минуты — на 50% быстрее fast-fail mode.
- Trade-off: ложные срабатывания (transient 5xx) при низком threshold чаще. Но для money лучше fast-fail с retry через task-queue, чем разогревать deadlock-цикл.
Half-open и permittedNumberOfCallsInHalfOpenState
R-RES-CB-4: после waitDurationInOpenState: 30s CB переходит в half-open. Пропускает permittedNumberOfCallsInHalfOpenState: 3 пробных вызова:
- Все 3 успешны → closed (нормальная работа).
- Хотя бы 1 fail → open (ещё 30s ожидания).
Почему 3, не 1 и не 10:
- 1 — слишком хрупко: один transient 5xx во время восстановления отбрасывает в open на ещё 30s. Реально лежащая система может никогда не восстановиться по таким «пробам».
- 10 — слишком много нагрузки за раз. Если система ещё не готова, 10 calls создадут load spike и подтопят восстановление.
- 3 — компромисс: статистически репрезентативно, но не overload.
slowCallDurationThreshold — раньше, чем сам timeout
R-RES-CB-5: помимо явных ошибок CB считает «медленные» вызовы как failures, если они занимают больше slowCallDurationThreshold.
resilience4j.circuitbreaker.instances.sber:
slow-call-duration-threshold: 15s # readTimeout = 30s → /2
slow-call-rate-threshold: 50
Что это даёт:
- Без этого CB ловит только error-ответы (5xx, IOException). Если внешняя система медленно умирает (отвечает за 25 секунд вместо 1), CB закрыт до того, как первый timeout сработает.
- С
slow-call-rate-threshold: 50— если 50% последних вызовов идут >15s, CB открывается до того, как они превратятся в timeout. - Threshold =
readTimeout / 2потому что: 50% от полного timeout — явный сигнал деградации, не разовый всплеск.
Маппинг CallNotPermittedException
R-RES-CB-6: когда CB открыт, Resilience4j бросает io.github.resilience4j.circuitbreaker.CallNotPermittedException. Адаптер маппит её в port-specific exception, handler — в HTTP-статус.
@Component
public class SberClientAdapter implements PaymentPort {
@CircuitBreaker(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
try {
return doRegister(cmd);
} catch (CallNotPermittedException e) {
throw new PaymentPortException.SystemUnavailable("sber", e);
}
}
}
// Error-handler в bootstrap/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PaymentPortException.SystemUnavailable.class)
public ResponseEntity<ProblemDetails> handleSystemUnavailable(
PaymentPortException.SystemUnavailable e) {
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ProblemDetails("system_unavailable",
"Платёжная система временно недоступна, попробуйте позже"));
}
}
См. Error Handling → Иерархия исключений — почему port-specific, а не общий exception.
Что запрещено
@CircuitBreaker на репозитории
R-RES-CB-X1: см. Где какая защита. Локальные операции не имеют транзиентов.
Custom CB на try/catch + AtomicInteger
R-RES-CB-X2: писать CB вручную — гарантированный bug-source.
// ПЛОХО — самописный CB
private final AtomicInteger failures = new AtomicInteger(0);
private volatile long openedAt = 0;
public RegisterResult register(RegisterCommand cmd) {
if (System.currentTimeMillis() - openedAt < 30_000) {
throw new RuntimeException("circuit open");
}
try {
RegisterResult r = doRegister(cmd);
failures.set(0);
return r;
} catch (Exception e) {
if (failures.incrementAndGet() > 10) {
openedAt = System.currentTimeMillis();
}
throw e;
}
}
Что не так:
- Нет sliding window — считаем cumulative failures, никогда не «забываем».
- Нет half-open пробных вызовов — после 30s сразу открываем поток, можем тут же снова положить.
- Нет интеграции с Micrometer —
resilience4j_circuitbreaker_stateне появится в Prometheus. - Не учитывает slow-calls.
Корректно: Resilience4j делает всё это из коробки. Один import, одна аннотация, declarative-конфиг.
@CircuitBreaker без name или с общим name="default"
R-RES-CB-X3: один общий CB на разные системы = state у них общий.
// ПЛОХО — один CB на две системы
@CircuitBreaker(name = "default")
public RegisterResult sberRegister(...) { ... }
@CircuitBreaker(name = "default")
public CashboxResponse odnaKassaSend(...) { ... }
Что не так: если Sber начнёт отвечать 5xx, CB откроется и OdnaKassa тоже перестанет работать, хотя с ней всё в порядке. Per-system name — обязателен.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@CircuitBreaker на репозитории / JOOQ-вызове | R-RES-CB-X1 | Только на public-методе out-adapter |
| Custom CB на try/catch + AtomicInteger | R-RES-CB-X2 | Resilience4j из коробки |
@CircuitBreaker(name = "default") для всех систем | R-RES-CB-X3 | Per-system name (sber, odnakassa) |
TIME_BASED sliding window для outbound | R-RES-CB-2 | COUNT_BASED |
Без slowCallDurationThreshold | R-RES-CB-5 | = readTimeout / 2 |
Без permittedNumberOfCallsInHalfOpenState | R-RES-CB-4 | 3 пробных вызова |
CallNotPermittedException пропускается наружу как есть | R-RES-CB-6 | Mapping в port-specific exception |
Куда дальше
- Resilience → раздел 4. Circuit Breaker — нормативные
R-RES-CB-*. - Per-system isolation — отдельный CB instance на систему.
- Timeouts — почему
slowCallDurationThreshold = readTimeout/2. - Bulkhead — semaphore-based limit на concurrent calls.
- Retry — когда повторять, когда нет.
- Fallback — что делать при open-state CB.
- Конфигурация — declarative config.
- Error Handling → Иерархия исключений — port-specific exceptions.