Shanraq.org Shanraq.org
Вежливость к чужому серверу: таймаут, повтор с отступом и кеш
IT

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. Что делает программа без таймаута, когда сервер замолчал?
  2. Почему пауза между повторами удваивается, а не остаётся одинаковой?
  3. Какие коды ответа повторять бессмысленно и почему?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

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) и без — сравните, сколько запросов в секунду получилось.

Куда это встанет в проекте

Сводка перестаёт быть проблемой для источников. Каждый её запрос теперь ограничен по времени, отказы переживаются повторами с отступом, а повторный запуск в тот же день читает вчерашний ответ с диска, а не идёт в банк заново.

Долги. Кеш у нас в памяти, а сводке нужен на диске — и там же появится вопрос, что делать со старыми записями. К нему вернёмся в следующем уроке: он про то, куда складывать данные, чтобы они пережили выключение.

Ответы

Показать ответы

На вопросы

  1. Ждёт столько, сколько сервер молчит, — и снаружи это неотличимо от зависания. Таймаут есть всегда, даже если его не написали: тогда он равен «сколько угодно».
  2. Потому что источник обычно отказывает из-за перегрузки, и одинаково частые повторы добавляют ему нагрузки ровно тогда, когда он и так не справляется. Удвоение даёт ему время встать.
  3. 404 и 403: адреса нет и доступ закрыт — от повтора не изменится ни то, ни другое. Повторяют временное: 429, 500, 502, 503, 504.

К разминке

  1. 1 0.5, 2 1.0, 3 2.0, 4 4.0. Пауза удваивается, и к четвёртой попытке программа ждёт уже четыре секунды — ровно то поведение, которого от неё хочет чужой сервер.
1 0.5
2 1.0
3 2.0
4 4.0
  1. 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 не повторяем
  1. У повторов не было ни конца, ни паузы: 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

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.