🌐 Раздел 6 · Вопрос #8

Является ли POST идемпотентным

Согласно спецификации протокола HTTP (RFC 9110), метод POST официально определен как неидемпотентный (Non-idempotent) и небезопасный (Non-safe).


🟢 Junior Level

Нет, метод POST не является идемпотентным.

Согласно спецификации протокола HTTP (RFC 9110), метод POST официально определен как неидемпотентный (Non-idempotent) и небезопасный (Non-safe).

Это означает, что повторная отправка одного и того же запроса POST с идентичным телом может приводить к созданию новых ресурсов, списанию денежных средств или повторному выполнению побочных эффектов на сервере.

Наглядный пример

Представьте оформление заказа в интернет-магазине:

POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{
  "item": "Ноутбук",
  "quantity": 1,
  "price": 100000
}
  • 1-й вызов: Создается заказ №101. Со счета списывается 100 000 руб.
  • 2-й вызов: Создается заказ №102. Со счета списывается еще 100 000 руб.
  • 3-й вызов: Создается заказ №103. Списание повторяется в третий раз.

Именно поэтому веб-браузеры выводят диалоговое окно с предупреждением: «Подтвердите повторную отправку формы» (Confirm Form Resubmission) при попытке обновить страницу после отправки данных методом POST.


🟡 Middle Level

Назначение метода POST по RFC 9110

Спецификация HTTP (RFC 9110, раздел 9.3.3) определяет POST как универсальный метод для:

  1. Создания подчиненного ресурса (Subordinate Resource): URI сервера выступает коллекцией (/users), а сервер сам генерирует ID нового объекта (/users/105).
  2. Обработки данных (Data Processing): Передача команд, запуск бизнес-процессов (/calculate-tax, /send-notification).
  3. Добавления данных в существующий журнал или поток сообщений.

Поскольку сервер сам управляет выделением идентификаторов и жизненным циклом операции, каждый запрос производит новый эффект.

Паттерн Post/Redirect/Get (PRG)

В классических веб-приложениях неидемпотентность POST вызывает проблему дублирования данных при нажатии пользователем клавиши F5 (Refresh). Решением является архитектурный паттерн PRG:

Пользователь (Браузер)                 Сервер (Spring MVC)
       │                                       │
       ├─── 1. POST /order/checkout ──────────>│ (Сохранение заказа в БД)
       │                                       │
       │<── 2. HTTP 303 See Other ─────────────┤ (Location: /order/101/summary)
       │                                       │
       ├─── 3. GET /order/101/summary ────────>│ (Безопасное чтение)
       │                                       │
       │<── 4. 200 OK (HTML страница) ─────────┤

Если пользователь нажмет F5, браузер повторит безопасный и идемпотентный метод GET, а не опасный POST.

Исключение: Использование POST для поиска (POST as Read-Only)

В ряде случаев POST используется для операций чтения:

  • Сложные поисковые фильтры: Если JSON с параметрами фильтрации, сортировки и фасетов слишком велик (URL в браузере или прокси-сервере ограничен 2048–8192 байтами).
  • GraphQL: Все запросы (включая query) отправляются методом POST.

Важно понимать: Хотя такой POST /search фактически безопасен (только читает данные), на уровне протокола HTTP клиенты и прокси-серверы все равно считают его неидемпотентным (не кэшируют автоматически и не повторяют при сбоях сети). Для стандартизации безопасного чтения с телом запроса IETF разрабатывает новый метод QUERY.


🔴 Senior Level

Архитектура обеспечения идемпотентности для POST (Stripe Idempotency Pattern)

В распределенных платежных и торговых системах потеря сетевого пакета не должна приводить к финансовым потерям. Чтобы сделать бизнес-операцию на базе POST идемпотентной, применяется связка Idempotency-Key + Redis + Database Constraints.

Жизненный цикл запроса с Idempotency-Key

  1. Клиент генерирует UUID (v4) и передает его в заголовке Idempotency-Key.
  2. Приложение атомарно проверяет ключ в распределенном хранилище (Redis):
    • Если ключ найден со статусом COMPLETED: немедленно возвращается кэшированное тело ответа и статус-код (бизнес-логика пропускается).
    • Если ключ найден со статусом IN_PROGRESS: возвращается 409 Conflict (предотвращение Race Condition при параллельных запросах).
    • Если ключа нет: устанавливается статус IN_PROGRESS с TTL (например, 2 минуты).
  3. Выполняется бизнес-транзакция.
  4. В хранилище статус меняется на COMPLETED, записывается ответ и выставляется постоянный TTL (например, 24 часа).

Spring Boot 3 реализация сервиса платежей

package com.example.service;

import com.example.dto.PaymentRequest;
import com.example.dto.PaymentResponse;
import com.example.entity.Payment;
import com.example.repository.PaymentRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;

@Service
public class PaymentProcessingService {

    private final StringRedisTemplate redisTemplate;
    private final PaymentRepository paymentRepository;
    private final ObjectMapper objectMapper;

    private static final Duration LOCK_TTL = Duration.ofMinutes(1);
    private static final Duration RESULT_TTL = Duration.ofHours(24);

    public PaymentProcessingService(StringRedisTemplate redisTemplate,
                                    PaymentRepository paymentRepository,
                                    ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.paymentRepository = paymentRepository;
        this.objectMapper = objectMapper;
    }

