Опирается на правила:
AUTH-19из Auth Patterns → раздел 8. Идемпотентность как часть auth-контракта.
Важно знать
- Money-команды требуют
Idempotency-Keyв заголовке — обязательный, не опциональный.- Повторный вызов с тем же ключом возвращает прежний ответ, не создаёт дубль.
- Защита от retry клиента, retry мобильного приложения, retry в s2s-цепочке, network timeout.
- Без
Idempotency-Keyсетевой timeout → пользователь нажимает снова → два списания.- Идемпотентность — auth-контракт: правильно аутентифицированный запрос обрабатывается ровно один раз.
- Тот же ключ + другой payload →
409 Conflict.- В NestJS реализуется через
NestInterceptor+ таблицаidempotency_record.
Money-команды не выполняются дважды от одного клиента. Кажется очевидным, но сетевой timeout не означает, что write не произошёл — он означает, что backend неизвестно. Retry без идемпотентности → двойное списание. UCP формулирует требование как часть auth-контракта, не optional оптимизацию.
Какие команды требуют Idempotency-Key
AUTH-19: критерий — money или резерв.
| Команда | Нужен Idempotency-Key? |
|---|---|
CreateOrder | Да — резервирование / payment |
ConfirmPayment | Да — money |
RefundPayment | Да — money |
ChargeBalance | Да — money |
CreditBalance | Да — money |
ReserveInventory | Да — резерв |
CancelOrder | Да — может откатить payment |
UpdateOrderStatus | Нет (если не triggers payment) |
GetOrderById | Нет (read-only) |
UpdateCustomerProfile | Нет (не money) |
Общее правило: «если retry с тем же payload может потребовать compensation на стороне backend — нужен Idempotency-Key».
Контракт
Клиент:
POST /orders
Authorization: Bearer ...
Idempotency-Key: 0193a8f3-7c21-7e3f-9b4a-...
Content-Type: application/json
{ "customerId": "sber-42", "items": [...] }
Backend:
- Первый вызов: обрабатывает, сохраняет
(idempotency_key, command_hash, response)вidempotency_record, возвращает201 Created+ body. - Повтор с тем же ключом + тем же payload: возвращает сохранённый ответ (тот же status, тот же body). Не создаёт дубль.
- Тот же ключ + другой payload:
409 Conflict— ключ переиспользуется неверно. - Без заголовка:
400 Bad Request—Idempotency-Keyrequired.
Guard — проверка наличия заголовка
// adapters/in/http/guards/idempotency-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class IdempotencyKeyGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<Request>();
const key = (req.headers as Record<string, string>)['idempotency-key'];
if (!key) throw new BadRequestException('Idempotency-Key header is required');
return true;
}
}
Guard регистрируется на money-контроллерах через @UseGuards(JwtAuthGuard, RolesGuard, IdempotencyKeyGuard).
Interceptor — lookup и сохранение
// adapters/in/http/interceptors/idempotency.interceptor.ts
import {
Injectable, NestInterceptor, ExecutionContext,
CallHandler, ConflictException,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { IdempotencyRecordRepository } from '../../../core/common/idempotency-record.repository';
import { HashUtil } from '../../../core/common/hash.util';
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private readonly records: IdempotencyRecordRepository) {}
async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const req = ctx.switchToHttp().getRequest<Request & { body: unknown }>();
const res = ctx.switchToHttp().getResponse<{ statusCode: number }>();
const key = (req.headers as Record<string, string>)['idempotency-key'];
const commandHash = HashUtil.sha256(req.body);
const existing = await this.records.findByKey(key);
if (existing) {
if (existing.commandHash !== commandHash) throw new ConflictException('Idempotency-Key reused with different payload');
ctx.switchToHttp().getResponse<{ status: (n: number) => void }>().status(existing.httpStatus);
return new Observable((subscriber) => {
subscriber.next(existing.responseBody);
subscriber.complete();
});
}
return next.handle().pipe(
tap(async (body) => {
await this.records.save({
key,
commandHash,
responseBody: body,
httpStatus: res.statusCode,
});
}),
);
}
}
Контроллер — CreateOrder
// adapters/in/http/order.controller.ts
import { Controller, Post, Body, Headers, UseGuards, UseInterceptors, HttpCode } from '@nestjs/common';
import { Roles } from '../security/roles.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { IdempotencyKeyGuard } from '../guards/idempotency-key.guard';
import { IdempotencyInterceptor } from '../interceptors/idempotency.interceptor';
import { CreateOrderUseCase } from '../../../core/order/use-cases/create-order.use-case';
import { CreateOrderRequest } from './dto/create-order.request';
import { OrderResponse } from './dto/order.response';
import { Principal } from '../security/principal';
import { CurrentUser } from '../security/current-user.decorator';
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrderController {
constructor(private readonly createOrder: CreateOrderUseCase) {}
@Post()
@HttpCode(201)
@Roles('customer', 'seller')
@UseGuards(IdempotencyKeyGuard)
@UseInterceptors(IdempotencyInterceptor)
async create(
@Headers('idempotency-key') key: string,
@Body() dto: CreateOrderRequest,
@CurrentUser() principal: Principal,
): Promise<OrderResponse> {
const order = await this.createOrder.execute({ ...dto, idempotencyKey: key }, principal);
return OrderResponse.from(order);
}
}
Репозиторий idempotency_record
// core/common/idempotency-record.repository.ts
export interface IdempotencyRecord {
key: string;
commandHash: string;
responseBody: unknown;
httpStatus: number;
createdAt: Date;
}
export abstract class IdempotencyRecordRepository {
abstract findByKey(key: string): Promise<IdempotencyRecord | null>;
abstract save(record: Omit<IdempotencyRecord, 'createdAt'>): Promise<void>;
}
Реализация через jOOQ (Node) или Knex читает таблицу idempotency_record с TTL 24–72 часа. Просроченные записи чистятся scheduled job-ом.
Двойная защита для money
На application-уровне idempotency_record — первый рубеж. Money-операции защищаются ещё и уникальным ограничением в БД:
CREATE TABLE payment (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
idempotency_key text NOT NULL,
amount numeric(19,4) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (order_id, idempotency_key)
);
Если interceptor пропустил retry (например, разные nodes обработали запросы под race), UNIQUE constraint ловит дубль на уровне БД. Для CreateOrder в контексте Sber: (order_id, idempotency_key) гарантирует, что один платёж создаётся ровно один раз даже при конкурентных retry.
Почему «часть auth-контракта»
Idempotency-Key не optional feature, он часть того, что значит «правильно аутентифицированный запрос обрабатывается». UCP формулирует это в auth-контексте, потому что:
- Без него auth бесполезен. Атакёр с украденным токеном делает retry money-команды — без идемпотентности каждый retry проводит списание.
- Без него legitimate retry превращается в атаку на пользователя — клиент думает «не отправилось», нажимает снова, два списания.
- Это контракт между client и server — как
Authorizationheader, без него запрос отклоняется с400.
Поэтому money-endpoint без обязательного Idempotency-Key — нарушение AUTH-19, не оптимизация.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Money-endpoint без Idempotency-Key обязательного | AUTH-19 | header required, иначе 400 |
Idempotency-Key optional для money | AUTH-19 | обязателен всегда |
Retry с новым Idempotency-Key на каждый attempt | AUTH-19 | один ключ на бизнес-операцию |
idempotency_record без TTL и очистки | AUTH-19 | 24–72 часа + scheduled cleanup |
| Идемпотентность только в interceptor, без UNIQUE на БД | AUTH-19 | двойная защита для money |
| Одинаковый ключ для разных команд | AUTH-19 | проверка commandHash → 409 |
Idempotency-Key в URL-параметре вместо header | AUTH-19 | header (URL предназначен для resource id) |
Куда дальше
- ABAC: владение ресурсом — проверка владения по
order.customerId === principal.subв Handler. - Audit admin-команд —
NestInterceptorдля*_audit_logпри admin-действиях. - Хранение токенов на клиенте — HttpOnly cookie, refresh rotation в NestJS BFF.
- JWT validation —
JwtStrategyсpassport-jwt+jwks-rsa, 401 vs 403. - PII и секреты —
pino redact,ExceptionFilterбезString(cause), секреты не в git. - RBAC: маппинг ролей —
Roles-декоратор,RolesGuard,extractRolesиз claims. - Service-to-service — mTLS / Client Credentials, outbound-клиенты не анонимны.
- Где какая проверка — Gateway / BFF / Domain Service — распределение auth по слоям.