HTTP-запити

Дані, яких немає в тебе на диску. Як програма питає чужий сервер, що саме приїжджає у відповідь — і чому 404 це не помилка Python.

У попередній темі ми дописали скрипт-звіт: програму, якій кажеш прямо в момент запуску, який файл читати й що з ним робити. Вона вийшла справді корисною — але в неї є мовчазне припущення. Файл із даними вже лежить на диску, і поклав його туди хтось. Руками. Сьогодні ми приберемо це припущення: навчимо програму йти по дані туди, де вони справді живуть — на чужий сервер.

Наскрізний приклад теми — та сама кавʼярня «Кома», що й у темі 22: позицій у меню тут чотири — рівно стільки, скільки треба цій темі, бо наскрізним є заклад, а не довжина списку. Тоді меню лежало у файлі menu.csv, який ми самі й записали. Тепер уяви, що меню живе на сервері кавʼярні, змінюється щоранку, і твоя програма має щоразу брати свіже.

МотивДані, яких немає на диску

Файл на диску має одну прикру властивість: він застигає в момент, коли його записали. Прайс постачальника, курс валют, розклад електричок, показники датчика, меню кавʼярні — усе це змінюється без твоєї участі. Тримати такі дані у файлі можна рівно доти, доки хтось не забув його оновити.

Спосіб отримати свіже той самий, що й у людей: запитати в того, у кого воно є. Уся різниця в тому, що замість людини питає програма, а замість слів використовує суворо описаний порядок — протокол. Той, яким користується майже все, що ти бачиш у браузері, зветься HTTP (HyperText Transfer Protocol — протокол передавання гіпертексту).

Назва історична: сьогодні HTTP возить не лише гіпертекст, а будь-що — таблиці, картинки, відео, показники датчиків, платежі.

Межі цієї теми. Тут ми розбираємо сам протокол: як влаштований запит, що приїжджає у відповідь, як реагувати на кожен випадок. Що робити з даними далі — окремі теми. Розбирати відповідь у форматі JSON і працювати з чужими API ми будемо в темі 28, витягати дані зі сторінок для людей — у темі 29. Тут ми навчимося приносити.

МодельХто кого питає

Уся модель HTTP тримається на трьох реченнях, і їх варто прочитати повільно.

  1. Розмову завжди починає клієнт. Твоя програма — клієнт. Вона формулює запит і надсилає його. Сервер сам ніколи не заговорить першим: він мовчить, доки його не спитали.
  2. Одне питання — одна відповідь. На кожен запит сервер віддає рівно одну відповідь, і на цьому обмін закінчено.
  3. Сервер не памʼятає попереднього разу. Кожен запит для нього самодостатній. Якщо серверу треба знати, хто ти, це доведеться сказати у самому запиті — щоразу заново.

Третій пункт дивує найчастіше. У браузері ж видно, що сайт «памʼятає», що ти увійшов. Памʼятає — але не тому, що між вами є щось постійне: браузер просто щоразу докладає до запиту той самий розпізнавальний рядок. Постійного «підключення до сайту» не існує.

Що таки існує — це зʼєднання: канал між двома програмами, який відкривають, використовують і закривають. Щоб його відкрити, треба знати дві речі. Перша — адреса машини: чотири числа через крапку на кшталт 127.0.0.1, за якими мережа знаходить потрібний компʼютер серед усіх інших. Друга — порт: номер дверей на цій машині, за якими сидить потрібна програма. Одна машина тримає десятки програм одночасно, і без номера дверей незрозуміло, до кого саме ми прийшли. Для HTTP типовий порт 80, для захищеного HTTPS — 443. У наших прикладах сервер сидітиме на 127.0.0.1 — це особлива адреса, яка на будь-якій машині означає «цей самий компʼютер», — з портом, який видасть система.

Але в коді ми пишемо не числа, а звичну адресу сторінки. Вона й містить усе потрібне, просто в іншому вигляді:

http://cafe.local/menu
 ↑        ↑        ↑
 схема    хост     шлях

