Опирается на правила: 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 readgetProductCatalog при открытом CB возвращает последний успешный ответ из Redis/кеша.
  • Default valuegetRecommendations возвращает [], когда отсутствие данных принято бизнесом как норма.
  • Async-mode writecreateOrder при отказе 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-X1Optional / throw / task-queue
catch (e) { logger.warn(...) } — тихий успехR-RES-FB-X2Явный queued / failed или throw
Fallback с outbound в другой провайдер без своего CBR-RES-FB-X3Каждый провайдер — свой policy-set
return RegisterResult.success(...) при write-fallbackR-RES-FB-1queued + 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 на каждую систему.
  • Где ставить защиту — какой уровень защищается, а какой нет.