Опирается на правила: R-RES-FB-1R-RES-FB-2 и R-RES-FB-X1R-RES-FB-X3 из Resilience Style Guide → раздел 7. Fallback.

Важно знать

  • Fallback допустим в трёх случаях: cached read, default value для read, async-mode для write.
  • Cached readgetProductCatalog при отказе CDN возвращает локальную копию из Redis с stale-if-error.
  • Default valuegetRecommendations возвращает [], когда отсутствие данных — норма.
  • Async-mode writecreateOrder при отказе Sber → 202 Accepted + задача в task-queue. Клиент явно знает, что обработка отложена.
  • Контракт fallback-метода: same return type, дополнительный last-параметр Throwable (или конкретный exception type).
  • Нет fallback с null / Money.ZERO для money — это бизнес-баг.
  • Нет тихого fallback с success — клиент должен знать, что произошла ошибка / обработка отложена.
  • Нет каскадного fallback в другой провайдер без своего CB — это cascading failure.

Fallback — это что отдать клиенту, когда защищаемая система недоступна. Соблазн прост: «вернуть что-нибудь нейтральное и не падать». Но это работает только если «нейтральное» — реально допустимое значение для бизнеса. В большинстве случаев тихий fallback хуже честной ошибки. Раскрытие раздела 7 гайда.

Три случая, когда fallback оправдан

R-RES-FB-1: иерархия по убыванию приемлемости.

1. Cached read — отдать последний успешный ответ

Подходит для каталога, словарей, конфигов — данных, которые меняются редко и допускают stale.

@Component
@RequiredArgsConstructor
public class ProductCatalogAdapter implements CatalogPort {

    private final ProductCatalogApi api;
    private final ProductCatalogCache cache;

    @CircuitBreaker(name = "catalog", fallbackMethod = "fromCache")
    public List<Product> listProducts(CategoryId categoryId) {
        List<Product> live = executeCall(api.listProducts(categoryId.value()));
        cache.put(categoryId, live);            // обновляем cache при успехе
        return live;
    }

    private List<Product> fromCache(CategoryId categoryId, Throwable t) {
        log.warn("catalog unavailable, returning cached for category {}", categoryId, t);
        return cache.get(categoryId).orElse(List.of());
    }
}

Что важно:

  • cache.put(...) — внутри happy-path, не в fallback. Fallback только читает cache.
  • Лог уровня WARN с указанием причины. SRE видит «catalog был недоступен, отдали stale».
  • В заголовках ответа можно явно сказать Cache-Control: stale-if-error — клиент знает, что данные могут быть устаревшими.

2. Default value для read

Подходит, когда отсутствие данных — норма. Пример: персональные рекомендации.

@CircuitBreaker(name = "recommendations", fallbackMethod = "noRecommendations")
public List<Product> getRecommendations(CustomerId customerId) {
    return executeCall(api.recommendationsFor(customerId.value()));
}

private List<Product> noRecommendations(CustomerId customerId, Throwable t) {
    log.warn("recommendations unavailable for customer {}", customerId, t);
    return List.of();
}

Что важно:

  • Бизнес заранее согласен, что пустой список — допустимый ответ. UI покажет «Нет рекомендаций» — это normal-path.
  • Если бы пустой список был ошибкой (например, у customer 100% есть рекомендации), это не fallback, это сокрытие проблемы.

3. Async-mode для write — 202 Accepted + task-queue

Для write-операций fallback никогда не возвращает success. Только 202 Accepted с явным указанием, что обработка отложена.

@CircuitBreaker(name = "sber", fallbackMethod = "registerAsync")
@Bulkhead(name = "sber")
@Retry(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
    SberRegisterRequest req = mapper.toApiRequest(cmd);
    return mapper.toDomain(executeCall(sberApi.register(req, null)));
}

private RegisterResult registerAsync(RegisterCommand cmd, Throwable t) {
    log.warn("Sber unavailable, queuing for retry", t);
    Long taskId = taskQueue.enqueue(toRegisterTask(cmd));
    return RegisterResult.queued(taskId);
}

Что критично:

  • RegisterResult.queued(...) — отдельный вариант, не RegisterResult.success(...). Контроллер маппит его в 202 Accepted с body { "status": "queued", "task_id": ... } и Location header на GET-poll endpoint.
  • Task в БД переживает рестарт сервиса. Scheduler позже дёрнет Sber.
  • Клиент знает, что результат не финальный, и периодически опрашивает статус. Подробно — в Async и polling.

Контракт fallback-метода

R-RES-FB-2: технические требования к сигнатуре.

// Основной метод
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
public RegisterResult register(RegisterCommand cmd) { ... }

// Fallback — same return type, дополнительный last-параметр Throwable
private RegisterResult registerFallback(RegisterCommand cmd, Throwable t) { ... }

