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

Що таке ідемпотентність (idempotency)

Простими словами: якщо виконати ідемпотентну операцію 1 раз або 100 разів поспіль — стан системи на сервері залишиться незмінним.


🟢 Junior Level

Ідемпотентність (Idempotency) у програмуванні та веб-протоколах — це властивість операції, за якої повторний виклик одного й того самого запиту з тими самими параметрами призводить до точно такого ж стану сервера, як і після першого виконання.

Простими словами: якщо виконати ідемпотентну операцію 1 раз або 100 разів поспіль — стан системи на сервері залишиться незмінним.

Наочна аналогія з життя

  • Ідемпотентна дія: Натиснути кнопку «Вимкнути світло». Якщо світло увімкнене — воно згасне. Якщо світло вже вимкнене — повторні натискання нічого не змінять, світло так і залишиться вимкненим.
  • Неідемпотентна дія: Натиснути кнопку «Перемкнути світло» (toggle) або «Списати 100 гривень». Кожне повторне натискання призводить до нової дії (світло загоряється/гасне, гроші списуються знову і знову).

Ідемпотентність в HTTP (RFC 9110)

За специфікацією HTTP-методи поділяються за властивостями ідемпотентності:

  • GET /users/42 — ідемпотентний (і безпечний). Скільки б разів ми не читали дані, стан сервера не змінюється.
  • PUT /users/42 — ідемпотентний. Запит повністю перезаписує дані користувача {"name": "Ivan"}. Повторення запиту перезаписує ті самі поля тими самими даними.
  • DELETE /users/42 — ідемпотентний. Перший запит видаляє користувача. Повторні запити бачать, що користувача вже немає. Стан сервера (користувач відсутній) однаковий після першого та наступних запитів.
  • POST /users — неідемпотентний. Кожен повторний виклик створює нового користувача з новим ідентифікатором.

Навіщо потрібна ідемпотентність

Комп’ютерні мережі принципово ненадійні. Якщо при виклику API клієнт отримав мережеву помилку Read timed out (таймаут відповіді), клієнт не знає:

  1. Запит не дійшов до сервера?
  2. Сервер впав під час обробки?
  3. Сервер успішно виконав операцію, але відповідь загубилася в мережі?

Якщо метод ідемпотентний — клієнт може безпечно повторити (retry) цей самий запит, не ризикуючи зіпсувати дані або створити дублікати.


🟡 Middle Level

Відмінність між Safe та Idempotent методами

У специфікації HTTP (RFC 9110) суворо розділяються поняття безпечності (Safety) та ідемпотентності (Idempotency):

Властивість Визначення Припустимість зміни стану HTTP-методи
Safe (Безпечний) Виклик методу семантично призначений тільки для читання і не має права змінювати стан сервера. ❌ Немає змін GET, HEAD, OPTIONS, TRACE
Idempotent (Ідемпотентний) Багаторазовий виклик призводить до того самого стану сервера, що й одноразовий. ✅ Може змінювати (один раз) GET, HEAD, OPTIONS, TRACE, PUT, DELETE
Non-idempotent Кожен виклик може створювати нові побічні ефекти та мутації. ✅ Кожне виконання змінює стан POST, PATCH (за замовчуванням)

Математична формула: $f(f(x)) = f(x)$. Усі безпечні (Safe) методи завідомо є ідемпотентними, але не всі ідемпотентні методи є безпечними (PUT і DELETE змінюють стан БД!).

Важливий парадокс: Ідемпотентність стану vs Код відповіді

На співбесідах часто ставлять запитання-пастку:

«Перший запит DELETE /users/42 повернув 204 No Content, а другий повернув 404 Not Found. Чи однакові коди відповіді? Чи є метод ідемпотентним?»

Правильна відповідь: Так, метод суворо ідемпотентний! Специфікація HTTP вимагає ідентичності стану цільового ресурсу на сервері, а не HTTP-статусу відповіді або заголовків. І після першого, і після десятого виклику ресурс users/42 у системі відсутній.

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

При відправці веб-форм (наприклад, оформлення замовлення) браузер виконує неідемпотентний POST. Якщо користувач о 실вить сторінку (натисне F5), браузер попередить про повторну відправку даних форми.

Для вирішення цієї проблеми використовується патерн PRG:

  1. Браузер надсилає POST /orders.
  2. Сервер зберігає замовлення і відповідає редиректом: HTTP/1.1 303 See Other із заголовком Location: /orders/123.
  3. Браузер автоматично виконує ідемпотентний GET /orders/123.
  4. Натискання F5 тепер повторює тільки безпечний GET.

