Опирается на правила: R-RES-CB-1R-RES-CB-6 и R-RES-CB-X1R-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 + AtomicIntegerR-RES-CB-X2Resilience4j из коробки
@CircuitBreaker(name = "default") для всех системR-RES-CB-X3Per-system name (sber, odnakassa)
TIME_BASED sliding window для outboundR-RES-CB-2COUNT_BASED
Без slowCallDurationThresholdR-RES-CB-5= readTimeout / 2
Без permittedNumberOfCallsInHalfOpenStateR-RES-CB-43 пробных вызова
CallNotPermittedException пропускается наружу как естьR-RES-CB-6Mapping в 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.