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

Чому GET та DELETE ідемпотентні

Методи GET та DELETE вирішують протилежні завдання (читання та видалення), але обидва мають фундаментальну властивість протоколу HTTP — ідемпотентність.


🟢 Junior Level

Методи GET та DELETE вирішують протилежні завдання (читання та видалення), але обидва мають фундаментальну властивість протоколу HTTP — ідемпотентність.

Це означає, що повторення одного й того самого запиту залишає сервер у тому самому цільовому стані, що й одноразовий запуск.

1. Чому GET ідемпотентний

Метод GET призначений виключно для отримання (читання) представлення ресурсу. Він не змінює стан даних у базі даних:

GET /api/v1/users/42 HTTP/1.1  -> 200 OK (дані користувача)
GET /api/v1/users/42 HTTP/1.1  -> 200 OK (ті самі дані)

Оскільки GET є безпечним (Safe) методом, його повторний виклик $N$ разів залишає дані недоторканими ($f(x) = x$).

2. Чому DELETE ідемпотентний

Метод DELETE переводить цільовий ресурс у кінцевий стан: «ресурс у системі відсутній».

// 1-й запит: ресурс був у БД, ми його видалили
DELETE /api/v1/users/42 HTTP/1.1  -> 204 No Content

// 2-й запит (повтор через збій мережі): ресурс уже відсутній
DELETE /api/v1/users/42 HTTP/1.1  -> 404 Not Found (або 204 No Content)

Ключовий висновок: І після першого виклику, і після десятого виклику ресурс users/42 у системі відсутній. Підсумковий стан сервера ідентичний, що суворо задовольняє визначення ідемпотентності.


🟡 Middle Level

Різні коди відповіді при DELETE та ідемпотентність

Одне з найчастіших питань на технічних інтерв’ю:

«Якщо перший виклик DELETE повертає 200 OK або 204 No Content, а другий повертає 404 Not Found, чому метод вважається ідемпотентним? Адже коди відповіді різні!»

Пояснення за стандартом RFC 9110 (розділ 9.2.2): Ідемпотентність визначається виключно ефектом, який чиниться на стан сервера (side effects on the server state), а не статус-кодом HTTP-відповіді.

Існує дві поширені стратегії відповіді на повторний DELETE:

  1. Сувора REST-семантика: Перший виклик повертає 204 No Content, повторні виклики — 404 Not Found (клієнту повідомляється, що ресурсу вже немає).
  2. Тиха ідемпотентність (Silent Idempotency): Завжди повертати 204 No Content, навіть якщо ресурсу вже не було (клієнт просив, щоб ресурсу не було — його немає, мета досягнута).

Обидві стратегії є повністю ідемпотентними.

Технічні побічні ефекти при GET (Access Logs, Metrics, Cache)

У специфікації HTTP термін «Safe method» означає відсутність передбачуваних клієнтом мутацій цільового ресурсу. Проте GET запит має повне право:

  • Додавати записи в журнал доступу (access.log) та аудит.
  • Інкрементувати лічильники в Prometheus / StatsD.
  • Прогрівати кеш у Redis або CDN.

Ці інфраструктурні дії не змінюють семантичний стан бізнес-сутності і не порушують контракт безпеки.

М’яке видалення (Soft Delete)

У корпоративних додатках фізичне видалення (DELETE FROM table) часто замінюється логічним:

UPDATE users SET is_deleted = true, deleted_at = NOW() WHERE id = 42;

Навіть при м’якому видаленні метод DELETE залишається ідемпотентним: скільки б разів клієнт не відправляв запит, запис збереже статус is_deleted = true.


🔴 Senior Level

Ідемпотентність DELETE в Event-Driven Architecture (Kafka / Outbox)

У мікросервісних системах видалення сутності зазвичай супроводжується публікацією доменного повідомлення (події): UserDeletedEvent(userId=42).

Якщо клієнт через мережевий збій повторює запит DELETE /api/v1/users/42, наївна реалізація може відправити другу подію в Kafka. Це призведе до дублювання подій у downstream-сервісах (білінг, нотифікації).

Реалізація з Transactional Outbox у Spring Boot 3

package com.example.service;

import com.example.entity.OutboxEvent;
import com.example.entity.User;
import com.example.repository.OutboxRepository;
import com.example.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final OutboxRepository outboxRepository;

    public UserService(UserRepository userRepository, OutboxRepository outboxRepository) {
        this.userRepository = userRepository;
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public boolean deleteUser(Long userId) {
        Optional<User> userOpt = userRepository.findById(userId);

        if (userOpt.isEmpty()) {
            // Ресурс уже видалено!
            // НЕ генеруємо повторну подію в Outbox, зберігаючи ідемпотентність подій
            return false;
        }

        User user = userOpt.get();
        userRepository.delete(user);

        // Публікуємо подію рівно один раз
        OutboxEvent event = new OutboxEvent(
                "USER_EVENTS",
                String.valueOf(userId),
                "UserDeleted",
                "{\"userId\":" + userId + "}"
        );
        outboxRepository.save(event);

        return true;
    }
}
package com.example.controller;

