Опирается на правила: R-RES-RE-1R-RES-RE-5 и R-RES-RE-X1R-RES-RE-X4 из Resilience Style Guide → раздел 5. Retry.

Важно знать

  • retry() допустим только при идемпотентности: либо метод — read (GET-эквивалент), либо команда с Idempotency-Key (внешняя система дедуплицирует).
  • maxAttempts: 3 — типовое (включая первую попытку). 5 — верхний предел. Больше — это task-queue.
  • ExponentialBackoff обязателен (jitter встроен). Линейный retry удваивает нагрузку на лежачую систему.
  • handleType(...) — только транзиентные: timeout, 5xx, ECONNREFUSED, UND_ERR_*. 4xx — контрактные ошибки; повтор не поможет.
  • In-memory retry — для транзиентов <5s. Task-queue — для отказов >30s.
  • retry() на write без Idempotency-Key — главный источник двойных платежей. На 5xx ответ может быть «не дошло» или «дошло, но ответ потерян».
  • axios-retry, got-retry-дефолты, RxJS retry() — стихийные механизмы без интеграции с CB/bulkhead. Единая cockatiel-композиция заменяет все.

Retry — самый опасный из resilience-инструментов. Большинство retry-инцидентов в продакшне — это «дважды списали деньги», «послали SMS трижды», «создали два заказа». Поэтому правило простое: retry только тогда, когда операция идемпотентна по дизайну. Любое сомнение — retry нет. Раскрытие раздела 5 контракта R-RES-RE-*.

Когда retry допустим

R-RES-RE-1: ровно два случая.

Случай 1: read-операция (GET-эквивалент)

Чтение по определению идемпотентно: 1 запрос или 100 запросов — результат тот же, побочных эффектов нет.

@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.5, size: 50 }) }),
    bulkhead(8),
    timeout(5_000, TimeoutStrategy.Aggressive),
  );

  async findOrder(orderId: OrderId): Promise<Order> {           // read → retry допустим (R-RES-RE-1)
    try {
      const resp = await this.policy.execute(() =>
        this.client.request({ path: `/orders/${orderId.value}`, method: 'GET' }),
      );
      return toOrderDomain(await resp.body.json());             // mapper DTO → domain (R-RES-OAS-4)
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
      throw PaymentPortError.from(e);
    }
  }
}

Retry безопасен: если первый вызов вернул транзиентный 5xx, второй попробует снова — максимум потратит ~500ms лишних.

Случай 2: write с Idempotency-Key

Если внешняя система дедуплицирует по ключу, retry безопасен.

async registerPayment(cmd: RegisterPaymentCommand): Promise<PaymentRef> {  // write + ключ → retry допустим
  try {
    const resp = await this.policy.execute(() =>
      this.client.request({
        path: '/payments/register',
        method: 'POST',
        headers: { 'Idempotency-Key': cmd.idempotencyKey },   // ← обязательно (AUTH-19)
        body: JSON.stringify(toApiRequest(cmd)),
      }),
    );
    return toPaymentRef(await resp.body.json());
  } catch (e) {
    if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
    throw PaymentPortError.from(e);
  }
}

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

  • idempotencyKeyдетерминированный: UUID v5 из orderId + operation или client-generated UUID v7, созданный один раз на user-action, хранится в домене. Не генерировать в адаптере на каждый retry.
  • Внешняя система обязана гарантировать дедуп. Если гарантия не прописана в её контракте — retry на write запрещён, даже с ключом.
  • TTL ключа на стороне внешней системы должен быть больше нашего max retry-window (включая task-queue).

Подробно про идемпотентность — в auth-patterns AUTH-19.

Cockatiel-политика: положение retry в композиции

R-RES-RE-2/R-RES-RE-3: composing order имеет значение.

// PREFER: retry снаружи CB — каждая попытка проходит через CB, CB учитывает failure rate
const policy = wrap(
  retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
  circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.5, size: 50 }) }),
  bulkhead(8),
  timeout(5_000, TimeoutStrategy.Aggressive),
);

// AVOID: retry внутри CB — CB видит только финальный исход после всех попыток, не каждую ошибку
const badPolicy = wrap(
  circuitBreaker(handleAll, { ... }),
  retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
);

wrap обрабатывает политики справа налево: timeout → bulkhead → circuitBreaker → retry. Каждая попытка retry проходит через CB и bulkhead — CB накапливает статистику по каждой неудачной попытке, не только по финальному исходу.

ExponentialBackoff из cockatiel встраивает jitter: итоговые паузы примерно 500ms → 1s → 2s с разбросом. Это снижает thundering-herd при одновременном восстановлении нескольких инстансов.

maxAttempts — 3 типовое, 5 предел

R-RES-RE-3: больше 5 попыток — уже task-queue.

  • 3 попытки покрывают транзиентные сбои: connection reset, перезапуск инстанса внешней системы (k8s-под перезапускается 1–2 секунды).
  • 5 попыток — для нестабильных систем с высокой baseline-ошибкой.
  • 10+ попыток — признак реальной деградации внешней системы; нужно либо открывать CB в fallback, либо откладывать в task-queue.

Граница in-memory retry vs task-queue

R-RES-RE-4: критический порог — около 5 секунд суммарного in-memory времени.

Длительность отказаИнструментПочему
<5s (транзиент)In-memory retry()Быстро, бесплатно, не нагружает БД
5–30s (пограничный)Обычно task-queuePending-промисы копятся, event loop под давлением
>30s (устойчивый отказ)Task-queue (DB-driven)Переживает рестарт сервиса, не блокирует event loop

