Клієнт надіслав запит. Сервер виконав операцію. Потім з’єднання обірвалося до того, як відповідь дійшла назад.
Клієнт не знає, що сталося.
Для нього однаково можливі два варіанти: сервер не отримав запит або сервер уже все виконав, але відповідь загубилась.
Найприродніша реакція - повторити запит.
І саме тут з’являється питання ідемпотентності: як серверу зрозуміти, що це повтор уже виконаної операції, а не нова операція з такими самими даними?
Втрачена відповідь не означає невиконану операцію
Клієнт Сервер | | |------ запит --------->| | | | операція | виконана | | |<----- відповідь ------X | | відповідь втрачена | |------ повтор -------->|Якщо сервер просто виконає другий запит ще раз, результат залежатиме від типу операції.
Для читання це часто безпечно.
Для зміни стану повтор може означати другий запис, друге списання, друге повідомлення або інший дубльований побічний ефект.
Тому клієнтський повтор не повинен автоматично означати повторне виконання.
Я не визначаю повтор лише за однаковими даними
Запит A amount = 100currency = UAH Запит B amount = 100currency = UAH Однакові дані можуть означати: 1. повтор запиту A2. нову окрему операціюПорівняння всіх полів запиту здається простим способом знайти повтор.
Але воно змішує дві різні речі: однакові дані й однаковий намір.
Користувач може законно виконати дві однакові операції поспіль.
Тому серверу потрібен окремий спосіб пов’язати повторний мережевий запит із тією самою логічною операцією.
Повтор повинен зберігати ідентичність логічної операції
Нова дія користувача | vнова логічна операція | +--> перша спроба | +--> повтор після timeout | +--> ще один повтор Усі спроби належатьодній операції. Для мене це головна модель.
Запит по мережі - лише спроба доставити команду.
Одна логічна операція може мати одну, дві або більше таких спроб.
Поки клієнт повторює ту саму операцію, він повинен зберігати її стабільний ідентифікатор.
Коли користувач справді починає нову операцію, створюється нова ідентичність.
Сервер перевіряє, чи бачив цю операцію раніше
$result = $operations->find( $request->operationIdentifier(),); if ($result !== null) { return $result->response();} $result = $service->execute( $request->input(),); $operations->remember( $request->operationIdentifier(), $result,);Це навмисно спрощений приклад.
Сенс не в конкретному класі чи форматі ідентифікатора.
Сенс у тому, що повтор уже відомої операції не запускає її бізнесову дію ще раз.
Замість цього сервер повертає результат, який відповідає першому успішному виконанню.
Та сама ідентичність з іншими даними - це помилка
Перша спроба operation = Xamount = 100 Повтор operation = Xamount = 100 -> той самий намір Інший запит Стабільний ідентифікатор повтору не повинен перетворюватися на спосіб випадково виконати іншу дію.
Тому я також перевіряю, що повтор відповідає початковому наміру.
Якщо та сама ідентичність приходить уже з іншими суттєвими даними, це не повтор.
Я вважаю такий запит суперечливим і не запускаю нову операцію під старою ідентичністю.
Повтор може прийти, поки перша спроба ще виконується
Запит A | +--> операція почалась | | Запит B | | | +--> та сама операція | +--> перша ще не завершена | +--> завершенняЩе один важливий випадок - два запити можуть перетнутися в часі.
Перший уже почав виконання, але ще не встиг завершитися. У цей момент приходить повтор.
Якщо обидва запити незалежно вирішать "такої операції ще немає" і підуть далі, ідемпотентність зламається.
Тому створення запису про операцію або інша точка, яка встановлює право першого виконання, повинна бути захищена від конкурентного доступу.
Унікальність у базі дає останню гарантію від подвійного створення
Schema::create( 'processed_operations', function (Blueprint $table): void { $table->id(); $table->string( 'operation_identifier', ); $table->unique( 'operation_identifier', ); });Перевірка в коді корисна, але дві конкурентні спроби можуть одночасно побачити однаковий стан.
Тому для самого факту "ця логічна операція вже зареєстрована" я хочу мати і гарантію на рівні бази даних.
Унікальне обмеження не визначає всю поведінку повтору, але не дозволяє двом конкурентним запитам одночасно стати першим виконанням.
Повтору потрібен стабільний результат, а не нове виконання
Після успішного завершення я зберігаю достатньо інформації, щоб наступний повтор не запускав операцію знову.
Це не обов’язково означає зберігати повну HTTP-відповідь у сирому вигляді.
Можна зберегти стабільний результат операції і вже з нього відновити відповідь.
Головне, щоб повтор не створював новий побічний ефект лише тому, що клієнт не отримав попередню відповідь.
Ідемпотентність має охоплювати побічні ефекти, а не лише основний запис
Можна правильно захистити основний запис і все одно отримати дубль в іншому місці.
Наприклад, повтор не створив другу сутність, але вдруге відправив повідомлення або повторно викликав зовнішню систему.
Тому я дивлюся на операцію цілком:
- що змінюється в базі;
- які події створюються;
- які зовнішні виклики відбуваються;
- які повідомлення можуть бути відправлені.
Безпечний повтор означає, що весь важливий результат операції не дублюється.
Не кожен запит потребує окремого механізму ідемпотентності
Я не додаю такий механізм автоматично до кожного endpoint.
Для звичайного читання повтор часто вже безпечний.
Особливо важливо це для операцій, де повтор може створити новий побічний ефект і де клієнт реально може повторити запит після невизначеного результату.
Тобто я починаю не з шаблону, а з питання: що станеться, якщо відповідь загубиться після успішного виконання?
Для складніших випадків я розрізняю стан операції
Невідома операція | vприйнята до виконання | vвиконується | +--> успіх | +--> контрольована помилка Повтор: успіхНе кожен проєкт цього потребує.
Але сама модель корисна: повтор завершеної операції, повтор операції в процесі та нова операція - це три різні ситуації.
Якщо їх не розрізняти, код дуже швидко перетворюється на набір випадкових перевірок.
Що я перевіряю тестами
1. Перша спроба -> операція виконується один раз 2. Повтор після успіху -> нова операція не створюється -> повертається попередній результат 3. Дві конкурентні спроби -> лише одна стає першим виконанням 4. Та сама ідентичність з іншими даними -> запит відхиляється Особливо важливий для мене останній тест.
Ідемпотентність не повинна перетворювати однакові дані на довічну заборону повторити дію.
Якщо користувач свідомо почав нову операцію, вона повинна залишатися новою навіть тоді, коли її параметри повністю збігаються з попередньою.
Що я перевіряю перед додаванням ідемпотентності
Перед реалізацією я ставлю кілька питань:
- Чи може клієнт не знати результат після втрати відповіді?
- Чи безпечно виконати операцію вдруге?
- Що саме визначає одну логічну операцію?
- Як відрізнити повтор від нового наміру?
- Що буде, якщо дві спроби прийдуть одночасно?
- Чи є гарантія унікальності на рівні бази?
- Що повернути після повтору вже завершеної операції?
- Чи всі побічні ефекти також захищені від дублювання?
Ідемпотентність захищає намір, а не просто HTTP-запит
Для мене найважливіше в ідемпотентності - не сам механізм повтору.
Важливо правильно відокремити логічну операцію від окремої спроби доставити її серверу.
Втрачена відповідь може змусити клієнта повторити запит. Це нормально.
Сервер повинен розпізнати, що намір не змінився, не створити другий побічний ефект і повернути стабільний результат.
А коли користувач справді починає нову дію, навіть із такими самими даними, система повинна дозволити нову операцію.
Повторювати можна мережеву спробу. Бізнесова операція від цього не повинна автоматично ставати новою.
Ідемпотентність потрібна не для того, щоб заборонити повтори. Вона потрібна, щоб повтор тієї самої операції залишався тією самою операцією.



