Опирается на правила: R-ERR-RETRY-1R-ERR-RETRY-3 и R-ERR-RETRY-X1 из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.

Важно знать

  • DomainErrorникогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.
  • InputValidationErrorникогда не retry. Тот же input → тот же fail.
  • IntegrationErrorretry-safe при идемпотентности. Write без Idempotency-Key — retry запрещён.
  • TechnicalError — обычно retry после latency (timeout БД, OOM — транзиентны).
  • HTTP 4xx от внешней системы — не retry. «Мы послали некорректное», повтор не поможет.
  • HTTP 5xx и timeout — retry-safe только при идемпотентности. Без Idempotency-Key на write — деньги могут списаться дважды.
  • Retry-обёртка вокруг Exception Filter — бессмысленна. Edge уже вне retry-цикла.

Retry — дешёвый способ пережить транзиентный сбой внешней системы. Но та же механика может стать оружием против целостности: повторно списать деньги, отправить SMS, создать платёж. Правило в одну фразу: retry безопасен только когда операция идемпотентна. Тип исключения — первый признак, можно ли retry'ить вообще. Раскрытие правил R-ERR-RETRY-* ниже.

Таблица по типам

R-ERR-RETRY-1: однозначный ответ на «retry или нет» по типу.

ТипRetryПричина
DomainError❌ НикогдаБизнес-правило детерминированно. Тот же state + тот же запрос → тот же fail.
InputValidationError❌ НикогдаНевалидный input. Те же данные → тот же fail.
IntegrationError✅ При идемпотентностиСетевой сбой / 5xx — обычно транзиентно. Но только если операция идемпотентна.
TechnicalError✅ После latencyВнутренняя проблема (БД-таймаут). Часто проходит само.

«Retry» здесь — автоматический retry внутри сервиса через cockatiel. Клиентский retry (пользователь нажимает кнопку ещё раз) — отдельная история, контролируется клиентом.

DomainError и InputValidationError — никогда

// use-cases/create-order/create-order.handler.ts
@Injectable()
export class CreateOrderHandler {
  constructor(
    private readonly catalog: CatalogPort,
    private readonly orders: OrderRepository,
    private readonly payment: PaymentPort,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    const product = await this.catalog.getProduct(cmd.productId);
    if (product.stock < cmd.quantity) {
      throw new ProductOutOfStockError(cmd.productId, cmd.quantity, product.stock);
    }
    const order = Order.create(cmd);
    await this.orders.save(order);
    return order;
  }
}

Если ProductOutOfStockError бросается — retry не поможет:

  • Состояние агрегата не изменится между попытками — продукт всё ещё закончился.
  • Правило детерминированно — сработает на каждой попытке одинаково.
  • Получаем 3 одинаковых WARN подряд и спустя 3 секунды ту же 422.

Retry-политика в cockatiel должна явно исключать эти типы через handleType:

// adapters/out/payment/payment.resilient.adapter.ts
const retryPolicy = retry(
  handleType(PaymentGatewayError),     // только PaymentGatewayError retry-able
  {
    maxAttempts: 3,
    backoff: new ExponentialBackoff({ initialDelay: 200, maxDelay: 2000 }),
  },
);

handleType(PaymentGatewayError) — retry сработает только если исключение является PaymentGatewayError или его наследником. DomainError, InvalidPaymentRequestError (4xx) — проходят насквозь без retry.

IntegrationError — retry-safe при идемпотентности

R-ERR-RETRY-3: HTTP 5xx и timeout — транзиентные сбои, обычно проходят. Но повторять можно только если операция идемпотентна.

Идемпотентность = «повторный вызов с теми же параметрами даёт тот же результат, что и одиночный». Для money-операций — через Idempotency-Key: клиент посылает уникальный ключ, сервер при повторе с тем же ключом возвращает результат первой попытки, не выполняя заново.

Read-операции (getProduct, getOrderStatus) — естественно идемпотентны. Retry безопасен.

Write-операции (chargePayment, registerOrder) — идемпотентны только при наличии Idempotency-Key:

// ПЛОХО — retry без идемпотентности на write
const retryPolicy = retry(handleType(PaymentGatewayError), { maxAttempts: 3, ... });

async charge(cmd: ChargeCommand): Promise<ChargeResult> {
  return retryPolicy.execute(() =>
    this.http.axiosRef.post('/charge', toApi(cmd))   // каждая попытка может списать
  );
}

Если первая попытка успешно списала 1000 руб., но ответ потерян из-за timeout — cockatiel повторит запрос. Банк снова спишет 1000 руб. Пользователю снимут 2000 руб.

Правильно — передавать Idempotency-Key с каждым запросом:

// adapters/out/payment/payment.client.adapter.ts
async charge(cmd: ChargeCommand): Promise<ChargeResult> {
  try {
    const { data } = await this.http.axiosRef.post('/charge', toApi(cmd), {
      headers: { 'Idempotency-Key': cmd.idempotencyKey },
    });
    return toDomain(data);
  } catch (e) {
    if (axios.isAxiosError(e)) {
      if (e.response && e.response.status < 500) {
        throw new InvalidPaymentRequestError(cmd.orderId, e.response.data?.message ?? '');
      }
      throw new PaymentGatewayError('payment 5xx/timeout', { cause: e });
    }
    throw e;
  }
}