import com.example.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        boolean wasDeleted = userService.deleteUser(id);
        if (wasDeleted) {
            return ResponseEntity.noContent().build(); // 204 No Content
        }
        // При повторному запиті повертаємо 404
        return ResponseEntity.notFound().build(); // 404 Not Found
    }
}

Умовний DELETE з Optimistic Locking (ETag / If-Match)

За специфікацією RFC 9110 для методу DELETE можна застосовувати заголовок If-Match:

DELETE /api/v1/documents/100 HTTP/1.1
If-Match: "v2-revision-etag"

Сервер перевіряє, що документ видаляється саме в тій версії, яку бачив користувач. Якщо інший користувач паралельно відредагував документ (версія стала v3), сервер поверне 412 Precondition Failed, захищаючи дані від випадкового видалення модифікованого документа.

Пакетне видалення (Bulk Delete): як не зламати REST

Поширена антипатерн-помилка: створення методу POST /api/v1/users/delete-batch. Використання POST позбавляє операцію ідемпотентності на рівні протоколу.

Коректні REST-підходи:

  1. Query-параметри: DELETE /api/v1/users?ids=1,2,3,4 (метод DELETE зберігає ідемпотентність).
  2. Підпорядкований ресурс завдань: Якщо видалення мільйонів записів займає хвилини, використовується асинхронний патерн:
    • POST /api/v1/deletion-jobs -> 202 Accepted + заголовок Location: /api/v1/deletion-jobs/789.
    • Клієнт опитує статус завдання через ідемпотентний GET /api/v1/deletion-jobs/789.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

  • GET ідемпотентний, тому що є безпечним (Safe) методом: він семантично призначений тільки для читання і не мутує стан бізнес-ресурсів. Повторення запиту $N$ разів залишає базу даних у вихідному стані.
  • DELETE ідемпотентний, тому що приводить ресурс до детермінованого цільового стану: «ресурс видалено». Перший виклик видаляє об’єкт, усі повторні виклики констатують його відсутність.
  • Різниця в статус-кодах (204 No Content при першому виклику і 404 Not Found при повторному) не порушує ідемпотентність, оскільки стандарт RFC 9110 вимагає незмінності стану сервера, а не кодів відповідей.

4 каверзних питання з відповідями

  1. Якщо GET /articles/15 збільшує лічильник переглядів у БД, чи перестає він бути Safe та ідемпотентним? Відповідь: З точки зору специфікації RFC 9110 §9.2.1, допоміжні технічні ефекти (логування, лічильники аналітики, кешування) не позбавляють метод статусу Safe, якщо вони не змінюють представлення цільового ресурсу і не впливають на бізнес-логіку управління ресурсом. Проте розміщувати критичну бізнес-логіку (наприклад, списання балансу) у GET категорично заборонено.

  2. Чому небезпечно робити операції видалення через GET (наприклад, GET /users/1/delete)? Відповідь: Браузери, веб-краулери (пошукові роботи Google) та проксі-сервери вважають GET безпечним методом. Пошуковий робот або браузерний механізм попереднього завантаження посилань (prefetching) перейде за цим посиланням і видалить дані без відома користувача.

  3. Що робити в Event-Driven архітектурі, якщо DELETE викликається повторно? Відповідь: Використовувати перевірку існування ресурсу перед публікацією події (або Transactional Outbox). Повторний DELETE повинен повернути клієнту 404 Not Found (або 204), але не повинен генерувати дублюючу подію EntityDeleted у Kafka/RabbitMQ.

  4. Як реалізувати ідемпотентне пакетне видалення в REST API? Відповідь: Використовувати DELETE /api/v1/items?id=1&id=2&id=3. Використання POST /api/v1/items/batch-delete семантично неідемпотентне і позбавляє клієнт можливості безпечного автоматичного повтору при збоях мережі.

Червоні прапорці (чого категорично не можна говорити)

  • ❌ «DELETE не ідемпотентний, тому що повертає 404 замість 200/204 при повторі» (ідемпотентність оцінюється за станом БД, а не кодами відповідей).
  • ❌ «GET запити заборонено логувати, тому що логування змінює стан сервера» (логування та збір метрик прямо дозволені RFC 9110).
  • ❌ «Для пакетного видалення потрібно завжди робити POST» (для ідемпотентного пакетного видалення використовується DELETE зі списком ID в query).
  • ❌ «Soft Delete робить метод DELETE неідемпотентним» (навпаки, повторний UPDATE одного й того самого прапорця is_deleted = true абсолютно ідемпотентний).

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