Опирается на правила: AUTH-19 из Auth Patterns Style Guide → раздел 8. Идемпотентность как часть auth-контракта.

Важно знать

  • Money-команды требуют Idempotency-Key header — обязательный.
  • Повторный вызов с тем же ключом возвращает прежний результат, не создаёт дубль.
  • Защита от retry клиента, retry мобильного приложения, retry в S2S, network timeout.
  • Без Idempotency-Key два списания одного клиента — money-инцидент.
  • Идемпотентность — это auth-контракт: «правильно подписанный запрос обрабатывается ровно один раз».
  • Тот же ключ + другая команда → 409 Conflict.

Money-команды не должны выполняться дважды от одного клиента. Кажется очевидно, но сетевой timeout не означает, что write не произошёл — он означает, что мы не знаем, произошёл или нет. Retry без идемпотентности → двойное списание. UCP формулирует требование как часть auth-контракта, не optional optimization.

Какие команды требуют 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)
UpdateUserProfileНет (не money)

Общее правило: «если retry с тем же payload может потребовать compensation на стороне backend — нужен Idempotency-Key».

Контракт

Клиент:

POST /orders
Authorization: Bearer ...
Idempotency-Key: 0193a8f3-7c21-7e3f-9b4a-...
Content-Type: application/json

{ "customerId": 42, "items": [...] }

Backend:

  • Первый вызов: обрабатывает, сохраняет (idempotency_key, command_hash, response) в idempotency_record, возвращает 201 Created + body.
  • Повтор с тем же ключом + тем же payload: возвращает сохранённый response (тот же status, тот же body). Не создаёт дубль.
  • Тот же ключ + другой payload: 409 Conflict — клиент пытается переиспользовать ключ.
  • Без header: 400 Bad RequestIdempotency-Key required.

Реализация — см. Distributed → idempotency.

@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {

    private final UseCaseDispatcher dispatcher;
    private final IdempotencyRecordRepository idempotencyRepository;

    @PostMapping
    @PreAuthorize("hasRole('customer')")
    public ResponseEntity<OrderResponse> create(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody @Valid CreateOrderRequest request
    ) {
        var commandHash = HashUtil.sha256(request);
        var existing = idempotencyRepository.findByKey(key);
        if (existing.isPresent()) {
            var record = existing.get();
            if (!record.commandHash().equals(commandHash)) {
                throw new IdempotencyConflictException(key);
            }
            return ResponseEntity.status(record.httpStatus())
                .body(record.response(OrderResponse.class));
        }

        var order = dispatcher.dispatch(new CreateOrderCommand(key, request));
        var response = OrderResponse.from(order);
        idempotencyRepository.save(key, commandHash, response, 201);
        return ResponseEntity.status(201).body(response);
    }
}

Почему «часть auth-контракта»

Idempotency-Key не optional, он не feature. Он часть того, что значит «правильно подписанный запрос обрабатывается». UCP формулирует это в auth-контексте, потому что:

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

Поэтому endpoint money без Idempotency-Key header — 400 Bad Request, не оптимизация.

Money — двойная защита

Дополнительно к application-уровню idempotency_record, money-операции защищаются БД unique-constraint:

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)
);

Если idempotency_record пропустил retry (например, разные clients использовали разные ключи), UNIQUE constraint на (order_id, idempotency_key) ловит дубль на уровне БД.

Подробнее — Distributed → idempotency R-DIST-IDEM-4.

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

АнтипаттернПравилоЧто взамен
Money-endpoint без Idempotency-Key обязательногоAUTH-19header required, иначе 400
Idempotency-Key optional для moneyAUTH-19обязателен
Retry с новым Idempotency-KeyAUTH-19один ключ на бизнес-операцию
idempotency_record без TTLAUTH-1924-72 часа
Idempotency только на application, без БД UNIQUEAUTH-19двойная защита для money
Одинаковый ключ для разных командAUTH-19проверка command_hash → 409
Idempotency-Key только в URL, не в headerAUTH-19header (URL meant для resource id)

Куда дальше

  • Auth → раздел 8. Идемпотентность — нормативные формулировки.
  • Distributed → idempotency — детали idempotency_record, двойная защита.
  • Kafka → idempotent consumer — eventId как Idempotency-Key для downstream HTTP.
  • Distributed → eventual consistency — read-your-writes без double-charge.
  • REST API → headersIdempotency-Key header convention.
  • Resilience → retry — retry только при идемпотентности.