У попередній темі ми розібрали
сам протокол: як складається запит, що означають коди станів, навіщо заголовки й таймаут.
Уміння вже є — requests.get(...) приносить відповідь. Лишилося головне питання:
що в цій відповіді лежить. Бо сторінка, зроблена для людських очей, і дані,
зроблені для програми, — це дві дуже різні речі, хоч приходять вони однаковим способом.
Наскрізний приклад — той самий, що і в темі про CSV і JSON: маленька кавʼярня з меню на пʼять позицій. Тільки тепер меню не лежить у файлі поруч, а живе на сервері, і ми ходимо по нього по мережі.
127.0.0.1. Це не заглушка й не імітація: requests робить
справжнє TCP-зʼєднання, отримує справжні коди станів і справжні заголовки. Відрізняється
лише адреса — і те, що результат буде той самий і сьогодні, і за рік, і без інтернету
взагалі.list.append() теж API. Але в цій темі йдеться саме
про вебʼAPI — інтерфейс, до якого ходять по HTTP і який відповідає даними, а не
версткою.МотивСторінка для очей, дані для програм
Уяви, що кавʼярня має сайт із меню. Ти відкриваєш його — і бачиш заголовок, назви напоїв, ціни в гривнях, гарний шрифт. Усе це прийшло від сервера одним шматком тексту: HTML. Браузер прочитав розмітку й намалював сторінку.
Тепер уяви, що меню потрібне програмі: щоранку рахувати середню ціну кави й записувати її в таблицю. Програмі не потрібні шрифти, відступи й слово «грн» — їй потрібні назва і число. Але в HTML вони заховані між тегами: щоб дістати ціну, доведеться знайти потрібний тег, вирізати текст, відрізати «грн» і перетворити рядок на число. Чотири кроки там, де вистачило б двох — розібрати відповідь і взяти поле.
Саме тому в багатьох сервісів є друга адреса — та, що віддає ті самі дані у форматі, призначеному для програм. Найчастіше це JSON. Подивись, наскільки різна робота виходить в одному й другому випадку:
Одні дані, дві обгортки
Ліворуч — те, що надіслав сервер. Праворуч — що доведеться зробити, щоб дістати ціну еспресо.
Ось звідки береться правило, з якого починається вся тема: якщо для потрібних даних є API — бери API. Розбирати сторінку доводиться лише тоді, коли API немає; цим ми займемось у наступній темі.
Розбірr.json() і де він падає
Відповідь від сервера завжди приходить як послідовність байтів. requests
дає до неї три доступи: r.content — сирі байти, r.text — ті самі
байти, перетворені на рядок, і r.json() — рядок, розібраний як JSON у звичайні
обʼєкти Python.
Третій — це рівно те, що ми робили в темі 22 функцією json.loads(), тільки
без зайвого рядка. Метод бере r.text, віддає його розбирачу JSON і повертає
результат: словник, список, число — залежно від того, що надіслав сервер.
import requests
r = requests.get(f"{BASE}/menu", params={"page": 1}, timeout=5)
data = r.json() # текст відповіді стає словником
print(type(data)) # <class 'dict'>
print(data["total"]) # 5
print(data["items"][0]["name"]) # Еспресо
print(data["items"][0]["price"] + 5) # 30 — це вже число, не рядок
Останній рядок і є вся суть. З HTML ти отримав би рядок "25 грн", і додати
до нього пʼятірку не вийшло б — Python кинув би TypeError. З JSON число
приходить числом, бо у формату є типи: рядок у лапках, число без лапок, true
і false, null. Розбирач їх розрізняє й повертає
str, int, bool і None.
Пастка: сервер відповів не тим, чого ти чекав
r.json() нічого не перевіряє наперед. Він просто пробує розібрати текст —
і якщо текст не JSON, кидає виняток:
r = requests.get(f"{BASE}/menu.html", timeout=5)
r.json()
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Повідомлення виглядає загадково, а насправді воно точне: розбирач стояв на першому
символі першого рядка й чекав початку значення JSON, а побачив <.
Найчастіша причина саме така: сервер віддав HTML замість даних. Не з
підступності — просто щось пішло не так, і замість відповіді API прилетіла сторінка
помилки: «404, такої адреси немає», «502, шлюз не відповідає», сторінка входу в систему.
Усе це — коректні HTTP-відповіді, тільки з версткою всередині.
Тому не сподівайся, а перевіряй. Сервер сам каже, що він надіслав, — у заголовку
Content-Type:
kind = r.headers.get("Content-Type", "")
if kind.startswith("application/json"):
data = r.json()
print("позицій у відповіді:", len(data["items"]))
else:
# не гадаємо, а друкуємо, що саме прийшло — це і є діагностика
print("сервер віддав не JSON, а", kind)
print(r.text[:120])
Другий варіант — обгорнути виклик у try, як ми робили
в темі про винятки. Підходи не конкурують:
Content-Type каже, чого чекати, а try ловить випадок,
коли заголовок обіцяв JSON, а тіло виявилося обірваним. У серйозному коді роблять обидва.
204 No Content тіла не має взагалі — і r.json() на ній теж
впаде, хоч сервер відпрацював ідеально. Перед розбором корисно глянути не лише на тип,
а й на довжину: if r.status_code != 204 and r.content:.БудоваРесурс і метод
Тепер про те, як API влаштовані зсередини. Найпоширеніший стиль зветься REST (representational state transfer). За ним стоїть багато теорії, але для роботи достатньо однієї ідеї, і вона напрочуд проста:
Тобто адреса називає ресурс — річ, про яку йдеться:
/menu — усе меню, /menu/2 — друга позиція,
/orders/4417/items — позиції замовлення 4417. А те, що з цією річчю
робити, каже метод запиту: GET, POST, PUT,
PATCH, DELETE. Адреси на кшталт /getMenu або
/deleteItem?id=2 теж трапляються, але це вже інший стиль — там дієслово
запхали в шлях, і метод перестає щось означати.
Два слова, які треба знати про методи, звучать страшно й означають дуже конкретні речі.
Безпечний (safe) метод нічого не змінює на сервері. Ти можеш викликати
його скільки завгодно разів, і світ лишиться таким самим. Безпечний тільки
GET (а ще HEAD і OPTIONS). Побутова аналогія:
подивитись на цінник — безпечно; узяти товар із полиці — ні.
Ідемпотентний (idempotent) метод можна повторити, і стан
лишиться тим самим, що після першого разу. Це не те саме, що безпечний: видалення змінює
світ, але видалити ту саму позицію двічі — те саме, що видалити один раз, її просто вже
немає. Аналогія: вимикач світла в положенні «вимкнено» — скільки не тисни, світло далі
вимкнене. А от POST — це кнопка «додати ще одну позицію»: натиснув двічі —
отримав дві.
Метод і наслідок
Той самий запит надсилаємо один раз і другий. Дивись на третю колонку — саме вона розрізняє методи.
| метод | що робить | безпечний | ідемпотентний |
|---|---|---|---|
| GET | прочитати ресурс | так | так |
| POST | створити новий ресурс | ні | ні |
| PUT | замінити ресурс цілком | ні | так |
| PATCH | змінити частину полів | ні | як домовиться сервіс |
| DELETE | видалити ресурс | ні | так |
Обережне формулювання в рядку PATCH стоїть чесно: стандарт його
ідемпотентності не гарантує, і все вирішує тіло запиту. Якщо тіло каже «постав ціну
50» — повтор нічого не змінить. Якщо тіло каже «додай до ціни 5» — кожен повтор зробить
дорожче. Формат тіла придумує сервіс, тому за PATCH завжди читай
документацію.
СтворенняСтворення запису
Дотепер ми тільки читали. Створення нового запису виглядає так: метод
POST, адреса колекції (не окремого елемента), а нові дані —
у тілі запиту.
r = requests.post(f"{BASE}/menu",
json={"name": "Какао", "price": 40},
timeout=5)
print(r.status_code) # 201
print(r.headers["Location"]) # /menu/99
print(r.json()) # {'id': 99, 'name': 'Какао', 'price': 40, ...}
Три речі в цій відповіді варті окремої уваги.
Код 201 Created, а не 200 OK. Обидва означають
успіх, але 201 каже більше: зʼявився новий ресурс. Якщо сервер відповів
200, найімовірніше нічого не створилося, а тобі просто щось повернули.
Заголовок Location. У ньому адреса щойно створеного
ресурсу. Це важливо: id вигадує сервер, а не ти, і Location —
офіційний спосіб його дізнатися. Тіло відповіді теж зазвичай містить створений обʼєкт,
але не в кожного API.
Аргумент json=, а не data=. Це різні речі,
і плутанина між ними — одна з найчастіших причин відповіді 400 на цілком
правильні дані:
json= — тіло у форматі JSON
requests.post(url,
json={"name": "Какао"})
тіло запиту:
{"name": "Как..."}
заголовок став сам:
Content-Type: application/jsondata= — тіло як поля форми
requests.post(url,
data={"name": "Какао"})
тіло запиту:
name=%D0%9A%D0%B0%D0%BA%D0%B0...
заголовок став сам:
Content-Type: application/x-www-
form-urlencodedjson= робить дві дії однією: перетворює словник Python на текст JSON
і ставить заголовок Content-Type: application/json. Другу половину
легко проґавити, і саме через неї сервер, який чекав JSON, відповідає «не розумію тіло».
data= зі словником кодує його як поля HTML-форми — так надсилають дані
звичайні сторінки з полями вводу, і деякі API справді хочуть саме цього. Дізнатись,
котрий варіант потрібен, можна лише з документації.
ВідмоваКоли сервер каже «ні»
Спробуймо створити позицію без ціни:
r = requests.post(f"{BASE}/menu", json={"name": "Какао"}, timeout=5)
print(r.status_code) # 422
print(r.json()) # {'error': 'бракує обовʼязкових полів', 'missing': ['price']}
Коди станів ми розбирали в попередній темі, тут важлива одна межа, і вона проходить між четвіркою й пʼятіркою.
4xx — помилка на твоєму боці. Запит дійшов, сервер його
зрозумів і відмовився виконувати: бракує поля, невірний токен, немає такого ресурсу.
Повторювати такий запит без змін безглуздо — відповідь буде та сама завтра й через рік.
Треба виправити запит.
5xx — помилка на боці сервера. Запит був нормальний, але
щось зламалося там: перевантаження, впала база даних, оновлення посеред дня. Твій код тут
ні до чого, і той самий запит через кілька секунд цілком може спрацювати. Тут повтор
доречний — про нього окремий розділ нижче.
Код 422 Unprocessable Content — типова відповідь на «синтаксично все
гаразд, а по суті ні»: тіло є, це справжній JSON, але поля price в ньому
немає. Дехто на це саме відповідає 400 Bad Request; різницю задає сервіс.
{'missing': ['price']}. Один print(r.text) замінює
годину гадання. Особливо це стосується r.raise_for_status(): він кидає
виняток із кодом і адресою, але тіло у виняток не потрапляє — надрукуй його сам
перед тим, як піднімати помилку далі.КлючАвтентифікація й токен
Меню кавʼярні відкрите всім. А от виторг за день — ні. Щоб API впустив тебе до закритої частини, треба довести, хто ти. Найпоширеніший спосіб у вебʼAPI — токен: довгий випадковий рядок, який сервіс видає один раз і який ти прикладаєш до кожного запиту в заголовку:
headers = {"Authorization": "Bearer demo-token"}
r = requests.get(f"{BASE}/secret", headers=headers, timeout=5)
print(r.status_code) # 200
print(r.json()) # {'revenue': 12450, 'orders': 217}
Слово Bearer («предʼявник») — це схема автентифікації, а не частина твого
ключа. Воно означає буквально: хто предʼявив цей рядок, той і має доступ. Ніяких додаткових
перевірок — тому токен і є секретом, рівносильним паролю.
Без заголовка або з чужим токеном сервер відмовить, але по-різному, і різницю між двома кодами варто затямити раз і назавжди:
Токен: два коди відмови й три місця витоку
Угорі — що сервер відповість. Унизу — куди потрапить сам токен, залежно від того, як ти його передаєш.
Чому токен не пишуть у код
Рядок token = "demo-token" посеред файлу зручний рівно доти, доки код не
поїхав кудись іще: у репозиторій, у резервну копію, у скріншот для колеги. А git памʼятає
історію — прибрати ключ наступним комітом не досить, він лишається в попередніх.
Робочий спосіб — тримати секрет поза кодом, у змінній оточення:
import os
token = os.environ["CAFE_TOKEN"] # впаде з KeyError, якщо змінної немає
headers = {"Authorization": f"Bearer {token}"}
Змінну задають перед запуском: export CAFE_TOKEN=demo-token у Linux і macOS,
$env:CAFE_TOKEN="demo-token" у PowerShell. Щоб не набирати це щоразу, ключі
складають у файл .env поруч із проєктом — а сам .env вписують у
.gitignore, щоб він ніколи не потрапив у репозиторій. Це два різні файли й дві
різні дії: перший зберігає секрет, другий забороняє його комітити.
?token=… прямо в URL — і це найгірший із варіантів. Адресу цілком записує в
свій журнал і сервер, і кожен проксі по дорозі; вона потрапляє в заголовок
Referer, в історію браузера й в історію команд оболонки. Заголовки в журнали
зазвичай не пишуть — тому Authorization і є правильним місцем.ПорціїПагінація
Наше меню з пʼяти позицій сервер віддає по дві за раз. Виглядає дивно — доки не уявиш API з мільйоном записів. Віддати мільйон одним шматком означає зібрати його в памʼяті сервера й передати кількасот мегабайтів по мережі, а якщо зʼєднання обірветься на середині — починати спочатку. Тому дані віддають сторінками.
r = requests.get(f"{BASE}/menu", params={"page": 1}, timeout=5)
print(r.json())
{'page': 1, 'total': 5, 'next': 2,
'items': [{'id': 1, 'name': 'Еспресо', 'price': 25}, ...]}
Крім самих items, у відповіді є службові поля: скільки записів усього
(total) і яка сторінка наступна (next). На останній сторінці
next дорівнює null, тобто в Python — None. Це і є
сигнал зупинитись.
Скільки буде сторінок і запитів
Покрути обидва повзунки: кількість запитів залежить від розміру сторінки різкіше, ніж здається.
Обхід усіх сторінок — це цикл, який іде, поки є
next:
all_items = [] page = 1 made = 0 # лічильник запитів while page is not None: if made >= 50: # стеля: далі щось явно не так print("забагато сторінок, зупиняюсь") break r = requests.get(f"{BASE}/menu", params={"page": page}, timeout=5) r.raise_for_status() made += 1 data = r.json() all_items.extend(data["items"]) page = data["next"] # None на останній сторінці print("зібрано позицій:", len(all_items))
next. Помилка на боці сервісу — і
next починає вказувати сам на себе. Без лічильника твоя програма надсилатиме
запити нескінченно, а ти дізнаєшся про це, коли сервіс тебе заблокує. Правило просте:
будь-який цикл, умову виходу з якого визначає чужа програма, має власну стелю.ВвічливістьЛіміти й 429
Чужий сервер — не твій. Він обслуговує ще тисячу клієнтів, і його час коштує грошей.
Тому майже кожен API має ліміт частоти (rate limit): скільки запитів на
хвилину або на годину дозволено одному клієнту. Перевищив — отримуєш код
429 Too Many Requests.
r = requests.get(f"{BASE}/limited", timeout=5)
print(r.status_code) # 429
print(r.headers["Retry-After"]) # 2
Разом із 429 сервер зазвичай надсилає заголовок
Retry-After. Це не порада, а пряма вказівка: скільки секунд чекати,
перш ніж пробувати знову. Число в ньому дає сам сервер — він краще за тебе знає,
коли твій ліміт відновиться. Ігнорувати його — найшвидший спосіб перетворити тимчасове
обмеження на постійне блокування.
Ліміт 60 запитів на хвилину
Кожна крапка — один запит на осі однієї хвилини. Зелена пройшла, рожева відбита з кодом 429.
Найпростіша ввічливість — time.sleep() між запитами в циклі. Якщо ліміт
60 запитів на хвилину, пауза в одну секунду тримає тебе рівно на межі й не вимагає жодної
арифметики:
import time
for page in range(1, 6):
r = requests.get(f"{BASE}/menu", params={"page": page}, timeout=5)
print(page, r.status_code)
time.sleep(1.0) # не частіше ніж раз на секунду
НаполегливістьПовтори з відступом
Код 5xx означає «зараз не можу». Слово «зараз» тут ключове: сервіс міг
перезапускатися, а через секунду вже працює. Тому на 5xx запит повторюють.
Наш навчальний сервер має маршрут, який поводиться саме так: перші дві спроби —
503, третя — 200.
Питання не в тому, повторювати чи ні, а з якими паузами. І ось тут інтуїція підводить: здається, що чекати треба однаково, наприклад по секунді. А правильна відповідь — пауза має зростати: 1 секунда, 2, 4, 8. Це зветься експоненційний відступ (exponential backoff), бо кожна наступна пауза вдвічі довша за попередню.
Причина в тому, що 5xx найчастіше означає перевантаження. Якщо тисяча
клієнтів отримали відмову й усі почали грюкати раз на секунду, сервер отримує ту саму
тисячу запитів щосекунди — і не встає ніколи. Зростаюча пауза розводить клієнтів у часі:
навантаження спадає само, і сервер отримує шанс піднятися. А ще вона дозволяє перечекати
довгий збій за розумну кількість спроб: пʼять спроб із подвоєнням перечікують
1 + 2 + 4 + 8 = пʼятнадцять секунд, а пʼять спроб по секунді — лише чотири.
Три стратегії проти одного збою
Сіра зона — час, поки сервер лежить. Кружечок на осі — спроба: рожева отримала 503, зелена дочекалась 200. Спроб тут шість, а зростаюча пауза стартує з півсекунди, тож остання спроба припадає на 15.5 с.
Кодом це кілька рядків. Пауза стартує маленькою й подвоюється:
import time
def get_with_retry(url, attempts=5, pause=1.0):
"""Повторює запит, поки сервер віддає 5xx. Пауза щоразу подвоюється."""
for number in range(1, attempts + 1):
r = requests.get(url, timeout=5)
if r.status_code < 500:
return r # 2xx або 4xx — повторювати нічого
print(f"спроба {number}: код {r.status_code}, чекаю {pause:.1f} с")
time.sleep(pause)
pause = pause * 2 # ось він, відступ
return r # спроби вичерпані, віддаємо останню відповідь
Зверни увагу на умову r.status_code < 500. Повторюємо тільки
пʼятірки. На 404 чи 422 повтор — це марна витрата часу й чужих
ресурсів: відповідь не зміниться.
GET,
PUT і DELETE ідемпотентні — повторюй скільки треба. А от
POST створює новий запис щоразу. Уяви найгірший випадок: сервер прийняв твій
POST, створив замовлення — і зʼєднання обірвалося дорогою назад. Ти не
отримав відповіді й повторюєш. Тепер замовлень два, і клієнта списали двічі. Тому
POST або не повторюють узагалі, або повторюють із ключем ідемпотентності:
власним випадковим рядком у заголовку, за яким сервер упізнає, що це той самий запит,
і другого запису не створює.ФормаВід форми відповіді до коду
Останній практичний навик — читати форму відповіді. JSON вкладений: словники всередині списків усередині словників. Дістати з такої структури потрібне число означає скласти шлях — ланцюжок звертань, де до словника ти йдеш по ключу, а до списку — за номером.
Клацни будь-який вузол дерева, і побачиш рядок, який треба написати в Python:
Дерево відповіді й шлях у Python
Клацни по будь-якому рядку дерева — унизу зʼявиться вираз, який дістає саме це значення.
Найкорисніша звичка при знайомстві з новим API — надрукувати відповідь красиво, перш ніж писати шлях:
import json print(json.dumps(r.json(), indent=2, ensure_ascii=False))
indent=2 розкладає структуру по рядках із відступами, а
ensure_ascii=False лишає кирилицю кирилицею — обидва аргументи ми розбирали
в темі 22. Дві секунди на цей рядок економлять
пів години здогадок про те, як зветься потрібне поле.
ДовідникЯк читати документацію API
Кожен сервіс має власну документацію, і виглядають вони по-різному. Але шукати в них треба одне й те саме, і в такому порядку:
- Базовий URL. Спільний початок усіх адрес, на кшталт
https://api.example.com/v2. Одна змінна в коді — і решта адрес складаються з нього. - Автентифікація. Чи потрібен ключ, як його отримати й у якому заголовку передавати. Тут ховається сюрприз на кшталт «токен живе годину».
- Ліміти. Скільки запитів дозволено й за який період. Іноді ліміти різні для різних маршрутів, і найдорожчий тобі якраз і потрібен.
- Формат помилок. Які коди сервіс вживає й що кладе в тіло. Побачивши приклад заздалегідь, ти напишеш обробку відразу, а не після першого падіння.
- Пагінація. Як зветься параметр сторінки, який максимальний розмір,
де сигнал кінця: поле
next, заголовокLinkчи просто порожній список. - Версіонування. Номер версії у шляху (
/v2/) або в заголовку. Він існує рівно для того, щоб сервіс міг змінити формат відповіді, не зламавши старих клієнтів.
І остання звичка: перш ніж писати код, зроби один запит вручну й подивись на відповідь. У документації трапляються застарілі приклади — а сервер завжди каже правду про себе.
ГлибшеДля тих, хто вже ходив в API
Кілька речей, які виринають далі й нечасто трапляються в перших туторіалах.
Retry-After буває датою. Стандарт дозволяє два формати:
кількість секунд (2) і мітку часу за HTTP (Wed, 09 Sep 2026 08:00:00
GMT). Код, який робить int(r.headers["Retry-After"]), на другому
форматі впаде з ValueError. Розбирати дату вміє
email.utils.parsedate_to_datetime.
Пагінація буває не сторінками, а курсором. Замість
?page=2 сервер віддає непрозорий рядок і просить надіслати його наступного
разу. Це не примха: доки ти йдеш сторінками, у колекцію можуть додати запис, позиції
зсунуться, і одну ти побачиш двічі, а іншу пропустиш. Курсор указує на конкретне місце
в даних і від вставок не залежить.
Умовні запити економлять трафік. У відповіді часто є заголовок
ETag — короткий відбиток вмісту. Надішли його наступного разу в
If-None-Match, і якщо нічого не змінилося, сервер відповість
304 Not Modified без тіла: той самий результат за майже нуль байтів.
Ліміт видно ще до відмови. Чимало сервісів повертають лічильники в
заголовках: X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset. Дивитись на Remaining і пригальмовувати
заздалегідь набагато акуратніше, ніж дочекатись 429.
Не всі API — REST. Трапляється GraphQL, де адреса одна, а що саме повернути, описуєш ти сам у тілі запиту. Логіка з цієї теми — заголовки, коди, повтори, ліміти — лишається тією самою; міняється лише те, як формулюється питання.
ПідсумокЩо з цього забрати
Ми навчилися розмовляти з чужою програмою по мережі:
- API віддає дані, а не верстку — і це економить не байти, а кроки: число приходить числом;
r.json()перетворює тіло відповіді на обʼєкти Python і падає на не-JSON — перевіряйContent-Type, а не сподівайся;- іменник у шляху, дієслово в методі; безпечний метод нічого не змінює, ідемпотентний не змінює стану при повторі;
POST+json=створює запис: код201, адреса нового ресурсу в заголовкуLocation;4xx— виправляй запит,5xx— чекай і повтори; тіло помилки читай завжди;- токен — у заголовку
Authorization, сам токен — у змінній оточення,.env— у.gitignore;401це «не знаю тебе»,403— «знаю, але не можна»; - пагінація — цикл «поки є
next» з обовʼязковою стелею кроків; 429іRetry-After— сервер сам каже, скільки чекати; пауза дешевша за блокування;- повтори з відступом — пауза подвоюється, повторюємо тільки
5xxі тільки ідемпотентні методи.
І остання думка, яка веде далі. Усе це працює за однієї умови: API існує — хтось у кавʼярні сів і зробив адресу, яка віддає меню в JSON. Але переважна більшість сторінок в інтернеті такої адреси не має: там лишається тільки верстка для людських очей, з якої ми починали. Що робити, коли дані видно на екрані, а взяти їх нема звідки, — тема наступної лекції про скрапінг. І перше, що там прозвучить: перевір, чи справді немає API, бо розбирати HTML — завжди гірший варіант.