Python: от данных до своей сводки Урок 20 из 56
Вежливость к чужому серверу: таймаут, повтор с отступом и кеш
Двадцатый урок курса по Python. Три вещи, из которых состоит вежливость к чужому серверу: таймаут — не ждать вечно, повтор с удвоением паузы — не бить лежачего, кеш — не спрашивать дважды. Измерено на своём сервере: четыре чтения, один запрос.
Зачем это нужно
Три прошлых урока ходили к Нацбанку. Работало — и именно поэтому пора сказать, как этого не делать.
Чужой сервер вам ничего не должен. Он может молчать, отвечать «попробуйте позже», уходить на обслуживание. Программа, написанная в расчёте на то, что он всегда отвечает быстро, однажды повиснет — а программа, которая на отказ отвечает повтором без паузы, добьёт то, что и так шатается.
Вежливость здесь — не манеры, а три конкретные вещи: таймаут, повтор с отступом и кеш. Учимся на своём сервере: тренироваться на чужом — это и есть та невежливость, о которой урок.
Сразу целиком
Файл sypaiy.py. Запуск: python sypaiy.py из окружения.
Первые двадцать строк — учебный сервер, который умеет тормозить, отказывать и считать запросы; его достаточно узнать в лицо, к устройству серверов курс вернётся отдельно. Обязательное — три блока после него.
"""Урок 20: вежливость к чужому серверу — таймаут, повтор, кеш.
Три предыдущих урока ходили в сеть к настоящему банку. Сегодня учимся делать
это так, чтобы не ждать вечно и не мешать тому, у кого спрашиваем, — а учимся
на своём сервере, потому что тренироваться на чужом и есть та самая невежливость.
"""
import json
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Сколько раз сервера попросили о каждом адресе. Это и есть мера вежливости.
HITS = {"data": 0, "flaky": 0}
class Teacher(BaseHTTPRequestHandler):
"""Учебный сервер рядом: он умеет тормозить, падать и считать запросы."""
def do_GET(self):
if self.path == "/slow":
time.sleep(2) # сервер задумался
self.answer(200, b'{"value": 1}')
elif self.path == "/flaky":
HITS["flaky"] += 1
if HITS["flaky"] < 3:
self.answer(503, b"try later") # временно не может
else:
self.answer(200, b'{"value": 42}')
else:
HITS["data"] += 1
self.answer(200, b'{"value": 510.43}')
def answer(self, code, body):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
return # без журнала в наш вывод
server = ThreadingHTTPServer(("127.0.0.1", 0), Teacher)
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{server.server_address[1]}"
def get(url, timeout=1.0):
"""Один запрос: ответ телом, или исключение."""
request = urllib.request.Request(url, headers={"User-Agent": "shanraq-course/1.0"})
with urllib.request.urlopen(request, timeout=timeout) as answer:
return json.load(answer)
print("== таймаут: сколько ждать чужого молчания")
try:
get(BASE + "/slow", timeout=0.3)
except TimeoutError:
# Молчание после соединения приходит именно так: TimeoutError.
print("ждали 0.3 с и перестали ждать")
except urllib.error.URLError as error:
# А если не удалось даже соединиться — URLError с причиной внутри.
print("не дозвонились:", type(error.reason).__name__)
print()
print("== повтор с отступом: три попытки вместо одной")
delay = 0.1
for attempt in range(1, 5):
try:
data = get(BASE + "/flaky")
except urllib.error.HTTPError as error:
# Повторяют только то, что может стать лучше само.
if error.code not in (429, 500, 502, 503, 504):
print("не повторяем:", error.code)
break
print(f"попытка {attempt}: {error.code}, ждём {delay:.1f} с")
time.sleep(delay)
delay *= 2
continue
print(f"попытка {attempt}: получено {data['value']}")
break
print("запросов к серверу:", HITS["flaky"])
print()
print("== кеш: один и тот же ответ не спрашивают дважды")
CACHE = {}
TTL = 60
def cached(url):
"""Ответ из памяти, пока он не устарел."""
now = time.monotonic()
if url in CACHE and now - CACHE[url][0] < TTL:
return CACHE[url][1], "из кеша"
data = get(url)
CACHE[url] = (now, data)
return data, "из сети"
for _ in range(3):
data, where = cached(BASE + "/data")
print(f"{data['value']} — {where}")
print("запросов к серверу:", HITS["data"])
server.shutdown()
Выводит:
== таймаут: сколько ждать чужого молчания
ждали 0.3 с и перестали ждать
== повтор с отступом: три попытки вместо одной
попытка 1: 503, ждём 0.1 с
попытка 2: 503, ждём 0.2 с
попытка 3: получено 42
запросов к серверу: 3
== кеш: один и тот же ответ не спрашивают дважды
510.43 — из сети
510.43 — из кеша
510.43 — из кеша
запросов к серверу: 1
Разбор
Таймаут: сколько ждать чужого молчания
ждали 0.3 с и перестали ждать
Без таймаута urlopen ждёт столько, сколько сервер молчит, — минуты. Со стороны это не отличается от зависшей программы, и хуже всего это выглядит в расписании: задача, которая должна была отработать за секунду, к утру всё ещё висит.
Таймаут отвечает на вопрос «сколько мы готовы ждать», и ответ на него всегда есть, даже если его не написали: просто тогда он равен «сколько угодно».
Заметьте, чем это приходит:
except TimeoutError: # соединились, а ответа не дождались
except urllib.error.URLError: # не соединились вовсе
Молчание после соединения — это TimeoutError. Не дозвонились — URLError, а внутри у него причина. Ловят оба: для программы это разные беды, но обе означают «данных нет».
Образ. Звонок в справочную. Вы кладёте трубку после третьего гудка не из вредности, а потому, что у вас есть другие дела.
Повтор с отступом: три попытки вместо одной
попытка 1: 503, ждём 0.1 с
попытка 2: 503, ждём 0.2 с
попытка 3: получено 42
запросов к серверу: 3
503 — «сейчас не могу, попробуйте позже». Это временно, и повтор здесь уместен. Но повтор без паузы — это удар по тому, кто и так не справляется: сервер лёг под нагрузкой, а вы добавили ещё сотню запросов в секунду.
Поэтому пауза удваивается: 0,1 → 0,2 → 0,4. Такой отступ (по-английски exponential backoff) даёт источнику время встать и в то же время не тянет ваш собственный расчёт до бесконечности.
Второе правило важнее первого: повторяют только то, что может стать лучше само.
if error.code not in (429, 500, 502, 503, 504):
... # не повторяем
404 не станет 200 от того, что вы спросите ещё раз: адреса нет. 403 не станет от повторов разрешённым. А вот 429 — «слишком часто» — прямо просит подождать, и часто говорит сколько, в заголовке Retry-After; если он есть, слушают его, а не свою формулу.
И у повторов должен быть конец. Три-четыре попытки — и программа честно говорит, что источник не ответил, вместо того чтобы висеть до утра.
Кеш: один и тот же ответ не спрашивают дважды
510.43 — из сети
510.43 — из кеша
510.43 — из кеша
запросов к серверу: 1
Три чтения — один запрос. Это и есть вся арифметика кеша, и она же — ответ на вопрос, зачем он нужен: не ради скорости, а ради того, чтобы не спрашивать чужой сервер о том, что вы уже знаете.
У кеша обязательно есть срок. Курс валют на сегодня не меняется целый день, инфляция за год — целый год, погода — полчаса. Срок и есть решение о том, насколько свежими должны быть данные; без него кеш превращается в «данные позапрошлой недели», а это хуже, чем медленно.
Наш кеш живёт в памяти и исчезает вместе с программой. Для сводки, которая запускается раз в день, этого мало — там кеш кладут на диск, как мы уже делали с файлом в одиннадцатом уроке; ключ — адрес запроса, содержимое — ответ и время, когда он получен.
Ещё три вещи, которые делают вас желанным гостем
Подписывайтесь. User-Agent со ссылкой на проект — это возможность написать вам, а не забанить по IP.
Не запрашивайте параллельно то, что можно спросить один раз. Список из двухсот городов, пройденный циклом без паузы, — это двести запросов в секунду; с time.sleep(0.2) между ними — пять, и никто не заметит.
Читайте условия. У многих источников есть страница о том, как часто можно спрашивать. Нацбанк и Всемирный банк отдают данные без ключа — это доверие, а не приглашение.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Что делает программа без таймаута, когда сервер замолчал?
- Почему пауза между повторами удваивается, а не остаётся одинаковой?
- Какие коды ответа повторять бессмысленно и почему?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Что напечатает эта программа?
delay = 0.5
for attempt in range(1, 5):
print(attempt, round(delay, 2))
delay *= 2
2. Заполните пропуск. Вместо ... поставьте условие: повторять стоит только эти коды.
RETRY = (429, 500, 502, 503, 504)
for code in (404, 500, 429, 403):
print(code, "повторяем" if ... else "не повторяем")
3. Почините. Источник сегодня не отвечает, и эта программа будет спрашивать его вечно, без единой паузы. Дайте повторам конец и отступ.
attempts = 0
def fetch():
"""Источник, который сегодня не отвечает."""
global attempts
attempts += 1
raise TimeoutError("timed out")
while True:
try:
print(fetch())
break
except TimeoutError:
continue
Задание
Обязательное. Напишите одну вежливую функцию polite(url) и проверьте её на учебном сервере, который отказывает при первом обращении и отвечает при втором (сервер возьмите из урока — он в вашем же файле).
Функция обязана: ставить timeout; повторять только коды 429, 500, 502, 503, 504, до трёх попыток, с удвоением паузы от 0,1 с; печатать каждую неудачную попытку; складывать успешный ответ в кеш со сроком; после исчерпания попыток поднимать свою ошибку. Прочитайте курс четыре раза подряд и напечатайте, сколько раз при этом побеспокоили сервер.
Ожидаемый вывод:
попытка 1: 503, ждём 0.1 с
1: 510.43 — из сети
2: 510.43 — из кеша
3: 510.43 — из кеша
4: 510.43 — из кеша
запросов к серверу: 2
Готово, когда: вывод совпадает построчно; у запроса есть timeout; 404 и 403 не повторяются вообще; повторы кончаются, а не длятся; четыре чтения дали ровно два обращения к серверу.
На своих данных. Возьмите свой запрос из восемнадцатого урока — курс за фиксированный день — и оберните его этой же функцией. Запустите программу трижды подряд и посмотрите на счётчик обращений: он и есть разница между вежливой программой и невежливой.
По желанию.
- Сделайте кеш файловым: ключ — адрес, содержимое — ответ и время получения.
- Добавьте чтение заголовка
Retry-After, когда сервер отвечает429. - Пройдите список из десяти адресов циклом с
time.sleep(0.2)и без — сравните, сколько запросов в секунду получилось.
Куда это встанет в проекте
Сводка перестаёт быть проблемой для источников. Каждый её запрос теперь ограничен по времени, отказы переживаются повторами с отступом, а повторный запуск в тот же день читает вчерашний ответ с диска, а не идёт в банк заново.
Долги. Кеш у нас в памяти, а сводке нужен на диске — и там же появится вопрос, что делать со старыми записями. К нему вернёмся в следующем уроке: он про то, куда складывать данные, чтобы они пережили выключение.
Ответы
Показать ответы
На вопросы
- Ждёт столько, сколько сервер молчит, — и снаружи это неотличимо от зависания. Таймаут есть всегда, даже если его не написали: тогда он равен «сколько угодно».
- Потому что источник обычно отказывает из-за перегрузки, и одинаково частые повторы добавляют ему нагрузки ровно тогда, когда он и так не справляется. Удвоение даёт ему время встать.
404и403: адреса нет и доступ закрыт — от повтора не изменится ни то, ни другое. Повторяют временное:429,500,502,503,504.
К разминке
1 0.5,2 1.0,3 2.0,4 4.0. Пауза удваивается, и к четвёртой попытке программа ждёт уже четыре секунды — ровно то поведение, которого от неё хочет чужой сервер.
1 0.5
2 1.0
3 2.0
4 4.0
code in RETRY. Список временных кодов заводят один раз и отдельно, чтобы условие читалось словами, а не набором чисел.
RETRY = (429, 500, 502, 503, 504)
for code in (404, 500, 429, 403):
print(code, "повторяем" if code in RETRY else "не повторяем")
404 не повторяем
500 повторяем
429 повторяем
403 не повторяем
- У повторов не было ни конца, ни паузы:
while Trueсcontinue— это запросы без остановки, и они не помогут ни вам, ни источнику. Попыток должно быть считанное число, между ними — растущая пауза, а после — честный ответ, что данных нет:
import time
attempts = 0
def fetch():
"""Источник, который сегодня не отвечает."""
global attempts
attempts += 1
raise TimeoutError("timed out")
delay = 0.1
for attempt in range(1, 4):
try:
print(fetch())
break
except TimeoutError:
time.sleep(delay)
delay *= 2
else:
print("не дождались; попыток:", attempts)
не дождались; попыток: 3
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.