Опирается на правила:
R-RES-FB-1…R-RES-FB-2иR-RES-FB-X1…R-RES-FB-X3из Resilience Style Guide → раздел 7. Fallback.
Важно знать
- Fallback допустим в трёх случаях: cached read, default value для read, async-mode для write.
- Cached read —
getProductCatalogпри отказе CDN возвращает локальную копию из Redis сstale-if-error.- Default value —
getRecommendationsвозвращает[], когда отсутствие данных — норма.- Async-mode write —
createOrderпри отказе 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 для money | R-RES-FB-X1 | Optional / exception / task-queue |
| Тихий fallback (логнули и забыли) | R-RES-FB-X2 | Явный возврат queued / failed или throw |
| Каскадный fallback в другой провайдер без CB | R-RES-FB-X3 | Каждый провайдер — свой CB |
Fallback с success для write при отказе | R-RES-FB-1 | queued + 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 Guide —
Cache-Control: stale-if-error, 202 Accepted.