Опирается на правила: R-RES-WHERE-1R-RES-WHERE-4 и R-RES-WHERE-X1 из Resilience Style Guide → раздел 1. Где какая защита.

Важно знать

  • Outbound HTTP к внешним системам — полный набор: timeout + @CircuitBreaker + @Bulkhead + опционально @Retry. Без CB первый «slow burn» внешней системы выедает thread pool сервиса.
  • Internal service-to-service (между нашими микросервисами) — timeout + @CircuitBreaker. Bulkhead — по необходимости (если вызов тяжёлый или критичный).
  • Schedulers и outbox-relaytask-queue retry через таблицу БД, не Resilience4j. R4J ловит транзиенты <5s; task-queue — долгие отказы (>30s) и переживает рестарт сервиса.
  • Inbound REST (наш API) — RateLimiter на edge (API Gateway). @RateLimiter в коде только если gateway недоступен.
  • Локальный код (репозиторий, JOOQ, in-memory вычисления) — без Resilience4j. Нет транзиентов «иногда работает, иногда нет».
  • Resilience4j защищает от отказов среды. Бизнес-ошибки (404, 4xx) защищать не нужно — они контрактные.

Resilience4j — это не «навесить везде на всякий случай». У каждой группы вызовов своя категория защиты, и навешивание не туда вредит больше, чем помогает. Раскрытие раздела 1 гайда.

Outbound HTTP к внешним системам — полный набор

R-RES-WHERE-1: любой вызов к внешней системе (платежи, фискализация, страхование, SMS, любой сторонний API) защищён полным набором.

@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {

    private final SberOrderServicesApi sberApi;

    @CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
    @Bulkhead(name = "sber")
    @Retry(name = "sber")
    @Override
    public RegisterResult register(RegisterCommand cmd) {
        return executeCall(sberApi.register(toApiRequest(cmd), null));
    }
}

Что даёт каждый слой:

  • Timeouts (connectTimeout, readTimeout, callTimeout на OkHttpClient) — гарантия, что один call не висит вечно.
  • @CircuitBreaker — fast-fail когда система явно лежит: после N подряд ошибок CB открывается и следующие call'ы падают мгновенно, не нагружая внешнюю систему.
  • @Bulkhead — ограничение concurrent invocations: даже если CB ещё закрыт, не более N одновременных вызовов к этой системе.
  • @Retry — повтор только при условии идемпотентности (см. Retry). Не везде.

Без CB первый медленный API выедает thread pool: 20 запросов висят по 30s, новые тоже висят, весь сервис лежит. С CB на 11-м call'е (после 10 failures) поток уходит в fast-fail.

Internal service-to-service — timeout + CB

R-RES-WHERE-2: вызовы между нашими собственными микросервисами тоже защищаются, но с меньшим набором. Bulkhead — опционально.

@CircuitBreaker(name = "customer-service")
public CustomerView fetchCustomer(Long id) {
    return customerServiceClient.getById(id);
}

Почему меньше:

  • Внутренние сервисы под нашим контролем — SLA лучше, retry-семантика прозрачна.
  • При деградации одного нашего сервиса CB просто остановит каскад. Bulkhead нужен, только если этот call идёт из горячего пути или вызовы могут затопить пул.
  • Retry между нашими сервисами обычно опасен (двойные write); тщательнее, чем на внешний API.

Schedulers и outbox-relay — task-queue, не Resilience4j

R-RES-WHERE-3: для scheduled работ (cron-job, outbox-relay, polling-task) Resilience4j не подходит. Используется паттерн task-queue в БД.

Почему R4J не подходит:

  • R4J живёт в памяти процесса. Restart сервиса = потеря CB-state и Retry-attempt counter'ов.
  • R4J ретраит в рамках одного вызова: in-memory backoff до 5 секунд. Для отказа на минуты этого мало.
  • Сделать Retry(maxAttempts = 100, waitDuration = 60s) нельзя — это 100×60s = 100 минут блока worker'а.

Task-queue решает это через персистентную таблицу с next_attempt_at:

