Опирается на правила: 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 RequestIdempotency-Key required.

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-контексте, потому что:

  1. Без него auth бесполезен. Атакёр с украденным токеном делает retry money-команды — без идемпотентности каждый retry проводит списание.
  2. Без него legitimate retry превращается в атаку на пользователя — клиент думает «не отправилось», нажимает снова, два списания.
  3. Это контракт между client и server — как Authorization header, без него запрос отклоняется с 400.

Поэтому money-endpoint без обязательного Idempotency-Key — нарушение AUTH-19, не оптимизация.

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

АнтипаттернПравилоЧто взамен
Money-endpoint без Idempotency-Key обязательногоAUTH-19header required, иначе 400
Idempotency-Key optional для moneyAUTH-19обязателен всегда
Retry с новым Idempotency-Key на каждый attemptAUTH-19один ключ на бизнес-операцию
idempotency_record без TTL и очисткиAUTH-1924–72 часа + scheduled cleanup
Идемпотентность только в interceptor, без UNIQUE на БДAUTH-19двойная защита для money
Одинаковый ключ для разных командAUTH-19проверка commandHash409
Idempotency-Key в URL-параметре вместо headerAUTH-19header (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 по слоям.