    public PaymentResponse processPayment(String idempotencyKey, PaymentRequest request) {
        String lockKey = "idemp:lock:" + idempotencyKey;
        String dataKey = "idemp:result:" + idempotencyKey;

        // 1. Проверяем наличие готового результата в кэше
        String cachedJson = redisTemplate.opsForValue().get(dataKey);
        if (cachedJson != null) {
            return deserializeResponse(cachedJson);
        }

        // 2. Атомарно захватываем распределенную блокировку
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED", LOCK_TTL);
        if (Boolean.FALSE.equals(acquired)) {
            throw new ConcurrentPaymentProcessingException("Payment with this idempotency key is already processing");
        }

        try {
            // 3. Выполняем бизнес-логику в транзакции
            PaymentResponse response = executePaymentTransaction(idempotencyKey, request);

            // 4. Кэшируем результат для безопасных повторов
            String responseJson = objectMapper.writeValueAsString(response);
            redisTemplate.opsForValue().set(dataKey, responseJson, RESULT_TTL);

            return response;
        } catch (Exception ex) {
            throw new PaymentException("Payment processing failed", ex);
        } finally {
            // 5. Освобождаем блокировку
            redisTemplate.delete(lockKey);
        }
    }

    @Transactional
    protected PaymentResponse executePaymentTransaction(String idempotencyKey, PaymentRequest req) {
        // Уровень СУБД защищен UNIQUE констрейнтом по idempotency_key
        try {
            Payment payment = new Payment();
            payment.setIdempotencyKey(idempotencyKey);
            payment.setAccountId(req.accountId());
            payment.setAmount(req.amount());
            payment.setStatus("CONFIRMED");

            Payment saved = paymentRepository.save(payment);
            return new PaymentResponse(saved.getId(), saved.getStatus(), saved.getAmount());
        } catch (DataIntegrityViolationException ex) {
            // Дубликат пойман констрейнтом БД при гонке между серверами
            Payment existing = paymentRepository.findByIdempotencyKey(idempotencyKey).orElseThrow();
            return new PaymentResponse(existing.getId(), existing.getStatus(), existing.getAmount());
        }
    }

    private PaymentResponse deserializeResponse(String json) {
        try {
            return objectMapper.readValue(json, PaymentResponse.class);
        } catch (Exception e) {
            throw new IllegalStateException("Failed to parse cached payment response", e);
        }
    }
}

Защита от подмены тела запроса (Payload Integrity)

Если клиент отправляет запрос с тем же Idempotency-Key, но с измененными параметрами (amount = 500 вместо amount = 100), сервер не имеет права возвращать старый результат.

Решение:

  1. При первом запросе сервер вычисляет криптографический хеш тела (SHA-256) и сохраняет его вместе с ключом.
  2. При повторном запросе вычисляется хеш нового тела.
  3. Если хеши не совпадают, сервер немедленно возвращает ошибку 422 Unprocessable Entity с сообщением: "Idempotency-Key reuse with different payload is prohibited".

🎯 Шпаргалка для интервью

30-секундный ответ

По спецификации HTTP (RFC 9110) метод POST официально не является идемпотентным.

Семантически он предназначен для создания подчиненных ресурсов или запуска бизнес-операций, поэтому повторный вызов $N$ раз порождает $N$ новых записей или повторные мутации данных. Из-за этого браузеры блокируют неявные повторы (Confirm Form Resubmission), а инфраструктурные прокси не имеют права повторять его при таймаутах.

В высоконадежных архитектурах (платежи, заказы) идемпотентность для POST реализуется программно через Idempotency-Key, распределенные блокировки в Redis и UNIQUE-индексы в базе данных.

4 каверзных вопроса с ответами

  1. Можно ли использовать POST для идемпотентных операций и не нарушает ли это REST? Ответ: Да, это допустимо, например, при поиске со сложным фильтром (POST /api/v1/search), когда параметры не помещаются в URL-лимит метода GET. Однако с точки зрения HTTP-клиентов и прокси-серверов этот эндпоинт будет считаться неидемпотентным (он не будет кэшироваться и автоматически ретраиться без специальной настройки).

  2. Что произойдет, если два одинаковых POST запроса с одним Idempotency-Key придут на разные ноды кластера одновременно? Ответ: Возникнет состояние гонки (Race Condition). Защитой служит атомарный захват распределенного лока (SETNX в Redis) или уникальный индекс в реляционной СУБД (UNIQUE(idempotency_key)). Первый запрос получит блокировку и начнет исполнение, второй получит статус 409 Conflict (или подождет завершения первого и вернет кэшированный ответ).

  3. В чем разница между созданием ресурса через PUT и через POST с точки зрения идемпотентности? Ответ: При использовании PUT /users/42 клиент сам определяет URI и идентификатор ресурса. Повторный PUT перезапишет тот же URI 42 теми же данными (метод идемпотентен). При POST /users идентификатор генерирует сервер: повторный запрос создаст новый URI /users/43 (метод неидемпотентен).

  4. Что такое паттерн PRG и какую проблему метода POST он решает? Ответ: Post/Redirect/Get — веб-паттерн, при котором успешный POST завершается HTTP-ответом 303 See Other с заголовком Location на URL созданного ресурса. Браузер автоматически делает безопасный GET. Это исключает повторную отправку формы при обновлении страницы по F5.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «POST идемпотентен, если мы обновляем данные, а не создаем новые» (по спецификации HTTP метод POST неидемпотентен всегда, вне зависимости от логики контроллера).
  • ❌ «Сетевые клиенты могут автоматически повторять сбойные POST запросы так же, как GET» (это приведет к дублированию транзакций и финансовым списаниям).
  • ❌ «Idempotency Key нужно проверять только в оперативной памяти приложения» (в кластере из нескольких подов это приведет к пропуску дубликатов; требуется общее хранилище вроде Redis или СУБД).
  • ❌ «Паттерн PRG нужен для ускорения работы базы данных» (PRG решает проблему повторной отправки форм пользователем при обновлении страницы в браузере).

Связанные темы