CREATE TABLE order_confirmation_task (
    task_id          BIGINT PRIMARY KEY,
    order_id         BIGINT NOT NULL,
    status           TEXT NOT NULL,        -- PENDING / IN_PROGRESS / COMPLETED / FAILED
    retry_count      INTEGER NOT NULL DEFAULT 0,
    next_attempt_at  TIMESTAMPTZ NOT NULL,
    last_error       TEXT,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ix_oct_due ON order_confirmation_task (status, next_attempt_at)
    WHERE status IN ('PENDING', 'IN_PROGRESS');
@Scheduled(fixedDelay = 5_000)
public void processPending() {
    List<Task> due = taskRepository.findDueForRetry(50);  // FOR UPDATE SKIP LOCKED
    for (Task t : due) {
        try {
            adapter.confirm(t);
            taskRepository.markCompleted(t.id());
        } catch (Exception e) {
            taskRepository.scheduleRetry(t.id(), e.getMessage(), nextBackoff(t.retryCount()));
        }
    }
}

Подробно — в Async и polling и PG Runtime → task-queue.

Inbound REST — RateLimiter на edge

R-RES-WHERE-4: защита нашего REST API от перегрузки клиентами — это RateLimiter, и он живёт на API Gateway (Spring Cloud Gateway, Kong, Istio), не в каждом сервисе.

Почему на gateway:

  • Единая точка контроля для всех сервисов.
  • Защита до того, как запрос дошёл до приложения — экономия CPU и connection slots.
  • Per-client лимиты по API-key / IP — gateway это умеет, сервисы — нет.

@RateLimiter Resilience4j в коде сервиса допустим только в legacy-сценариях, когда gateway недоступен. Пример из real-world: внутренняя инсталляция без API Gateway, и нужно защитить сервис от плохо себя ведущего клиента. Дёрнуть @RateLimiter на контроллере — приемлемый workaround.

Что запрещено

Resilience4j вокруг локальных операций

R-RES-WHERE-X1: никакого @CircuitBreaker / @Retry / @Bulkhead на репозитории, JOOQ-вызове, in-memory вычислении.

// ПЛОХО — CB на репозитории
@Repository
public class JooqOrderRepository implements OrderRepository {
    @CircuitBreaker(name = "orderRepo")        // ← зачем?
    public Optional<Order> findById(OrderId id, SelectMode mode) { ... }
}

Что не так:

  • Транзиентов нет. PostgreSQL внутри одного процесса либо доступен, либо нет. Если нет — это серьёзный failure (соединение оборвалось, БД лежит), и CB здесь не помогает: восстановит состояние тот же reconnect-loop HikariCP.
  • CB-state бесполезен. «Открыт» CB на репозитории = сервис не работает. Тогда лучше пусть упадёт и k8s его рестартанёт.
  • Метрики мусорят. resilience4j_circuitbreaker_state{name=orderRepo} ничего не значит — она всегда либо closed, либо «пиздец».

Корректно: HikariCP обеспечивает connection retry, jOOQ кидает осмысленные исключения, edge-handler возвращает 500. Локально нечего «защищать» от транзиентов.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
@CircuitBreaker на репозитории / JOOQ-вызовеR-RES-WHERE-X1Не нужно — нет транзиентов
Resilience4j вокруг @Service-метода без outboundR-RES-WHERE-X1Не нужно
In-memory R4J retry для долгих отказов (>30s)R-RES-WHERE-3Task-queue с БД
@RateLimiter на каждом контроллере вместо API GatewayR-RES-WHERE-4Centralized rate-limit на edge
Outbound без CB и BulkheadR-RES-WHERE-1Полный набор обязателен

Куда дальше

  • Resilience → раздел 1. Где какая защита — нормативные R-RES-WHERE-*.
  • Per-system isolation — отдельный pool и CB на каждую систему.
  • Timeouts — иерархия connect / read / call.
  • Circuit Breaker — как настроить sliding window и threshold.
  • Retry — когда повторять (и когда нельзя).
  • Async и polling — task-queue для долгих отказов.