Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке ідемпотентність?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Ідемпотентність (idempotence)** означає, що повторний виклик однієї операції дає той самий кінцевий стан, що й перший. ```js PUT /balance {balance: 200} // 3 виклики → balance: 200 кожен раз (ідемпотентно) POST /charge {amount: 100} // 3 виклики → списано 300 загалом (не ідемпотентно) ``` **Головне:** GET, PUT і DELETE є ідемпотентними за RFC 9110. POST не є безпечним для retry без idempotency key.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Ідемпотентність (idempotence)** - це властивість операції, при якій повторний виклик дає той самий кінцевий стан, що й перший. ## Теорія ### Коротко - Фарбуємо стіну в червоний: перший шар - червона стіна, другий шар - все ще червона стіна. Ось ідемпотентність. - `PUT /balance/1 {balance: 200}`, викликаний три рази, завжди повертає `balance: 200`. - `POST /charge {amount: 100}`, викликаний три рази, списує $300 загалом. Не ідемпотентно. - Правило вибору: GET, PUT, DELETE безпечні для повторних запитів. POST без додаткового захисту - ні. - У розподілених системах retry неминучий. Ідемпотентні операції роблять його безпечним. ### Швидкий приклад ```javascript // PUT - ідемпотентний: встановлює стан абсолютно app.put('/balance/:id', (req, res) => { const newBalance = req.body.balance; // {balance: 200} userBalance = newBalance; // Перезаписує, результат однаковий кожен раз res.json({ balance: userBalance }); }); // POST - не ідемпотентний: накопичує стан app.post('/charge', (req, res) => { total += req.body.amount; // +100 кожен раз: 100, 200, 300... res.json({ total }); }); // Три однакових PUT → balance: 200, 200, 200 // Три однакових POST → total: 100, 200, 300 ``` PUT перезаписує. POST додає. У цьому вся різниця. ### Що означає "той самий результат" Ідемпотентність стосується **фінального стану**, а не HTTP-відповіді. DELETE на вже видалений ресурс поверне 404, але стан однаковий: ресурсу немає. Обидва виклики досягли одного результату. RFC 9110 визначає ідемпотентні методи як ті, де "намірений ефект на сервері від кількох однакових запитів такий самий, як і від одного." Саме намірений ефект, не ідентична відповідь. ### HTTP методи та ідемпотентність | Метод | Ідемпотентний | Безпечний | Примітки | |-------|--------------|-----------|----------| | GET | Так | Так | Тільки читання, стан не змінюється | | HEAD | Так | Так | Як GET, без тіла відповіді | | PUT | Так | Ні | Повний перезапис, той самий payload = той самий стан | | DELETE | Так | Ні | Ресурс відсутній після першого виклику | | POST | Ні | Ні | Кожен раз створює новий запис | | PATCH | Залежить | Ні | Операції-інкременти порушують ідемпотентність | Безпечний означає відсутність будь-яких побічних ефектів. Ідемпотентний означає, що повторення не додає нових ефектів. Кожен безпечний метод є ідемпотентним, але не навпаки. PUT змінює стан, але повторення не змінює його далі. ### Пастка PATCH Саме тут більшість розробників помиляються. PATCH може бути ідемпотентним або ні - залежить від реалізації. Я бачив, як PATCH з операцією-інкрементом проходив code review в продакшн API, а потім спричиняв тихе пошкодження даних при retry. ```javascript // НЕПРАВИЛЬНО: цей PATCH не є ідемпотентним app.patch('/user/:id', async (req, res) => { // При повторному запиті: visits стає 2, потім 3, потім 4 await db.user.update({ where: { id }, data: { visits: { increment: 1 } } }); }); // Краще: використовувати PUT з абсолютним значенням app.put('/user/:id', async (req, res) => { const updates = req.body; // { visits: 5 } - абсолютне, не відносне await db.user.update({ where: { id }, data: updates }); }); ``` Проблема `increment: 1` - це відносна операція. Однаковий запит з однаковим payload змінює стан по-різному залежно від кількості викликів. Це робить retry небезпечним. ### Idempotency key для POST Іноді POST - правильний метод, але потрібен захист від повторних запитів. Для цього існують idempotency key. ```javascript // POST з idempotency key в стилі Stripe app.post('/charge', async (req, res) => { const idempotencyKey = req.headers['idempotency-key']; // UUID від клієнта const existing = await redis.get(`idem:${idempotencyKey}`); if (existing) return res.json(JSON.parse(existing)); // Повертаємо збережений результат const result = await chargeCustomer(req.body); // Зберігаємо результат прив'язаним до цього ключа на 24 години await redis.setex(`idem:${idempotencyKey}`, 86400, JSON.stringify(result)); res.json(result); }); ``` Stripe саме так реалізує `/v1/charges`. Клієнт генерує UUID, додає його в заголовок, сервер дедуплікує. Перший виклик обробляє платіж. Кожен наступний retry отримує оригінальну відповідь. Без подвійного списання. ### Типові помилки **Вважати, що PATCH завжди ідемпотентний.** HTTP-специфікація каже, що PATCH *може* бути ідемпотентним, якщо реалізований відповідно, але нічого не гарантує. Операції-інкременти - не ідемпотентні за визначенням. **Думати, що 204 vs 404 порушує ідемпотентність DELETE.** Другий DELETE, що повертає 404 замість 204, не порушує ідемпотентність. Ресурс відсутній в обох випадках. RFC 9110 про це однозначний: важливий намірений стан. **Вставка в базу без UNIQUE-обмеження:** ```javascript // Неправильно: retry створює дублікати в таблиці платежів db.query('INSERT INTO payments VALUES (?, ?)', [paymentId, amount]); // Правильно: PostgreSQL пропускає дублікат без помилки db.query( 'INSERT INTO payments VALUES (?, ?) ON CONFLICT (payment_id) DO NOTHING', [paymentId, amount] ); ``` Це класична проблема Kafka-консюмерів. Повідомлення оброблено, консюмер впав до коміту offset, при перезапуску обробляє те саме повідомлення ще раз. Без `ON CONFLICT` - отримаєш дублікати в таблиці. **Розраховувати на PUT в eventually-consistent системі.** При replica lag два однакових PUT на різні ноди можуть тимчасово давати різний стан. Система зрештою синхронізується, але в цьому вікні ідемпотентність не гарантована. Тому CockroachDB і DynamoDB надають умовний запис (compare-and-swap). **Вважати GET завжди стабільним.** GET ідемпотентний за дизайном, але `/users?offset=10&limit=5` може повернути різні записи, якщо між запитами були вставки. Операція ідемпотентна, але результати не стабільні. Ключі кешу мають включати повний URL. ### Де зустрічається в реальних проектах - **Stripe API**: кожен `POST /v1/charges` приймає заголовок `Idempotency-Key`. Retry протягом 24 годин повертає оригінальну відповідь. - **Kubernetes**: `kubectl delete pod my-pod`, викликаний двічі, залишає систему в одному стані. Другий виклик отримує 404, і це очікувано. - **React Query**: `useMutation` з `mutationKey` дозволяє бібліотеці дедуплікувати запити в польоті. - **AWS DynamoDB**: `PutItem` перезаписує весь елемент. Однаковий payload - однаковий результат. - **PostgreSQL**: `INSERT ... ON CONFLICT DO NOTHING` і `UPSERT` - стандартні інструменти для ідемпотентних записів у навантажених системах. ### Питання на співбесіді **Q:** Які HTTP методи є ідемпотентними за RFC 9110? **A:** GET, HEAD, PUT і DELETE. POST і PATCH не є ідемпотентними за замовчуванням, хоча PATCH можна реалізувати ідемпотентно, якщо використовувати абсолютні значення замість відносних операцій. **Q:** У чому різниця між idempotent і safe в HTTP? **A:** Safe означає відсутність будь-яких побічних ефектів (тільки читання). Idempotent означає, що повторні виклики не додають нових ефектів. GET - і те, і інше. PUT - ідемпотентний, але не safe. POST - ні те, ні інше. **Q:** Як зробити POST безпечним для retry? **A:** Додати idempotency key (UUID в заголовку), зберігати результат прив'язаним до цього UUID, при повторному запиті з тим самим ключем повертати збережений результат. Так роблять Stripe, Braintree та більшість платіжних сервісів. **Q:** Чи може PUT порушити ідемпотентність у розподіленій системі? **A:** Так. При eventual consistency replica lag може призвести до того, що два однакових PUT на різні ноди тимчасово дадуть різний стан. Умовний запис через ETag або compare-and-swap вирішує це ціною складності. **Q (senior):** Спроектуй ідемпотентний лічильник для шардованої бази даних. **A:** Зберігай поточне значення на клієнті і використовуй PUT з абсолютним цільовим значенням замість відносного інкременту. Якщо потрібен саме інкремент, обгортай у транзакцію з унікальним operation ID: перевіряй чи вже застосовано, пропускай якщо так. Це outbox pattern для лічильників. ## Приклади ### Базовий: PUT проти POST при повторному запиті ```javascript const express = require('express'); const app = express(); app.use(express.json()); let userBalance = 100; const charges = []; // Ідемпотентний: завжди встановлює задане значення app.put('/balance/:id', (req, res) => { userBalance = req.body.balance; res.json({ balance: userBalance }); }); // Не ідемпотентний: записує новий платіж кожен раз app.post('/charge', (req, res) => { charges.push({ amount: req.body.amount, at: Date.now() }); res.json({ charges }); }); // Три PUT /balance/1 {balance: 200} → завжди {balance: 200} // Три POST /charge {amount: 100} → [{...}, {...}, {...}], разом 300 ``` PUT встановлює цільовий стан. POST фіксує подію. Якщо мережа повторить PUT - нічого страшного. Якщо повторить POST - клієнт заплатить тричі. ### Середній: Idempotency key для POST-запитів ```javascript const express = require('express'); const app = express(); app.use(express.json()); const processedKeys = new Map(); // У продакшені: Redis з TTL app.post('/payment', async (req, res) => { const idempotencyKey = req.headers['x-idempotency-key']; if (!idempotencyKey) { return res.status(400).json({ error: 'Заголовок x-idempotency-key обов\'язковий' }); } // Повертаємо збережений результат, якщо вже бачили цей ключ if (processedKeys.has(idempotencyKey)) { return res.json(processedKeys.get(idempotencyKey)); } const result = { paymentId: `pay_${Date.now()}`, amount: req.body.amount, status: 'charged' }; processedKeys.set(idempotencyKey, result); res.json(result); }); // Клієнт: POST /payment з заголовком x-idempotency-key: uuid-abc-123 // Retry з тим самим ключем → той самий paymentId, без подвійного списання // Новий ключ → новий запис про платіж ``` Ключова деталь: клієнт сам генерує idempotency key. Якщо клієнт не знає, чи дійшов перший запит до сервера, він надсилає той самий ключ ще раз. Дедуплікацією займається сервер. ### Senior: Ідемпотентний консюмер у черзі повідомлень ```javascript // Kafka-консюмер - повідомлення може прийти більше одного разу (at-least-once) async function processPaymentMessage(message) { const { paymentId, amount, userId } = JSON.parse(message.value); // Використовуємо paymentId як природний idempotency key const existing = await db.payments.findUnique({ where: { paymentId } }); if (existing) { console.log(`Пропускаємо дублікат: ${paymentId}`); return; // Вже оброблено, комітимо offset і йдемо далі } await db.$transaction(async (tx) => { await tx.payments.create({ data: { paymentId, amount, userId } }); await tx.users.update({ where: { id: userId }, data: { balance: { decrement: amount } } }); }); } // Без цієї перевірки: at-least-once delivery = потенційні подвійні платежі // З цією перевіркою: at-least-once delivery стає effectively-once обробкою ``` Цей патерн зустрічається в кожній системі, що використовує Kafka, SQS або RabbitMQ. Гарантія at-least-once delivery означає, що консюмер рано чи пізно побачить те саме повідомлення двічі. Перевірка ідемпотентності - це те, що відокремлює систему, яка обробляє це коректно, від тієї, яка відправляє клієнту два чеки.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.