Опирается на правила:
R-RES-FB-1,R-RES-FB-2,R-RES-FB-X1,R-RES-FB-X2,R-RES-FB-X3из Resilience Style Guide → раздел 7. Fallback.
Важно знать
- Fallback допустим в трёх случаях: cached read, default value для read, async-mode для write (
R-RES-FB-1).- В Node нет
fallbackMethodкак в Resilience4j — fallback реализуется явнымcatchнаBrokenCircuitError/TaskCancelledError/BulkheadRejectedErrorлибо черезfallback(policy, value)из cockatiel.- Cached read —
getProductCatalogпри открытом CB возвращает последний успешный ответ из Redis/кеша.- Default value —
getRecommendationsвозвращает[], когда отсутствие данных принято бизнесом как норма.- Async-mode write —
createOrderпри отказе Sber →202 Accepted+ запись задачи в task-queue. Клиент явно знает, что обработка отложена.- Контракт fallback-метода: same return type, явная обработка (
catch) с осознанным значением (R-RES-FB-2).- Нет fallback с
null/Money(0)для money-операций — это бизнес-баг (R-RES-FB-X1).- Нет тихого fallback с success — клиент должен знать, что данные устарели или обработка отложена (
R-RES-FB-X2).- Нет каскадного fallback во второй провайдер без своего CB — это cascading failure (
R-RES-FB-X3).
Fallback — это что отдать клиенту, когда защищаемая система недоступна. Соблазн прост: вернуть «что-нибудь нейтральное и не падать». Но это работает только если «нейтральное» — реально допустимое значение для бизнеса. В большинстве случаев тихий fallback хуже честной ошибки.
Три случая, когда fallback оправдан
R-RES-FB-1: иерархия по убыванию приемлемости.
1. Cached read — отдать последний успешный ответ
Подходит для каталога товаров, справочников, конфигов — данных, которые меняются редко и допускают stale.
@Injectable()
export class ProductCatalogAdapter implements CatalogPort {
private readonly policy = wrap(
circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.5, size: 50 }) }),
bulkhead(8),
timeout(5_000, TimeoutStrategy.Aggressive),
);
constructor(
@Inject(CATALOG_CLIENT) private readonly client: Agent,
private readonly cache: ProductCatalogCache,
) {}
async listProducts(categoryId: CategoryId): Promise<Product[]> {
try {
const resp = await this.policy.execute(() =>
this.client.request({ path: `/products?category=${categoryId.value}`, method: 'GET' }),
);
const live = toDomainList(await resp.body.json());
await this.cache.put(categoryId, live); // обновляем кеш в happy-path
return live;
} catch (e) {
if (e instanceof BrokenCircuitError || e instanceof TaskCancelledError) {
this.logger.warn('catalog unavailable, returning cached', { categoryId: categoryId.value, err: e.message });
const cached = await this.cache.get(categoryId);
return cached ?? [];
}
throw CatalogPortError.from(e);
}
}
}
Что важно:
cache.put(...)— внутри happy-path, не вcatch. Fallback только читает кеш.- Лог уровня WARN: SRE видит «catalog был недоступен, отдали stale».
- В ответе контроллер может добавить
Cache-Control: stale-if-error— клиент знает, что данные могут быть устаревшими.
2. Default value для read
Подходит, когда отсутствие данных — норма. Пример: персональные рекомендации.
@Injectable()
export class RecommendationsAdapter implements RecommendationsPort {
private readonly policy = wrap(
circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.5, size: 50 }) }),
bulkhead(5),
timeout(3_000, TimeoutStrategy.Aggressive),
);
async getRecommendations(customerId: CustomerId): Promise<Product[]> {
try {
const resp = await this.policy.execute(() =>
this.client.request({ path: `/recommendations/${customerId.value}`, method: 'GET' }),
);
return toDomainList(await resp.body.json());
} catch (e) {
if (e instanceof BrokenCircuitError || e instanceof TaskCancelledError) {
this.logger.warn('recommendations unavailable', { customerId: customerId.value });
return [];
}
throw RecommendationsPortError.from(e);
}
}
}
Что важно:
- Бизнес заранее согласен, что пустой список — допустимый ответ. UI покажет «Нет рекомендаций» — это normal-path.
- Если пустой список был бы ошибкой (например, у клиента гарантированно есть рекомендации), это не fallback — это сокрытие проблемы.
Альтернатива через cockatiel fallback():
// Только для простых случаев: дефолтное значение известно заранее
const withFallback = fallback(this.policy, (err) => {
this.logger.warn('recommendations unavailable', { err: err.message });
return [] as Product[];
});
const result = await withFallback.execute(() => this.client.request(...));
3. Async-mode для write — 202 Accepted + task-queue
Для write-операций fallback никогда не возвращает success. Только queued с явным указанием, что обработка отложена.
@Injectable()
export class SberAdapter implements PaymentPort {
private readonly policy = wrap(
retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.3, size: 50 }) }),
bulkhead(8),
timeout(5_000, TimeoutStrategy.Aggressive),
);
async register(cmd: RegisterCommand): Promise<RegisterResult> {
try {
const resp = await this.policy.execute(() =>
this.client.request({ path: '/v2/register', method: 'POST', body: JSON.stringify(toApiRequest(cmd)) }),
);
return toDomainResult(await resp.body.json()); // mapper DTO → domain (R-RES-OAS-4)
} catch (e) {
if (e instanceof BrokenCircuitError) {
this.logger.warn('Sber CB open, queuing registration', { orderId: cmd.orderId.value });
const taskId = await this.taskQueue.enqueue(toRegisterTask(cmd));
return RegisterResult.queued(taskId); // явный queued, не success
}
throw PaymentPortError.from(e);
}
}
}
Что критично:
RegisterResult.queued(taskId)— отдельный вариант, неRegisterResult.success(...). Контроллер маппит в202 Acceptedс body{ "status": "queued", "taskId": "..." }иLocation-заголовком на GET-poll endpoint.- Задача в БД переживает рестарт сервиса. Scheduler позже отправит запрос в Sber.
- Клиент знает, что результат не финальный, и периодически опрашивает статус. Подробно — в Async и polling.
Контроллер:
@Post('orders/:id/payment')
async registerPayment(@Param('id') orderId: string, @Body() dto: RegisterPaymentDto): Promise<Response> {
const result = await this.handler.handle(new RegisterPaymentCommand(orderId, dto));
if (result.isQueued()) {
return { status: 202, body: { status: 'queued', taskId: result.taskId }, location: `/tasks/${result.taskId}` };
}
return { status: 201, body: { orderId: result.orderId, paymentUrl: result.paymentUrl } };
}
Контракт fallback
R-RES-FB-2: явная обработка конкретных ошибок с осознанным результатом того же типа.
В Node нет декларативного fallbackMethod — вместо него явный catch с разветвлением по типу исключения:
async findOrder(ref: OrderRef): Promise<Order | null> {
try {
return await this.policy.execute(() => this.client.request(...));
} catch (e) {
if (e instanceof BrokenCircuitError) {
// CB открыт — система явно лежит, отдаём из кеша
return this.cache.getLastKnown(ref);
}
if (e instanceof TaskCancelledError) {
// timeout — транзиент; логируем, пробрасываем 503
this.logger.warn('order fetch timed out', { orderId: ref.id });
throw OrderPortError.systemUnavailable('sber', e);
}
// BulkheadRejectedError и прочее — пробрасываем
throw OrderPortError.from(e);
}
}
Разветвление важно:
BrokenCircuitError— CB открыт, система явно лежит. Можно безопасно отдавать из кеша или ставить в очередь.TaskCancelledError— timeout. Может быть транзиентом, может быть перегрузкой. Чаще пробрасывать, чем маскировать.BulkheadRejectedError— очередь заполнена, наш сервис перегружен. Честная503.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
return null / return new Money(0) для money-операции | R-RES-FB-X1 | Optional / throw / task-queue |
catch (e) { logger.warn(...) } — тихий успех | R-RES-FB-X2 | Явный queued / failed или throw |
| Fallback с outbound в другой провайдер без своего CB | R-RES-FB-X3 | Каждый провайдер — свой policy-set |
return RegisterResult.success(...) при write-fallback | R-RES-FB-1 | queued + 202 Accepted + task-queue |
Один catch (e) без разветвления по типу ошибки | R-RES-FB-2 | Разветвление на BrokenCircuitError / TaskCancelledError / BulkheadRejectedError |
Куда дальше
- Async и polling — task-queue с
202 Accepted,@Interval-poller. - Circuit Breaker — когда срабатывает
BrokenCircuitErrorи как строится policy-композиция. - Bulkhead —
BulkheadRejectedErrorкак триггер fallback. - Retry — когда retry снимает необходимость в fallback, а когда нет.
- Timeouts —
TaskCancelledErrorизtimeout()-policy. - Конфигурация — внешние параметры policy без магических чисел в коде.
- Observability — метрики и логирование CB-переходов через
prom-client. - Health checks — как состояние CB отражается в readiness.
- OpenAPI generator — биндинг — policy на public-методе адаптера, не на сгенерированном клиенте.
- Per-system isolation — отдельный policy-set на каждую систему.
- Где ставить защиту — какой уровень защищается, а какой нет.