🔴 Senior Level

Реалізація ідемпотентності для неідемпотентного POST (Патерн Idempotency-Key)

У фінансових сервісах (Stripe, Adyen), e-commerce та мікросервісах повтор мережевого запиту POST /api/v1/payments без захисту призведе до повторного списання коштів.

Стандартом індустрії (IETF Draft draft-ietf-httpapi-idempotency-key-header) є передача клієнтом унікального заголовка:

POST /api/v1/payments HTTP/1.1
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json

{
  "accountId": 1001,
  "amount": 5000
}

Архітектура та стани машини ідемпотентності

Client                     API Gateway / Filter                      Redis Cache               Business Logic
  │                                  │                                    │                          │
  ├─ POST + Idempotency-Key ────────>│                                    │                          │
  │                                  ├─ SETNX key "PROCESSING" ─────────>│                          │
  │                                  │<─ 1 (Key created, lock acquired) ──┤                          │
  │                                  ├──────────────────────────────────────────────────────────────>│
  │                                  │                                    │             (Execute TX) │
  │                                  │<─ Response (201 Created + Body) ──────────────────────────────┤
  │                                  ├─ SET key "COMPLETED" + Body ──────>│                          │
  │<─ 201 Created + Body ────────────┤                                    │                          │
  │                                  │                                    │                          │
  │── Retry POST (Same Key) ────────>│                                    │                          │
  │                                  ├─ GET key ─────────────────────────>│                          │
  │                                  │<─ "COMPLETED" + Body ──────────────┤                          │
  │<─ 201 Created + Cached Body ─────┤ (Skip Business Logic!)             │                          │

Обробка крайових сценаріїв (Edge Cases):

  1. Стан PROCESSING (Паралельний дублікат): Якщо надходить другий запит з тим самим ключем, поки перший ще виконується, сервер не повинен виконувати операцію паралельно. Повертається статус 409 Conflict або 425 Too Early.
  2. Невідповідність тіла запиту (Payload Hash Mismatch): Якщо клієнт надіслав той самий Idempotency-Key, але з іншим тілом запиту (amount: 9999 замість 5000), сервер зобов’язаний відхилити запит статусом 422 Unprocessable Entity або 400 Bad Request. Для цього у сховищі зберігається SHA-256 хеш початкового тіла запиту.
  3. Час життя ключа (TTL): Записи в Redis повинні мати суворий TTL (наприклад, 24 години), після якого ключ інвалідується.

Production-ready реалізація на Spring Boot 3 + Redis

Нижче наведено потокобезпечну реалізацію фільтра ідемпотентності з розподіленим блокуванням:

package com.example.idempotency;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

@Component
public class IdempotencyFilter extends OncePerRequestFilter {

    private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
    private static final Duration LOCK_TIMEOUT = Duration.ofMinutes(2);
    private static final Duration CACHE_TTL = Duration.ofHours(24);

    private final StringRedisTemplate redisTemplate;

    public IdempotencyFilter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // Перевіряємо ідемпотентність тільки для мутуючих методів з переданим заголовком
        String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER);
        if (idempotencyKey == null || !"POST".equalsIgnoreCase(request.getMethod())) {
            filterChain.doFilter(request, response);
            return;
        }

        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);

        // Читаємо тіло запиту
        byte[] requestBody = wrappedRequest.getInputStream().readAllBytes();
        String requestHash = DigestUtils.md5DigestAsHex(requestBody);

        String redisLockKey = "idemp:lock:" + idempotencyKey;
        String redisDataKey = "idemp:data:" + idempotencyKey;

        // Атомарне захоплення блокування SETNX
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(redisLockKey, requestHash, LOCK_TIMEOUT);

        if (Boolean.FALSE.equals(acquired)) {
            // Ключ уже існує. Перевіряємо, чи завершилася попередня операція
            String cachedResponse = redisTemplate.opsForValue().get(redisDataKey);
            if (cachedResponse != null) {
                // Повертаємо збережений результат клієнту без повторного виконання бізнес-логіки
                response.setStatus(HttpStatus.OK.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.setHeader("X-Cache-Lookup", "HIT-IDEMPOTENT");
                response.getWriter().write(cachedResponse);
                return;
            }

            // Запит усе ще обробляється іншим потоком/нодою
            response.setStatus(HttpStatus.CONFLICT.value());
            response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
            response.getWriter().write("{\"title\":\"Conflict\",\"detail\":\"Concurrent request in progress\"}");
            return;
        }

        try {
            // Передаємо керування сервісному шару
            filterChain.doFilter(wrappedRequest, wrappedResponse);

            // Зберігаємо успішну відповідь у Redis
            byte[] responseBody = wrappedResponse.getContentAsByteArray();
            String responseStr = new String(responseBody, StandardCharsets.UTF_8);

            if (wrappedResponse.getStatus() < 400) {
                redisTemplate.opsForValue().set(redisDataKey, responseStr, CACHE_TTL);
            }

            wrappedResponse.copyBodyToResponse();
        } finally {
            // Звільняємо lock
            redisTemplate.delete(redisLockKey);
        }
    }
}