При повторе с тем же Idempotency-Key банк вернёт результат первой операции. Деньги списываются ровно один раз.

HTTP 4xx — не retry

R-ERR-RETRY-2: 4xx означает «мы послали что-то некорректное». Повтор не поможет — те же данные дадут тот же 4xx.

// adapters/out/payment/payment.client.adapter.ts
async register(cmd: RegisterCommand): Promise<RegisterResult> {
  try {
    const { data } = await this.http.axiosRef.post('/payments/register', toApi(cmd));
    return toDomain(data);
  } catch (e) {
    if (axios.isAxiosError(e)) {
      const status = e.response?.status;
      if (status !== undefined && status < 500) {
        throw new InvalidPaymentRequestError(cmd.orderId, e.response?.data?.message ?? '');
      }
      throw new PaymentGatewayError(`payment ${status ?? 'network'} error`, { cause: e });
    }
    throw e;
  }
}

InvalidPaymentRequestErrorDomainError-наследник (не IntegrationError). Бросается с domain-смыслом «наш запрос некорректен с точки зрения внешней системы». Edge превращает в 422 для нашего клиента. handleType(PaymentGatewayError) этот тип не покрывает — retry не произойдёт.

Полная конфигурация resilience-обёртки

// adapters/out/payment/payment.resilient.adapter.ts
import {
  retry,
  handleType,
  ExponentialBackoff,
  circuitBreaker,
  ConsecutiveBreaker,
  bulkhead,
  wrap,
} from 'cockatiel';

@Injectable()
export class PaymentResilientAdapter implements PaymentPort {
  private readonly policy = wrap(
    bulkhead(10, 20),
    circuitBreaker(handleType(PaymentGatewayError), {
      halfOpenAfter: 10_000,
      breaker: new ConsecutiveBreaker(5),
    }),
    retry(handleType(PaymentGatewayError), {
      maxAttempts: 3,
      backoff: new ExponentialBackoff({ initialDelay: 200, maxDelay: 2000 }),
    }),
  );

  constructor(private readonly inner: PaymentClientAdapter) {}

  async charge(cmd: ChargeCommand): Promise<ChargeResult> {
    try {
      return await this.policy.execute(() => this.inner.charge(cmd));
    } catch (e) {
      if (e instanceof BrokenCircuitError) {
        throw new PaymentGatewayUnavailableError('circuit breaker open', { cause: e });
      }
      throw e;
    }
  }

  async getStatus(orderId: string): Promise<PaymentStatus> {
    return this.policy.execute(() => this.inner.getStatus(orderId));
  }
}

Порядок wrap:

  1. bulkhead — ограничивает concurrency (10 параллельных) и queue (20 ожидающих).
  2. circuitBreaker — после 5 consecutive failures открывается на 10 секунд.
  3. retry — 3 попытки с exponential backoff (200ms → 2s).

Все три политики срабатывают только на PaymentGatewayError. DomainError и InvalidPaymentRequestError проходят насквозь.

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

АнтипаттернПравилоЧто взамен
Retry-политика без handleType(...) (ловит всё)R-ERR-RETRY-1handleType(<SystemError>) — только конкретный тип
Retry write-операции без Idempotency-KeyR-ERR-RETRY-3headers: { 'Idempotency-Key': cmd.idempotencyKey }
Retry на 4xx ответ внешней системыR-ERR-RETRY-2status < 500 → DomainError, не IntegrationError
Retry в UseCase Handler вокруг всей операцииR-ERR-RETRY-X1Retry только в out-adapter на port-вызове
maxAttempts: 10 без maxDelayОграничить maxDelay, иначе суммарная задержка > timeout клиента

Retry на Handler — почему бессмысленно

// ПЛОХО — retry на всю операцию в handler
@Injectable()
export class CreateOrderHandler {
  private readonly retryPolicy = retry(handleType(Error), { maxAttempts: 3, ... });

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    return this.retryPolicy.execute(async () => {
      const product = await this.catalog.getProduct(cmd.productId);
      if (product.stock < cmd.quantity) {
        throw new ProductOutOfStockError(cmd.productId, cmd.quantity, product.stock);
      }
      const order = Order.create(cmd);
      await this.orders.save(order);
      await this.payment.charge(chargeCmd(order));
      return order;
    });
  }
}

Что не так:

  • handleType(Error) поймает и ProductOutOfStockError — 3 бесполезных попытки на детерминированный fail.
  • Если payment.charge упал на первой попытке и транзакция в БД откатилась — повтор создаст новый order с новым orderId, не перепробует старый.
  • Retry в out-adapter уже отработал — повторный retry в handler — это retry поверх retry.

Retry — только в out-adapter на конкретном port-вызове, только на IntegrationError-типах.

Куда дальше

  • Иерархия исключений — почему 4xx → DomainError, 5xx → IntegrationError.
  • Где throw, где catch — резильянс-обёртка как третья точка catch.
  • Логирование исключений — warn vs error при CB open/close.
  • Observability — метрики по количеству retry-попыток.
  • Result-types vs exceptions — альтернативы исключениям.
  • Resilience Style Guide → R-RES-RE-* — про retry, CB, bulkhead конфигурацию.
  • Auth Patterns → AUTH-19 — про Idempotency-Key для money-операций.