Схема каже, за яким протоколом розмовляти: http або захищений https. Хост — імʼя машини; саме воно поїде в заголовку Host, який ти побачиш у запиті через розділ. Імʼя — це ще не адреса: перш ніж відкривати зʼєднання, програма питає в служби імен (DNS), яке число ховається за cafe.local, і йде вже до числа. Шлях — те, що саме ми в цієї машини просимо. А порту в цій адресі немає, і це звичайна річ: коли його не написали, береться типовий для схеми — 80 для http, 443 для https. Написати його можна явно, через двокрапку після хоста: http://127.0.0.1:8000/menu — саме так виглядатимуть адреси в наших прикладах.

Слово «зʼєднання» ще знадобиться в розділі про Session: там усе крутиться навколо того, скільки разів ми його відкриваємо. А поки що подивись, з яких кроків складається один обмін і що саме летить на кожному з них.

Один обмін по кроках

Натискай «крок» і дивись, що саме зараз передається. Праворуч — сирий текст, який іде дротом.

на дроті
крок—
зʼєднань—
Головне: зʼєднання відкривається до того, як полетів запит, і може пережити відповідь — саме на цьому побудований Session. Зверни увагу на порожній рядок після заголовків: саме він каже «заголовки скінчились, далі тіло». У запиті GET тіла немає, тому після порожнього рядка нічого й немає.

БудоваАнатомія запиту й відповіді

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

Запит

