1. Stateless, safe, idempotency: повтори запитів
Якщо ви ще не стикалися з повторними запитами, вітаю: або ви тільки почали, або ваш інтернет настільки стабільний, що провайдер уже давно має поставити вам пам’ятник. У реальності один і той самий запит легко надсилається повторно — через тайм-аут, через «подвійний клік» або через автоматичний повтор у клієнтській бібліотеці. І саме тут три ідеї HTTP перестають бути «теорією з підручника» та стають практичним ременем безпеки.
Уявіть сцену: клієнт надіслав запит, сервер виконав роботу, але відповідь десь загубилася. Клієнт не знає, чи дія виконалася, і вирішує повторити запит. Питання просте: чи безпечний повтор? Чи він нічого не зламає? Чи не створить дублікат? Чи не перетворить «ще раз те саме» на подвійний результат?
Щоб не ворожити на кавовій гущі (яка, до речі, теж інколи «падає по тайм-ауту»), HTTP дає нам три властивості:
- stateless допомагає зрозуміти, що сервер не пам’ятає попередній крок розмови, і кожен запит має бути самодостатнім.
- safe methods допомагають відрізнити читання від зміни стану, щоб випадковий повтор, prefetch або кешування не перетворювалися на зміну даних.
- idempotency допомагає заздалегідь розуміти, що буде, якщо повторити запит, і чи може клієнт робити це без страху.
До цього ми розбирали форму HTTP-обміну: запит, відповідь, статус, заголовки, формат даних. Але щойно в мережі з’являються тайм-аути та повтори, однієї форми вже недостатньо. Потрібно розуміти, що саме метод обіцяє щодо поведінки: чи змінює він стан, чи можна його повторювати і чи має сервер пам’ятати попередній крок клієнта.
2. Stateless: кожен запит самодостатній
Слово stateless інколи звучить так, ніби сервер має бути «без пам’яті» і жити, як золота рибка: побачив запит — відповів — забув. Це майже завжди плутає новачків. На практиці stateless — не про те, чи зберігає сервер задачі в пам’яті, а про те, чи зберігає він контекст розмови між запитами. І для проєктування API це критично.
Stateless: контекст запиту, а не «сервер нічого не зберігає»
Давайте одразу знешкодимо головний міф: stateless не означає «у сервера взагалі немає стану». Сервер може зберігати задачі, коментарі, вкладення — що завгодно (у нашому навчальному проєкті це сховище в пам’яті). Stateless означає інше: кожен конкретний HTTP-запит має містити все, що потрібно для його обробки, і сервер не має залежати від попереднього запиту цього клієнта як від кроку діалогу.
Зручно думати так: стан ресурсу (наприклад, задачі) — це нормально та очікувано. А от стан сесії взаємодії — «клієнт зараз дивиться список задач, фільтр у нього такий-то, він на сторінці 3» — це вже пам’ять розмови. І класичний stateless HTTP припускає, що такої пам’яті між запитами немає.
Невелика аналогія: ви можете зберігати вдома продукти в холодильнику (стан ресурсів), але це не означає, що кур’єр має пам’ятати, що ви замовляли вчора (стан діалогу). Кожне нове замовлення має містити адресу та список товарів, інакше кур’єр починає «здогадуватися» — а в IT вгадування зазвичай закінчується баг-репортом.
Stateless на пальцях: один клієнт, два запити
Зараз зробимо найкорисніший «руйнівник ілюзій». Візьмемо два запити від одного й того самого клієнта. У першому він просить список задач зі статусом TODO. У другому — просто список задач.
# Важливо: фільтр явно передається в цьому запиті й не «приклеюється» до наступних
GET /api/v1/tasks?status=TODO HTTP/1.1
Accept: application/json
# Важливо: тут фільтра немає — сервер не зобов’язаний «пам’ятати», що хвилину тому був TODO
GET /api/v1/tasks HTTP/1.1
Accept: application/json
Іноді новачок думає: «Але ж це один користувач, він щойно запросив TODO, отже, другий запит має “пам’ятати фільтр”». HTTP на це відповідає: «Не має. І взагалі, я протокол, а не ваша особиста записна книжка».
Якщо в другому запиті немає status=TODO, сервер не зобов’язаний (і зазвичай не буде) «підтримувати» фільтр. Не тому, що сервер шкідливий, а тому, що так контракт зрозуміліший: усе, що важливо, має бути явно вказано в запиті.
Якщо зовсім спростити, stateless — це коли не можна сказати серверу: «ну ти ж розумієш, про що я». Доводиться говорити все цілком. Так, навіть якщо вам здається, що ви вже «обговорювали» це хвилину тому.
Stateless і Task Tracker API: фільтри, сторінки, сортування
У проєкті Task Tracker API у нас буде багато сценаріїв, які виглядають як «одна розмова»: спочатку ви просите список, потім уточнюєте фільтр, потім перемикаєте сторінку. І тут stateless змушує проєктувати контракт так, щоб кожен запит був самодостатнім.
Наприклад, клієнт може запросити задачі зі статусом IN_PROGRESS і одразу певну сторінку:
# Важливо: і фільтр, і пагінація передаються явно в кожному запиті
GET /api/v1/tasks?status=IN_PROGRESS&page=0&size=20 HTTP/1.1
Accept: application/json
Якщо за секунду клієнт попросить наступну сторінку, він не має розраховувати, що сервер «пам’ятає» status=IN_PROGRESS. Отже, коректний запит виглядає так:
GET /api/v1/tasks?status=IN_PROGRESS&page=1&size=20 HTTP/1.1
Accept: application/json
Так, це трохи багатослівніше. Зате контракт не перетворюється на телепатію.
І ось тут з’являється важливий практичний висновок: stateless робить API простішим для масштабування, тестування та розуміння. Будь-який запит можна взяти окремо — з логів, із .http-файла, з history у Postman — і зрозуміти, що він означає, не піднімаючи попередній розділ роману.
3. Safe methods: читання без зміни стану
Після stateless зазвичай виникає ще одне питання: «Окей, запит самодостатній. А чи можна його повторювати без страху, що щось зміниться?» Тут на сцену виходить поняття safe methods. Це не про безпеку в сенсі secure / not secure, а про те, чи змінює операція стан на сервері. І це напряму впливає на те, які методи ми обираємо для яких дій.
Що означає «safe» (і чому це не «безпечний»)
Safe method — це HTTP-метод, який за своєю семантикою призначений для читання і не має змінювати стан ресурсу на сервері. Найголовніший представник — GET. Також до safe зазвичай відносять HEAD і OPTIONS.
Важливо не переплутати: safe не означає «безпечний із погляду доступу». Безпека — тобто хто може читати й писати — це окрема тема. Safe означає «не змінює стан». Тобто GET /api/v1/tasks може бути повністю небезпечним із погляду доступу, якщо там приватні дані, але він усе одно має бути safe за змістом: не має створювати нову задачу і не має змінювати існуючу.
Ще один нюанс, який часто дивує: safe не забороняє побічні технічні ефекти на кшталт логів, метрик, збільшення лічильника запитів або заповнення кешу. Це внутрішня кухня сервера, яка не має змінювати сенс ресурсу для клієнта. Коли ми говоримо «не змінює стан», ми маємо на увазі стан ресурсів, доступних через API, а не те, що сервер не має права записати рядок у лог.
Як ламають safe-семантику (і чому це боляче клієнту)
Найтоксичніша звичка (так, токсичніша за «поставлю пароль 1234, я ж у dev») — робити зміни через GET, тому що «так зручно відкрити в браузері». Наприклад:
# Поганий приклад: GET має бути safe, а тут відбувається зміна стану (видалення)
GET /api/v1/tasks/42/delete HTTP/1.1
або
# Поганий приклад: GET має бути safe, а тут відбувається зміна стану (завершення)
GET /api/v1/tasks/42/complete HTTP/1.1
Навіть якщо ви поки що не обговорюєте REST-дизайн, проблема тут суто на HTTP-рівні: GET має бути safe, а ці запити явно змінюють стан.
Чому це реально небезпечно, а не просто «причіпки стандартів»:
- Проксі або браузер можуть кешувати GET. А кешування — нормальна оптимізація для читання. Але якщо GET змінює стан, кеш перетворюється на «рандомізатор поведінки».
- Клієнтські системи можуть робити prefetch (попереднє завантаження) GET-посилань. Тобто ви просто показали десь посилання, а браузер вирішив «підготувати його наперед». І раптом задача «видалилася сама».
- Деякі автоматичні роботи та сканери теж люблять переходити за GET-посиланнями, особливо якщо вони десь «засвітилися». Якщо GET змінює стан, ви буквально даєте роботі кнопку «зламай мені дані».
Тож правило просте й суто практичне: якщо ви бачите API, де GET щось змінює, це не «цікава архітектурна знахідка», а майбутнє джерело нічних дзвінків.
Safe-методи в нашому проєкті: приклади коректних GET
У контексті Task Tracker API safe-запити — це запити читання. Наприклад, прочитати список задач або одну задачу за ідентифікатором — класика.
# Safe: читання списку задач не має змінювати стан ресурсів
GET /api/v1/tasks HTTP/1.1
Accept: application/json
# Safe: читання конкретної задачі не має нічого «підкручувати» на сервері
GET /api/v1/tasks/42 HTTP/1.1
Accept: application/json
Навіть якщо ви надішлете їх 10 разів поспіль, ви очікуєте, що задачі від цього не зміняться. Так, відповіді можуть відрізнятися з часом, наприклад хтось інший оновив задачу або ви самі змінили її іншим запитом, але сам GET не має бути причиною зміни.
Якщо зовсім приземлитися: safe — це коли ви можете читати, не боячись, що «читання зламало об’єкт». У програмуванні ми це інтуїтивно любимо: виклик getTitle () не має раптово робити setTitle ("зламано"). В HTTP — рівно та сама ідея, тільки на рівні мережі.
4. Idempotency: повторення запиту та підсумковий стан
Якщо safe говорить «запит не змінює стан», то idempotency відповідає на хитріше питання: «А якщо запит усе ж змінює стан — що буде під час повтору?» І ось тут HTTP знову намагається врятувати нас від хаосу, який виникає через тайм-аути, повтори та «ой, я натиснув двічі». Ідемпотентність звучить грізно, але по суті це просто «повтор не погіршує».
Ідемпотентність: формулюємо без магії
Idempotent method — це такий метод, за якого повторення одного й того самого запиту приводить до одного й того самого підсумкового стану на стороні сервера.
Зверніть увагу на слова «підсумкового стану». Це важливіше, ніж «однакова відповідь». Відповіді можуть відрізнятися дрібницями, часом, заголовками. Але сенс у тому, що після N повторів сервер опиняється в тому самому стані, що й після 1 повтору.
Ідемпотентно: «Зробіть так, щоб було ось так»
Не ідемпотентно: «Повторіть ту саму дію»
- PUT — це зазвичай «зроби так, щоб ресурс виглядав ось так». Повторювати можна.
- DELETE — зазвичай «зроби так, щоб ресурсу більше не було». Повторювати можна.
- POST — часто «створи новий ресурс». Повторити — означає створити ще один. І ось тут починаються веселощі з дублікатами.
І ще один дуже важливий анти-міф: ідемпотентність у HTTP — це не «завжди однаковий статус і body». На практиці у повторів можуть бути різні відповіді, але контракт усе одно має залишатися осмисленим і передбачуваним.
PUT і DELETE як наочні приклади ідемпотентності
Щоб відчути ідемпотентність, найкраще дивитися на PUT і DELETE. Вони як навчальні приклади — майже ідеальні.
PUT у простому вигляді означає «замінити представлення ресурсу на ось це». Припустімо, ми хочемо зробити так, щоб задача 42 мала title "Write docs".
# Ідемпотентно: повтор PUT приводить ресурс до того самого стану (а не "додає ще один title")
PUT /api/v1/tasks/42 HTTP/1.1
Content-Type: application/json
{"title":"Write docs"}
Якщо ви надішлете цей запит ще раз, підсумковий стан ресурсу все одно буде: title = "Write docs". Жодного «другого Write docs» не зʼявиться. Це й є ідемпотентність.
DELETE означає «зроби так, щоб ресурсу не було». Наприклад:
# Ідемпотентно за підсумковим станом: після будь-якої кількості повторів ресурс залишається видаленим
DELETE /api/v1/tasks/42 HTTP/1.1
Перший DELETE видалить задачу. Другий DELETE… а от тут уже починається цікава частина дизайну. За змістом підсумковий стан однаковий: задачі немає. Але як відповідати — є варіанти. Хтось повертає 404 Not Found (мовляв, уже нічого видаляти), хтось — 204 No Content (мовляв, результат той самий: ресурсу немає). Важливо, що повтор не має призводити до «ще більшого видалення», бо більше видаляти вже нічого. Ресурс уже відсутній.
Внутрішнє «відчуття» можна перенести на Java: коли ви виконуєте map.remove(key), другий remove не «видаляє щось ще». Він або поверне null, або false, але стан map після другого разу не стане «ще більш видаленим».
Чому POST зазвичай не ідемпотентний (і що з цього випливає)
POST найчастіше означає «створи новий ресурс усередині колекції». Тому повтор того самого POST зазвичай означає створення ще одного ресурсу.
# Зазвичай не ідемпотентно: повтор POST може створити дублікат ресурсу
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"Write docs"}
Якщо клієнт надішле це двічі, сервер цілком чесно може створити дві різні задачі з різними id. І це не «помилка сервера». Це очікувана семантика POST.
Чому це важливо? Тому що повторні запити справді трапляються. Клієнт міг не отримати відповідь через тайм-аут і повторити запит. Користувач міг двічі натиснути кнопку «Create». У нормальному світі, тобто у світі, де мережа не ідеальна, POST — це зона ризику дублікатів.
Іноді ви можете зустріти рішення, які роблять POST ідемпотентним через додаткові механізми, наприклад через idempotency key у заголовку або через id, який клієнт генерує сам. Але це вже тема рівня «як боротися з дублікатами в розподіленій системі» і може бути надлишковою для першого знайомства. У межах цієї лекції важливо зрозуміти сам принцип: POST не можна автоматично вважати ідемпотентним, і клієнту потрібно бути обережним із повтором.
До речі, зафіксуймо одну корисну думку: коли ви обираєте метод, ви обираєте не тільки «як красиво виглядає API», а й які властивості отримає клієнт. Повторні запити з боку клієнта — це не фантазія, а реальність. І метод напряму впливає на те, чи буде такий повтор безпечним.
Мінітаблиця: safe vs idempotent для популярних методів
Щоб не тримати все в голові як вірш, зручно мати маленьку матрицю. Вона не про «єдину істину», а про типову семантику в більшості API.
| HTTP-метод | Safe | Idempotent | Інтуїтивний сенс |
|---|---|---|---|
| GET | так | так | прочитати представлення ресурсу чи колекції |
| HEAD | так | так | прочитати лише метадані (без тіла) |
| OPTIONS | так | так | дізнатися «що можна» (технічно) |
| PUT | ні | так | привести ресурс до вказаного стану |
| DELETE | ні | так | привести світ до стану «ресурсу немає» |
| POST | ні | зазвичай ні | створити новий ресурс / запустити обробку |
| PATCH | ні | не гарантовано | частково змінити ресурс (може бути ідемпотентним, але це залежить від контракту) |
І ще одна коротка фраза, яка часто рятує від плутанини: safe майже завжди означає idempotent, тому що якщо метод нічого не змінює, то й повтор нічого не змінює. Але idempotent не означає safe, тому що DELETE змінює стан, хоча повторювати його загалом можна.
5. Як ці властивості впливають на контракт
Три поняття — stateless, safe methods, idempotency — звучать теоретично, доки не спробуєш уявити реального клієнта, який «живе» в інтернеті: з тайм-аутами, повторними запитами, поганим Wi‑Fi та користувацькою поведінкою «натиснув, але не впевнений, натиснув ще раз». У цей момент раптом стає зрозуміло: ці властивості — не філософія, а інструкція, як зробити API, яким можна користуватися.
Клієнтські ретраї, тайм-аути та подвійні кліки: звідки беруться повтори
Найчастіше джерело повторів — банальний тайм-аут: клієнт надіслав запит і не отримав відповідь достатньо швидко. Клієнту потрібно прийняти рішення: чекати далі чи повторити. І іноді клієнтська бібліотека або gateway між клієнтом і сервером робить повтор автоматично.
flowchart LR
C[Клієнт] -->|"1) Запит"| S[Сервер]
S -->|"2) Відповідь"| C
C -->|"Тайм-аут: відповіді не видно"| C
C -->|"3) Повтор запиту"| S
На практиці сервер міг успішно обробити перший запит, але відповідь «не дійшла». Тоді повтор — це вже не «повторити спробу», а «зробити те саме вдруге». І тут ми знову впираємося у властивості методів.
- GET зазвичай можна повторювати спокійно: метод safe та idempotent.
- PUT і DELETE зазвичай також можна повторювати спокійно в сенсі підсумкового стану: вони не safe, але ідемпотентні.
- POST повторювати небезпечно: можна створити дублікат.
А stateless додає ще одну важливу думку: якщо ваш сценарій складається з кількох запитів, кожен запит має містити всю потрібну інформацію. Повтор запиту не має залежати від того, що було «на крок раніше».
Практична “матриця рішень” для методів у Task Tracker API
Тепер пов’яжемо все це з проєктом, але суворо на рівні HTTP-сенсів, не заглиблюючись у деталі реалізації. Нехай у нас є кілька типових операцій навколо задач.
Якщо клієнт хоче отримати список задач, він робить GET /api/v1/tasks. Це safe та idempotent. Клієнт може повторювати такий запит, оновлювати сторінку, запитувати дані знову — і контракт залишається зрозумілим.
Якщо клієнт хоче отримати одну задачу, він робить GET /api/v1/tasks/{taskId}. Теж safe та idempotent.
Якщо клієнт хоче видалити задачу, він робить DELETE /api/v1/tasks/{taskId}. Це не safe, але ідемпотентно. Тобто «повторити видалення» не має перетворюватися на «видалити щось ще». Підсумковий стан однаковий: задачі немає.
Якщо клієнт хоче створити задачу, він робить POST /api/v1/tasks. Це зазвичай не ідемпотентно: повтор може створити другу задачу. Отже, клієнт має це розуміти та бути обережним із повтором. На рівні контракту це проявляється навіть у статусах (згадуємо лекцію про коди стану): створення зазвичай повертає 201 Created, і це «подія», яку не хочеться випадково отримати двічі.
І окремо, щоб закріпити різницю між safe та idempotent, корисно проговорити просту пару фраз. GET хороший тим, що його повтор не має змінювати нічого. PUT і DELETE хороші тим, що їх повтор не має погіршувати підсумковий стан. POST хороший тим, що він чесно створює нове — але саме тому повтор небезпечний.
Коли ви обираєте метод для endpoint’а, ви фактично задаєте поведінку ресурсу в очах клієнта: чи можна його безпечно читати, чи можна його повторювати, чого чекати від повтору. Тут HTTP уже не фон, а частина самого дизайну API.
6. Типові помилки під час роботи з HTTP-семантикою
Помилка №1: розуміти stateless як “сервер нічого не зберігає”.
Коли чуєш «stateless», легко уявити сервер, який не зберігає взагалі жодних даних і весь час перераховує світ заново. Це неправильно і швидко призводить до дивних суперечок на кшталт «REST неможливий, бо сервер зберігає дані». Stateless стосується контексту запиту: сервер не зобов’язаний пам’ятати попередній крок конкретного клієнта. Водночас зберігати задачі як ресурси — нормально.
Помилка №2: очікувати, що сервер “пам’ятає фільтр” або “пам’ятає сторінку”.
Дуже поширена пастка: клієнт спочатку запитує GET /tasks?status=TODO, а потім робить GET /tasks?page=1 і дивується, чому на другій сторінці вже не TODO. HTTP-взаємодія stateless: якщо фільтр важливий, він має бути в кожному запиті. Інакше сервер не зобов’язаний вгадувати, що «сторінка 1» належить до «попереднього фільтра».
Помилка №3: плутати safe та “безпечний із погляду доступу”.
Safe — це про зміну стану. Це не про те, чи має клієнт право читати дані. Можна зробити GET safe, але при цьому доступ до нього має бути обмежений (у реальних проєктах). І навпаки, можна зробити POST доступним усім (що буде поганою ідеєю), але він усе одно не стане safe. Не змішуйте «не змінює стан» і «можна всім».
Помилка №4: робити зміни через GET “тому що так простіше”.
Це той випадок, коли лінь на старті перетворюється на неочікувані баги на продакшені. Якщо GET змінює стан, ви ламаєте очікування клієнта, ламаєте можливість кешування та наражаєтеся на prefetch і автоматичні перевірки посилань. У підсумку «випадковий перегляд посилання» може стати «випадковим видаленням задачі», а це вже не інженерія, а сюжет для тривожного трилера.
Помилка №5: думати, що idempotency означає “завжди однакова відповідь”.
Повтор DELETE може повернути інший статус, наприклад спочатку 204, потім 404, і це не обов’язково порушення ідемпотентності, якщо підсумковий стан лишається «ресурсу немає». Idempotency — про підсумковий стан і сенс операції, а не про байт у байт однакове тіло відповіді. Якщо ви чекатимете «абсолютно те саме», ви самі заженете себе в кут.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