Опирается на правила:
R-ERR-RETRY-1…R-ERR-RETRY-3иR-ERR-RETRY-X1из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.
Важно знать
DomainError— никогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.InputValidationError— никогда не retry. Тот же input → тот же fail.IntegrationError— retry-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;
}
}
InvalidPaymentRequestError — DomainError-наследник (не 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:
bulkhead— ограничивает concurrency (10 параллельных) и queue (20 ожидающих).circuitBreaker— после 5 consecutive failures открывается на 10 секунд.retry— 3 попытки с exponential backoff (200ms → 2s).
Все три политики срабатывают только на PaymentGatewayError. DomainError и InvalidPaymentRequestError проходят насквозь.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Retry-политика без handleType(...) (ловит всё) | R-ERR-RETRY-1 | handleType(<SystemError>) — только конкретный тип |
Retry write-операции без Idempotency-Key | R-ERR-RETRY-3 | headers: { 'Idempotency-Key': cmd.idempotencyKey } |
| Retry на 4xx ответ внешней системы | R-ERR-RETRY-2 | status < 500 → DomainError, не IntegrationError |
| Retry в UseCase Handler вокруг всей операции | R-ERR-RETRY-X1 | Retry только в 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-операций.