Варианты:

  • Throwable t — ловит любую ошибку. Самый широкий fallback.
  • CallNotPermittedException e — только когда CB открыт. Более точечный.
  • Несколько fallback-методов — Resilience4j выберет наиболее специфичный по типу exception.
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
public RegisterResult register(RegisterCommand cmd) { ... }

private RegisterResult registerFallback(RegisterCommand cmd, CallNotPermittedException e) {
    // CB открыт — система явно лежит, в task-queue
    return RegisterResult.queued(taskQueue.enqueue(toRegisterTask(cmd)));
}

private RegisterResult registerFallback(RegisterCommand cmd, Throwable t) {
    // Любая другая ошибка — пробросить вверх, не маскируем
    throw new PaymentPortException.RegistrationFailed("sber", t);
}

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

Fallback с null / Money.ZERO для money

R-RES-FB-X1: возврат «нулевого» значения за money-операцию = бизнес-баг.

// ПЛОХО — money fallback с Money.ZERO
@CircuitBreaker(name = "sber", fallbackMethod = "balanceFallback")
public Money getBalance(AccountId id) {
    return executeCall(api.balance(id.value()));
}

private Money balanceFallback(AccountId id, Throwable t) {
    return Money.ZERO;                          // ← клиент увидит «баланс = 0»
}

Что не так:

  • Клиент видит «баланс = 0» вместо реальной суммы. Принимает решения на ложных данных.
  • Пользователь паникует: «куда делись мои деньги?»
  • UI пытается списать с «нулевого» баланса → корректное «недостаточно средств» — но реально на счету 50000.

Корректно: либо Optional<Money> с явным Optional.empty() (UI покажет «временно недоступно»), либо exception (HTTP 503).

Тихий fallback с success

R-RES-FB-X2: fallback, который проглатывает ошибку и возвращает «как-будто всё ОК».

// ПЛОХО
private void sendNotification(NotificationCommand cmd) {
    try {
        executeCall(api.send(cmd));
    } catch (Exception e) {
        log.warn("notification failed", e);    // ← залогировали и забыли
    }
}

Что не так: вызывающий код считает, что нотификация ушла. Через сутки приходит жалоба «не получили SMS». Расследование начинается с нуля.

Корректно: либо throw, либо явная пометка в результате (NotificationResult.queued(taskId) / NotificationResult.failed(reason)).

Каскадный fallback в другой провайдер без CB

R-RES-FB-X3: fallback, делающий outbound в другую систему — это новый outbound, который должен быть обёрнут своим CB.

// ПЛОХО — fallback в backup-провайдер без CB
@CircuitBreaker(name = "sber", fallbackMethod = "tryYoomoney")
public RegisterResult register(RegisterCommand cmd) {
    return doSber(cmd);
}

private RegisterResult tryYoomoney(RegisterCommand cmd, Throwable t) {
    // ← если Yoomoney тоже лежит — нет защиты, всё висит
    return yoomoneyAdapter.register(cmd);
}

Сценарий: Sber лёг, CB открыт, идём в Yoomoney. Yoomoney тоже лежит (например, общая network problem). Каждый запрос висит на Yoomoney callTimeout, thread-pool съедается. Cascading failure.

Корректно:

private RegisterResult tryYoomoney(RegisterCommand cmd, Throwable t) {
    try {
        return yoomoneyAdapter.register(cmd);      // ← yoomoneyAdapter имеет свой @CircuitBreaker
    } catch (PaymentPortException.SystemUnavailable e) {
        return registerAsync(cmd, e);              // ← обе лежат — в task-queue
    }
}

yoomoneyAdapter.register — это отдельный @Component со своим @CircuitBreaker(name = "yoomoney"). Каждый провайдер изолирован. При обоих лежащих — третий уровень fallback в task-queue.

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

АнтипаттернПравилоЧто взамен
Fallback с null / Money.ZERO для moneyR-RES-FB-X1Optional / exception / task-queue
Тихий fallback (логнули и забыли)R-RES-FB-X2Явный возврат queued / failed или throw
Каскадный fallback в другой провайдер без CBR-RES-FB-X3Каждый провайдер — свой CB
Fallback с success для write при отказеR-RES-FB-1queued + 202 Accepted + task-queue
Один fallback на любую ошибку без разделенияR-RES-FB-2Несколько fallback по типу exception

Куда дальше

  • Resilience → раздел 7. Fallback — нормативные R-RES-FB-*.
  • Async и polling — task-queue с 202 Accepted.
  • Circuit Breaker — когда срабатывает fallback.
  • Bulkhead — BulkheadFullException тоже триггерит fallback.
  • Caching Style Guide — где хранить cache для cached-read fallback.
  • REST API Style GuideCache-Control: stale-if-error, 202 Accepted.