GET /menu HTTP/1.1          ← метод, шлях, версія протоколу
Host: cafe.local              ← до якого сайту звертаємось
User-Agent: python-requests/2.34.2
Accept: */*
Accept-Encoding: gzip
Connection: keep-alive
                              ← порожній рядок: заголовки скінчились

Перший рядок несе три речі. Метод — що саме ми хочемо зробити. Шлях — з чим саме. Версія — за якими правилами розмовляємо.

Методів кілька, і різниця між ними не технічна, а смислова: це домовленість про наміри, якої дотримуються всі. Головне питання до методу — чи змінює він щось на сервері і чи безпечно повторити його двічі.

методнамірзмінює данібезпечно повторити
GETдай мені ценітак
HEADдай лише заголовки, без тіланітак
POSTось нові дані, приймитакні
PUTхай за цією адресою буде ось цетактак
PATCHзміни ось цю частинутакяк домовиться сервіс
DELETEприбери цетактак

Стовпчик «безпечно повторити» — не дрібниця, а те, від чого залежить, чи можна автоматично перезапустити невдалий запит. Повторити GET можна скільки завгодно: він нічого не міняє. Повторити POST — означає ризикнути другим замовленням і другим списанням грошей. Властивість «повтор дає той самий результат, що й один виклик» зветься ідемпотентністю (idempotence). PUT ідемпотентний, хоч і змінює дані: «хай тут буде ось це» вдруге не змінює нічого нового.

PATCH стоїть окремо, і саме тому в таблиці не «так» і не «ні». Стандарт його ідемпотентності не гарантує, а на ділі все вирішує тіло запиту: «постав ціну 50» вдруге нічого не змінить, а «додай до ціни 5» щоразу робитиме дорожче. Отже, за PATCH відповідь дає документація сервісу, а не сам метод.

Відповідь

HTTP/1.1 200 OK               ← версія, код стану, пояснення словами
Content-Type: text/plain; charset=utf-8
Content-Length: 148
Server: BaseHTTP/0.6 Python/3.12.2
                              ← порожній рядок
Еспресо — 25 грн (кава)       ← тіло
Капучино — 45 грн (кава)
Латте — 50 грн (кава)
Чай — 30 грн (чай)

Будова дзеркальна. Замість методу й шляху — код стану (число, за яким рішення ухвалює програма) і пояснення (слова, які потрібні лише людині; жодна програма на них не дивиться). Далі заголовки, порожній рядок і тіло.

Оце й увесь HTTP на рівні, який треба розуміти. Руками такий текст ми не писатимемо жодного разу — його складе бібліотека. Але коли щось піде не так, дивитись треба саме сюди.

ІнструментПерший requests.get

У стандартній бібліотеці Python для HTTP є модуль urllib.request. Він працює, нічого не треба ставити — і майже ніхто ним не користується напряму. Причина проста: він написаний під іншу епоху, і найзвичайніші речі в ньому виходять довгими. Тому мовою реального Python-коду став сторонній пакет requests. Ставиться він так, як ми вчилися в темі 18:

python3 -m pip install requests

А далі один рядок:

код
import requests

r = requests.get(
    "http://127.0.0.1:8000/menu",
    timeout=5,
)

print(r.status_code)
print(r.reason)
print(r.headers["Content-Type"])
print(r.text)
що надрукується
200
OK
text/plain; charset=utf-8
Еспресо — 25 грн (кава)
Капучино — 45 грн (кава)
Латте — 50 грн (кава)
Чай — 30 грн (чай)

Виклик requests.get() повертає обʼєкт Response. Коли він у тебе в руках, обмін уже завершено: бібліотека відкрила зʼєднання, надіслала запит, дочекалась відповіді й дочитала тіло до кінця. Далі це просто дані в памʼяті. Найкорисніші його частини:

звертаннящо дає
r.status_codeкод стану числом: 200, 404, 500
r.reasonте саме словами: OK, Not Found
r.okTrue, якщо код менший за 400
r.textтіло як рядок
r.contentтіло як байти
r.headersзаголовки відповіді, як словник
r.urlадреса, за якою відповідь отримано насправді
r.historyланцюг перенаправлень, якщо він був

Аргумент timeout=5 у прикладі не для краси — про нього окремий розділ нижче. Запамʼятай поки що просто як звичку: timeout ставиться завжди.

ВідповідьКоди станів як система

Код стану — трицифрове число, і вчити його напамʼять не треба, бо це не список, а система. Значення несе перша цифра; дві інші лише уточнюють.

классенс одним реченнямхто винен
1xxприйняв, продовжуй — службове, в коді майже не трапляється—
2xxзробив те, що просив—
3xxтого, що просив, тут немає — воно за іншою адресою—
4xxзапит поганийклієнт, тобто ти
5xxзапит нормальний, зламався ясервер

Різниця між 4xx і 5xx — головне, що варто винести з цього розділу. Вона відповідає на найпрактичніше питання: чи має сенс повторити запит. На 4xx повтор дасть те саме, бо міняти треба запит. На 5xx повтор може врятувати: сервер міг перевантажитись на секунду.

Покрути повзунок і подивись, як змінюється реакція програми. Коди між відомими теж щось означають: перша цифра працює навіть для номера, якого ніхто не бачив.

Що робити з цим кодом

Повзунок веде по всьому діапазону. Дивись не на назву, а на клас — саме він визначає дію.

r.ok—
raise_for_status—
Пастка, на якій спотикаються всі: 404 — це успішно доставлена відповідь. Зʼєднання відкрилось, запит дійшов, сервер відповів, відповідь приїхала. Python не бачить тут жодної причини для винятку — і не піднімає його. Якщо тобі потрібен виняток, попроси про нього явно.

Обидва інструменти для перевірки вже є в Response:

перевірка числом
r = requests.get(url, timeout=5)

if r.status_code == 404:
    print("такого меню немає")
elif r.ok:
    print(r.text)
else:
    print("сервер відповів",
          r.status_code)
перевірка винятком
r = requests.get(url, timeout=5)

try:
    r.raise_for_status()
except requests.exceptions.HTTPError:
    print("не 2xx:", r.status_code)
else:
    print(r.text)

Обирай за тим, чи вміє це місце коду щось зробити з поганим кодом. Якщо вміє — перевіряй числом і розвʼязуй на місці. Якщо не вміє й задача просто зупинитись — raise_for_status() коротший і чесніший, бо кидає виняток з описом одразу там, де проблема виникла. Винятки ми розбирали в темі 19.

БайтиЗаголовки, Content-Type і кракозябра

Заголовки — це пари «назва: значення», якими сторони повідомляють службові речі про свій запит або свою відповідь. Найпростіший образ тут — посилка: тіло відповіді це вміст коробки, а заголовки — наліпка зверху. На наліпці написано, що всередині, скільки воно важить, хто відправник і чи можна коробку відкривати повторно. Розділені вони не випадково: наліпку читають, не розкриваючи коробки, — саме тому одержувач може вирішити, що робити з посилкою, ще до того, як дістане перший байт вмісту. У requests вони доступні як r.headers і поводяться майже як звичайний словник (тема 09), тільки регістр назви не має значення: r.headers["content-type"] і r.headers["Content-Type"] — те саме.

Найважливіший заголовок відповіді — Content-Type. Він відповідає на два питання одразу: що це за дані і в якому вони кодуванні.

Content-Type: text/plain; charset=utf-8
              └ тип/підтип ┘  └ кодування ┘

Тепер найтонше місце теми. Мережею їдуть байти — і більше нічого. Байт сам собою не знає, чи він частина літери. Щоб зібрати з байтів текст, треба знати правило складання, тобто кодування. Звідси три різні речі в Response, які часто плутають:

Отже, кракозябра — це завжди не «зіпсовані дані», а правильні байти, зібрані за неправильним правилом. І найчастіша причина ось яка: якщо сервер віддав щось текстове, але charset не назвав, старий стандарт HTTP каже вважати таке кодуванням ISO-8859-1 — західноєвропейським, де українських літер немає взагалі.

сервер не назвав charset
# Content-Type: text/plain
r.encoding
'ISO-8859-1'

r.text[:14]
'Ð\x95Ñ\x81пÑ\x80еÑ\x81о'

r.content[:6]
b'\xd0\x95\xd1\x81\xd0\xbf'
два способи полагодити
# 1. розкодувати самому
r.content[:14].decode("utf-8")
'Еспресо'

# 2. підказати requests
r.encoding = "utf-8"
r.text[:7]
'Еспресо'

Зверни увагу на самі числа в зрізах: 14 байтів і 7 символів — це те саме слово «Еспресо». Кожна українська літера в UTF-8 займає два байти, тож у байтах слово вдвічі «довше», ніж у літерах. Тому зріз по r.content і зріз по r.text ніколи не збігаються довжиною — вони міряють різні одиниці.

Правило, яке економить години. Якщо ти знаєш, у якому кодуванні дані, — не покладайся на r.text, бери r.content.decode(...). Здогад бібліотеки залежить від заголовків, які пише чужий сервер, і може змінитись без попередження. Байти не змінюються ніколи.

ПараметриЯк передати серверу слово

Досі ми просили сторінку цілком. Але частіше треба попросити щось конкретне: не «меню», а «меню, де є слово кава». Такі уточнення їдуть у самій адресі, після знака питання:

http://cafe.local/search?q=кава&limit=10
                        └ рядок запиту: пари ключ=значення через &

Спокуса склеїти таку адресу рядком дуже сильна: url + "?q=" + word — і готово. Не роби так. Адреса — це не вільний текст: у ній є символи зі службовим значенням, і твоє слово може випадково виявитись одним із них.

символщо означає в адресіщо буде при склеюванні
пробілнічого — його там просто не можнарядок обірветься або дійде покаліченим
&кінець параметра, далі наступнийзначення обріжеться на амперсанді
=межа між ключем і значеннямзайва межа посеред слова
#початок якоря, на сервер не їдеусе після нього мовчки зникне
кирилицяв адресі дозволені лише латиниця й цифризалежить від того, хто по дорозі як здогадається

Правильний спосіб — віддати requests словник і не думати:

r = requests.get(url, params={"q": "чай & кава"}, timeout=5)
print(r.url)
http://cafe.local/search?q=%D1%87%D0%B0%D0%B9+%26+%D0%BA%D0%B0%D0%B2%D0%B0

Те, що вийшло, зветься відсотковим кодуванням (percent-encoding). Правило його просте: слово перетворюють на байти в UTF-8, і кожен байт, який не є латинською літерою, цифрою чи одним із кількох безпечних знаків, записують як % і два шістнадцяткові знаки. Українська літера «ч» у UTF-8 — це два байти, тому вона й перетворюється на %D1%87. А пробіл у рядку запиту традиційно пишуть як +.

Поле нижче приймає будь-яке значення. Спробуй пробіл, амперсанд, кирилицю, символ відсотка — і подивись, що з них зробить params= і що вийде, якщо склеїти рядок самотужки.

Конструктор адреси

Угорі — те, що збере params=. Унизу — те, що вийде зі склеєного рядка, і що з нього прочитає сервер.

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

ТерпінняТаймаут

Ось найкоротший спосіб зробити програму, яка одного дня зависне назавжди:

r = requests.get(url)          ← без timeout

Мережа має властивість, якої немає в диска: вона може не відповісти й не відмовити. Сервер прийняв зʼєднання й замовк; кабель висмикнули посеред відповіді; проміжний пристрій мовчки проковтнув пакет. У всіх цих випадках твоя програма чекає — і без явної межі чекатиме доти, доки не втрутиться операційна система, а це можуть бути десятки хвилин.

timeout ставить цю межу:

requests.get(url, timeout=5)          ← 5 секунд на кожен етап
requests.get(url, timeout=(3, 10))    ← 3 на зʼєднання, 10 на читання

Важлива тонкість, через яку люди ставлять таймаут не туди: це не обмеження на весь запит. Це межа тиші — скільки чекати наступної порції даних. Сервер, який щосекунди надсилає по байту, з timeout=5 не обірветься ніколи. Для повного обмеження часу потрібні інші засоби; для переважної більшості задач достатньо таймауту тиші.

Коли межу перевищено, летить requests.exceptions.Timeout. У нього є два підвиди — ConnectTimeout (не вдалось навіть зʼєднатись) і ReadTimeout (зʼєднались, але відповіді не дочекались); ловити достатньо загальний Timeout.

Встиг чи не встиг

Ліворуч — скільки сервер думає. Праворуч — скільки ми готові чекати. Вердикт рахується з цих двох чисел.

що отримаємо—
чекали—
Дрібниця, яка псує демонстрації: на локальному сервері таймаут неможливо показати «випадково» — петля відповідає швидше за мілісекунду, і навіть timeout=0.001 встигає. Щоб побачити Timeout насправді, потрібен маршрут, який навмисно спить — інакше межу тиші просто нічим перевищити.

Дві бідиПомилка мережі проти помилки HTTP

Тепер зведімо докупи найважливішу відмінність теми. Є два зовсім різні сорти неприємностей, і плутати їх дорого.

Звідси й різна реакція. На 500 ти можеш подивитись у відповідь, записати її в журнал, показати користувачеві текст помилки — відповідь у тебе в руках. На ConnectionError у тебе немає нічого, крім факту невдачі.

Усі винятки requests — нащадки одного:

requests.exceptions.RequestException
   ├── ConnectionError     не вдалося дістатись сервера
   │      └── ConnectTimeout   (він же й Timeout)
   ├── Timeout
   │      └── ReadTimeout      зʼєднались, відповіді не дочекались
   ├── TooManyRedirects    ланцюг перенаправлень задовгий
   └── HTTPError           його піднімає raise_for_status()

Тому except requests.exceptions.RequestException ловить усе, що може піти не так у запиті, — зручно для верхнього рівня програми. Але для рішення «повторити чи здатись» потрібен точніший розбір:

Де саме зламалось

Перемикай причину й дивись, до якої точки дійшов запит, що отримає програма і чи допоможе повтор.

програма отримає—
повтор допоможе—
Правило повтору в одному реченні: повторюй тоді, коли причина тимчасова й не твоя — це 5xx, 429, Timeout і ConnectionError. І тільки для методів, які безпечно повторити: другий GET нікому не зашкодить, а другий POST може оформити друге замовлення.

ДорогаПеренаправлення

Клас 3xx означає: «того, що ти просиш, за цією адресою немає, але я знаю, де воно». Разом із таким кодом сервер надсилає заголовок Location — нову адресу.

кодсенсчи змінює метод
301переїхали назавждитак, POST може стати GET
302тимчасово туттак, історично
303подивись отам, і саме через GETтак, навмисно
307тимчасово, метод не чіпайні
308назавжди, метод не чіпайні

requests за замовчуванням іде за перенаправленням сам, і робить це доти, доки не дійде до відповіді без Location. Тому в звичайному коді ти 301 навіть не побачиш — одразу отримаєш кінцеву сторінку. Слід при цьому не зникає:

Ланцюг перенаправлень

Постав довжину ланцюга й перемкни allow_redirects. Праворуч — що після цього лежить у відповіді.

r.status_code—
len(r.history)—
Пастка, про яку варто знати заздалегідь: перехід із http на https зазвичай зроблено через 301. Якщо в такому запиті були службові заголовки, requests частину з них при зміні адреси навмисно не переносить — з міркувань безпеки. Тому запит, який «працює в браузері», з бібліотеки може несподівано отримати 401. Лікується тим, що адресу одразу пишуть із https.

ОщадністьSession: одне зʼєднання замість сорока

Повернімось до зʼєднання. Кожен виклик requests.get() — це окрема маленька історія: відкрити зʼєднання, надіслати запит, отримати відповідь, закрити зʼєднання. Якщо запитів сорок, ця історія повторюється сорок разів.

Відкрити зʼєднання не безкоштовно. Спершу TCP-рукостискання — три пакети туди-назад. Якщо адреса на https, поверх додається рукостискання TLS: ще один-два обміни, плюс перевірка сертифіката. На сервері за океаном кожен такий обмін коштує сотню мілісекунд до того, як полетів твій перший байт.

Session ламає цю схему: вона тримає відкриті зʼєднання й видає їх наступним запитам до того самого сервера.

сорок історій
for name in names:
    r = requests.get(
        url, params={"q": name},
        timeout=5)
    print(r.status_code)

# 40 запитів = 40 зʼєднань
одна історія
with requests.Session() as s:
    s.headers.update(
        {"X-Client": "menu-bot"})
    for name in names:
        r = s.get(url,
                  params={"q": name},
                  timeout=5)
        print(r.status_code)

# 40 запитів = 1 зʼєднання

Тепер чесно про виграш — бо тут підручники часто обіцяють не те, що можна побачити.

Я поміряв: сорок запитів до локального сервера, медіана з трьох прогонів. Виграш у часі виявився цілком залежним від однієї-єдиної налаштування сервера:

серверіз Sessionбез Sessionрізниця
як є1758 мс214 мсSession у 8 разів гірша
з вимкненим Нейглом81 мс204 мсSession у 2.5 раза краща

Перший рядок виглядає як спростування всього розділу — і саме тому його варто зрозуміти. Число в ньому нестійке: воно залежить від таймера відкладеного підтвердження, тож на іншій машині перший рядок легко дасть і вдвічі менше, і вдвічі більше. Стійке тут інше — напрямок: із Нейглом постійне зʼєднання програє, без нього виграє приблизно втричі. Саме напрямок і варто запамʼятати, а не множник. Виною давня оптимізація TCP на імʼя алгоритм Нейгла: щоб не слати мережею крихітні пакети, вона притримує дрібні порції, доки не підтвердять попередні. У парі з іншою оптимізацією — відкладеним підтвердженням на боці одержувача — виходить взаємна очікувальна пауза близько сорока мілісекунд. На постійному зʼєднанні вона припадає на кожну відповідь, і сорок запитів набирають ті самі 1.7 секунди. На короткоживучих зʼєднаннях паузи немає, бо закриття зʼєднання проштовхує дані одразу.

Мораль подвійна. Перша: числа треба міряти, а не переказувати. Друга, важливіша для нас: час — поганий доказ у цій темі. Він залежить від налаштувань обох сторін, від навантаження машини й від фази місяця.

Надійний доказ інший — кількість зʼєднань. Вона детермінована: сорок запитів через Session відкривають одне зʼєднання, без неї — сорок. Це число однакове на будь-якій машині, і саме воно пояснює, навіщо Session існує.

Скільки зʼєднань відкриється

Угорі — з Session, унизу — без неї. Кожен прямокутник — окреме відкрите зʼєднання.

із Session—
без Session—
На локальній петлі економія часу мізерна — зʼєднатися із сусіднім процесом дешево. Справжній виграш зʼявляється там, де кожне зʼєднання коштує подорожі пакета через півсвіту й рукостискання TLS. Кількість зʼєднань при цьому та сама — змінюється лише ціна кожного з них.

Зʼєднання — не єдина користь. Session також тримає спільні заголовки (задав User-Agent раз — їде з кожним запитом) і cookies, отримані від сервера. Тому для будь-якої серії запитів до одного сервера Session — типовий вибір, а поодинокий requests.get() лишається для разових звернень.

Дрібниця для допитливих. requests.get() насправді теж створює Session — на один запит, і одразу її закриває. Тобто різниця не в тому, є сесія чи ні, а в тому, скільки вона живе.

ГлибшеДля тих, хто вже ходив у мережу

Кілька речей, які не потрібні для першого запиту, але рано чи пізно зустрінуться.

Стиснення відбувається непомітно. requests сам додає Accept-Encoding: gzip, і сервер зазвичай надсилає тіло стисненим. Тому len(r.content) може бути значно більшим за Content-Length: заголовок описує те, що летіло дротом, а r.content — уже розтиснуте. Це не помилка й не привід порівнювати одне з одним.

Тіло за замовчуванням цілком опиняється в памʼяті. Для меню з чотирьох рядків це байдуже, для файлу на два гігабайти — ні. Тоді потрібен stream=True і читання шматками через r.iter_content(...); файл при цьому пишеться на диск, минаючи памʼять.

Твоя програма представляється. Заголовок User-Agent за замовчуванням — python-requests/2.34.2. Деякі сервери за цим рядком відповідають інакше або відмовляють. Ставити чуже імʼя, вдаючи браузер, — погана ідея; натомість добрий тон — назватись чесно, вказавши назву свого інструмента й спосіб звʼязку.

Повторів «із коробки» немає. requests навмисно не перезапускає невдалі запити сам — бо не може знати, чи безпечно повторити саме твій. Найпростіший робочий повтор — цикл із наростаючою паузою (одна секунда, дві, чотири) і з обовʼязковою перевіркою, що метод повторювати можна. Якщо сервер відповів 429 і додав заголовок Retry-After, це прямо сказаний час, який треба почекати, — не вигадуй свій.

Кодування іноді вгадують. Коли charset не названо, а requests не має правила, він може звернутись до r.apparent_encoding — здогаду за статистикою байтів. Здогад буває правильним, але це залишається здогадом: на коротких відповідях він помиляється найчастіше.

HTTP/1.1 не єдиний. Сучасні сервери говорять і HTTP/2, де кілька запитів їдуть одним зʼєднанням паралельно. requests цього не вміє, і для переважної більшості задач це не проблема. Якщо колись знадобиться — сусідня бібліотека httpx має майже такий самий інтерфейс і вміє HTTP/2 та асинхронність (тема 37).

ПідсумокЩо ми тепер уміємо

Наша програма більше не залежить від того, чи поклав хтось файл у потрібну теку:

І тут одразу видно наступну межу. Подивись, що ми приносимо: r.text — рядок. Для меню з чотирьох напоїв його ще можна розібрати руками, порізавши на рядки. Але сервери відповідають програмам не текстом для очей, а даними для програм: той самий наш сервер має адресу /menu.json, за якою лежить те саме меню — але у форматі, який Python перетворює на список словників одним рухом. А ще там будуть ключі доступу, ліміти на кількість запитів і домовленості про те, як просити сторінками. Це робота з API — наступна тема.