Опирается на правила:
R-RES-RE-1…R-RES-RE-5иR-RES-RE-X1…R-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-queue | Pending-промисы копятся, 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-Key | R-RES-RE-X1 | Либо idempotencyKey в запросе, либо без retry |
retry() на 4xx (handleAll без фильтра) | R-RES-RE-X2 | handleType(SberTransientError) только на транзиентные |
retry() с ConstantBackoff / без backoff | R-RES-RE-X3 | ExponentialBackoff (jitter встроен) |
axiosRetry(client, { retries: 3 }) без интеграции с CB | R-RES-RE-X4 | Единая cockatiel-композиция wrap(retry, cb, bulkhead, timeout) |
maxAttempts > 5 для in-memory retry | R-RES-RE-3 | Task-queue с таблицей *_task |
| In-memory retry для отказов >30s | R-RES-RE-4 | Task-queue (durable, переживает рестарт) |
Retry внутри CB в wrap(cb, retry, ...) | R-RES-CB-1 | wrap(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:
- Адаптер отправил
PATCH /customers/42(без ключа). - Внешняя система записала изменение, начала формировать ответ.
- TCP-соединение упало (network blip).
- Адаптер получает
ECONNRESET, cockatiel ретраит. - Внешняя система видит второй
PATCH— применяет изменение повторно (если не идемпотентно по природе). - Дублируется 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 на адаптере, не на сгенерированном клиенте.