🌐 Розділ 6 · Питання #8

Чи є POST ідемпотентним

Згідно зі специфікацією протоколу HTTP (RFC 9110), метод POST офіційно визначений як неідемпотентний (Non-idempotent) та небезпечний (Non-safe).


🟢 Junior Level

Ні, метод POST не є ідемпотентним.

Згідно зі специфікацією протоколу HTTP (RFC 9110), метод POST офіційно визначений як неідемпотентний (Non-idempotent) та небезпечний (Non-safe).

Це означає, що повторне надсилання одного й того самого запиту POST з ідентичним тілом може призводити до створення нових ресурсів, повторного списання коштів або повторного виконання побічних ефектів (side effects) на сервері.

Наочний приклад

Уявіть оформлення замовлення в інтернет-магазині:

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. Додавання даних до наявного журналу або потоку повідомлень (append-only).

Оскільки сервер сам керує виділенням ідентифікаторів та життєвим циклом операції, кожен запит спричиняє новий ефект.

Патерн 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 вирішує проблему повторного надсилання форм користувачем під час оновлення сторінки в браузері).

Пов’язані теми