Ідемпотентність на рівні СУБД (Database Level Idempotency)

Навіть якщо кеш або розподілене блокування дали збій, рівень бази даних повинен виступати гарантованим бар’єром проти дублікатів:

  1. Унікальні складені індекси: CREATE UNIQUE INDEX uq_order_idemp ON orders (idempotency_key);
  2. UPSERT-конструкції:
    INSERT INTO payments (idempotency_key, account_id, amount, status)
    VALUES (:key, :account, :amount, 'COMPLETED')
    ON CONFLICT (idempotency_key) DO NOTHING;
    
  3. Transactional Outbox & Inbox Pattern: Патерн Idempotent Consumer при вичитуванні повідомлень з Kafka / RabbitMQ з дедуплікацією за message_id у таблиці processed_messages.

4 Tricky Questions

1. Якщо перший виклик DELETE повертає 200/204, а другий 404, чому метод вважається ідемпотентним?

Відповідь: Специфікація HTTP визначає ідемпотентність через незмінність стану ресурсів на сервері, а не ідентичність заголовків і статус-кодів відповідей. Після першого виклику ресурс видалено; після другого виклику стан сервера той самий — ресурсу немає.


2. Чим відрізняються Safe та Idempotent методи?

Відповідь: Безпечні (Safe) методи (GET, HEAD) взагалі не мають права змінювати стан ресурсів (read-only). Ідемпотентні методи (PUT, DELETE) можуть змінювати стан сервера, але тільки при першому застосуванні; повторні виклики не створюють додаткових модифікацій. Будь-який Safe-метод є ідемпотентним, але не навпаки.


3. Що робити, якщо прийшов запит з тим самим Idempotency-Key, але з іншим тілом (payload)?

Відповідь: Це порушення протоколу інтеграції. Сервер зобов’язаний обчислити хеш (SHA-256) отриманого тіла, порівняти з хешем збереженого ключа і при розбіжності повернути 422 Unprocessable Entity або 400 Bad Request з повідомленням Idempotency key was previously used with a different request payload.


4. Що таке Retry Storm і як ідемпотентність пов’язана з цією проблемою?

Відповідь: При аварії на сервері клієнти починають лавиноподібно повторювати запити (retries). Завдяки ідемпотентності повтори є безпечними для даних, але створюють колосальне навантаження на інфраструктуру. Рішення: Exponential Backoff з Random Jitter з боку клієнта та Rate Limiting / Circuit Breaker з боку сервера.


🎯 Interview Cheat Sheet

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

Ідемпотентність (RFC 9110) — це властивість операції, яка гарантує, що багаторазове виконання запиту з тими самими аргументами залишає сервер у тому самому стані, що й одноразове.

За специфікацією:

  • Ідемпотентні: GET, HEAD, OPTIONS, PUT (повна заміна), DELETE (видалення).
  • Неідемпотентні: POST (створення нових сутностей), PATCH (залежить від операції, за замовчуванням не гарантується).

Ідемпотентність визначається станом сервера, а не кодом HTTP-відповіді (повторний DELETE може повернути 404, але метод залишається ідемпотентним). У розподілених системах для захисту POST застосовується патерн Idempotency-Key з дедуплікацією через Redis та UNIQUE-констрейнти в СУБД.

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

  • ❌ «Ідемпотентність означає, що сервер зобов’язаний завжди повертати однаковий HTTP-статус код» (груба помилка: ідемпотентність — це про стан даних на сервері).
  • ❌ «POST принципово неможливо зробити ідемпотентним» (на рівні застосунку реалізується через патерн Idempotency-Key).
  • ❌ «Safe та Idempotent — це синоніми» (Safe не змінює дані зовсім, Idempotent змінює, але стійкий до повторів).
  • ❌ «PATCH завжди ідемпотентний, як і PUT» (PATCH з операціями add до списку або інкрементом лічильника є неідемпотентним).

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