In-memory retry на 30s в Node — копятся pending promise: 200 входящих запросов ждут завершения policy.execute(). Новые запросы получают BulkheadRejectedError. Сервис лежит из-за retry, а не из-за внешней системы.

Task-queue retry

R-RES-RE-5: durable retry — через таблицу БД с @Interval-poller.

CREATE TABLE order_confirmation_task (
    task_id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id         BIGINT NOT NULL,
    status           TEXT NOT NULL DEFAULT 'IN_PROGRESS',
    retry_count      INTEGER NOT NULL DEFAULT 0,
    next_attempt_at  TIMESTAMPTZ NOT NULL,
    last_error       TEXT,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON order_confirmation_task (status, next_attempt_at)
    WHERE status = 'IN_PROGRESS';
@Injectable()
export class OrderConfirmationPoller {
  constructor(
    private readonly taskRepo: OrderConfirmationTaskRepository,
    private readonly sberAdapter: PaymentPort,
  ) {}

  @Interval(5_000)
  async runDue(): Promise<void> {
    const tasks = await this.taskRepo.findDue(50);   // FOR UPDATE SKIP LOCKED
    for (const task of tasks) {
      try {
        await this.sberAdapter.confirmPayment(task.orderId);
        await this.taskRepo.markCompleted(task.taskId);
      } catch (e) {
        const next = task.retryCount + 1;
        if (next >= 10) {
          await this.taskRepo.markFailed(task.taskId, String(e));
          this.alertOps(task);                        // ← человек должен увидеть
        } else {
          const backoffMs = Math.pow(2, next) * 1_000;
          await this.taskRepo.scheduleRetry(task.taskId, next, new Date(Date.now() + backoffMs), String(e));
        }
      }
    }
  }
}

Подробно — в Async и polling и Per-system isolation.

handleType — только транзиентные ошибки

Cockatiel не различает 4xx и 5xx автоматически — нужно явно описать, какие ошибки считаются транзиентными.

// PREFER: адаптер отличает транзиентные от контрактных
class SberTransientError extends Error {}

// в адаптере:
const status = resp.statusCode;
if (status >= 500) throw new SberTransientError(`upstream ${status}`);
if (status >= 400) throw new SberContractError(`client error ${status}`);   // не ретраить

// retry видит только SberTransientError:
retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() })

// AVOID: handleAll ретраит и 4xx тоже — спам запросами при валидационной ошибке
retry(handleAll, { maxAttempts: 3, backoff: new ExponentialBackoff() })

Дополнительно транзиентными считаются ECONNREFUSED, ECONNRESET, UND_ERR_CONNECT_TIMEOUT, UND_ERR_HEADERS_TIMEOUT, TaskCancelledError (timeout policy).

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

АнтипаттернПравилоЧто взамен
retry() на write без Idempotency-KeyR-RES-RE-X1Либо idempotencyKey в запросе, либо без retry
retry() на 4xx (handleAll без фильтра)R-RES-RE-X2handleType(SberTransientError) только на транзиентные
retry() с ConstantBackoff / без backoffR-RES-RE-X3ExponentialBackoff (jitter встроен)
axiosRetry(client, { retries: 3 }) без интеграции с CBR-RES-RE-X4Единая cockatiel-композиция wrap(retry, cb, bulkhead, timeout)
maxAttempts > 5 для in-memory retryR-RES-RE-3Task-queue с таблицей *_task
In-memory retry для отказов >30sR-RES-RE-4Task-queue (durable, переживает рестарт)
Retry внутри CB в wrap(cb, retry, ...)R-RES-CB-1wrap(retry, cb, ...) — retry снаружи

axiosRetry без интеграции с CB

R-RES-RE-X4: типичная ошибка при использовании axios-retry или дефолтов got:

// ПЛОХО — axios-retry не знает про CB, ретраит даже когда CB открыт
axiosRetry(client, { retries: 3 });

// Сценарий: CB открылся (sber недоступен), но axios-retry всё равно ретраит →
// каждый входящий запрос делает 3 попытки × callTimeout вместо fast-fail

Единая cockatiel-политика как singleton в DI решает это: retry видит BrokenCircuitError от CB, прекращает попытки, адаптер бросает PaymentPortError.systemUnavailable.

retry на write без Idempotency-Key

R-RES-RE-X1: главный источник production-инцидентов.

Сценарий с CustomerAdapter.updateProfile:

  1. Адаптер отправил PATCH /customers/42 (без ключа).
  2. Внешняя система записала изменение, начала формировать ответ.
  3. TCP-соединение упало (network blip).
  4. Адаптер получает ECONNRESET, cockatiel ретраит.
  5. Внешняя система видит второй PATCH — применяет изменение повторно (если не идемпотентно по природе).
  6. Дублируется side effect: email-уведомление отправлено дважды, история изменений засорена.

Корректно: либо Idempotency-Key, либо вообще без retry().

Куда дальше

  • Async и polling — task-queue для устойчивых отказов и polling внешних систем.
  • Circuit Breaker — fast-fail когда retry не помогает.
  • Fallback — что делать когда retry исчерпан.
  • Конфигурация — декларативный конфиг cockatiel-политик.
  • Per-system isolation — отдельный клиент и policy-набор на каждую систему.
  • Bulkhead — ограничение параллелизма, которое срабатывает раньше retry.
  • Timeouts — иерархия connect/read/total в undici и cockatiel.
  • Health checks — как CB-state влияет на readiness-пробу.
  • Observability — prom-client метрики и OTel-span на каждую retry-попытку.
  • Где ставить защиту — почему retry не ставится на репозиторий.
  • OpenAPI generator binding — policy на адаптере, не на сгенерированном клиенте.