CLODEx.
ПРАКТИКА / CLODEx

Таймаут API в Python: как не отправить платный запрос повторно

Клиент не получил ответ вовремя — но это ещё не означает, что сервер не обработал запрос. Разберём консервативную политику: сохранять попытку до отправки, не повторять её автоматически после таймаута и явно показывать неопределённый результат. Пример выполняется локально на Python и не обещает серверную идемпотентность, отмену операции или возврат денег.

Редакционная проверка · / Практические руководства

Что именно означает таймаут

Connect timeout относится к установлению соединения, read timeout — к ожиданию данных после подключения. Разные библиотеки разделяют эти ограничения по-разному; у urllib.request параметр timeout задаёт таймаут блокирующих операций, а не отдельные connect/read бюджеты. Это не гарантированный общий deadline всей бизнес-операции. Если клиент ждал ответ, запрос мог уже дойти до сервера. Даже ошибка подключения сама по себе не описывает всю цепочку прокси и предыдущих попыток. В этом примере любой TimeoutError приводит к состоянию unknown: результата недостаточно ни для вывода «выполнено», ни для вывода «точно отменено». HTTP-ошибка, обрыв потока и таймаут не должны превращаться в один пустой текст.

Явные состояния вместо незаметного повтора

До сетевого вызова записываем in_flight и завершаем транзакцию. После успешного возврата transport отмечаем completed. При TimeoutError отмечаем unknown. Если процесс завершился аварийно между записью и получением результата, сохранённый in_flight тоже требует выяснения, а не повторной отправки. Одна и та же пользовательская операция должна сохранять operation_id при повторном клике и перезапуске процесса. Новый случайный id на каждом retry обходит защиту. Первичный ключ SQLite не даёт двум вызовам с одинаковым id одновременно получить право отправки. Это ограничение действует только там, где используется один и тот же журнал; независимые базы на разных серверах не образуют распределённую блокировку.

Рабочий пример: сначала журнал, затем transport

Сохраните код в timeout_once.py. Используйте отдельную учебную SQLite-базу, не рабочую БД приложения. Функция send — ваш адаптер транспорта: он обязан делать одну попытку и переводить таймаут выбранной библиотеки в TimeoutError. Если внутри адаптера или SDK остались retry, внешний журнал их не отменит. В журнал пишутся только технический id и состояние. Ключ API, промпт, полный ответ и текст исключения здесь не сохраняются. completed означает, что transport вернулся; это ещё не доказательство корректности содержимого ответа. Валидацию результата выполняют отдельно. Не ловите все исключения в вызывающем коде с последующим безусловным retry.

python
"""Teaching example: one durable attempt, not server-side idempotency."""
from contextlib import closing
import re
import sqlite3


def execute_once(database, operation_id, send):
    if not re.fullmatch(r"[a-zA-Z0-9_-]{1,80}", operation_id):
        raise ValueError("invalid operation id")
    with closing(sqlite3.connect(database, timeout=5)) as db:
        db.execute("CREATE TABLE IF NOT EXISTS attempts "
                   "(id TEXT PRIMARY KEY, state TEXT NOT NULL)")
        try:
            with db:
                db.execute("INSERT INTO attempts VALUES (?, 'in_flight')",
                           (operation_id,))
        except sqlite3.IntegrityError:
            state = db.execute("SELECT state FROM attempts WHERE id = ?",
                               (operation_id,)).fetchone()[0]
            return "existing:" + state, None

        # The durable reservation is committed BEFORE entering the transport.
        # The transport must itself be configured without retries.
        try:
            result = send()
        except TimeoutError:
            with db:
                db.execute("UPDATE attempts SET state = 'unknown' WHERE id = ?",
                           (operation_id,))
            return "unknown", None
        except BaseException:
            with db:
                db.execute("UPDATE attempts SET state = 'unknown' WHERE id = ?",
                           (operation_id,))
            raise
        with db:
            db.execute("UPDATE attempts SET state = 'completed' WHERE id = ?",
                       (operation_id,))
        return "completed", result

Проверка: два вызова, но одна отправка

Следующий тест не использует сеть и настоящий ключ. Искусственный transport всегда выбрасывает TimeoutError. Первый вызов вернёт unknown, второй прочитает existing:unknown из той же базы. Счётчик должен остаться равным единице; это наблюдаемое свойство примера, а не утверждение о поведении production API.

python
import tempfile
from pathlib import Path
from unittest.mock import Mock
from timeout_once import execute_once

with tempfile.TemporaryDirectory() as folder:
    db = str(Path(folder) / "demo-attempts.sqlite")
    send = Mock(side_effect=TimeoutError())
    assert execute_once(db, "operation-1", send) == ("unknown", None)
    assert execute_once(db, "operation-1", send) == ("existing:unknown", None)
    send.assert_called_once_with()
print("PASS: one transport call")

Как сообщать о неопределённом результате

Покажите пользователю, что исход ещё не установлен, и сохраните тот же идентификатор операции для разбирательства. Не сообщайте «сервер отменил запрос» или «деньги возвращены» без отдельного подтверждения. Если документированный API позволяет получить сохранённый результат или статус по идентификатору, используйте именно этот контракт; существование такого метода нельзя предполагать по одному заголовку запроса. Повтор после выяснения исхода — отдельное осознанное действие с понятным риском второй обработки. Пример намеренно не удаляет unknown автоматически. В production нужен процесс сверки, доступный оператору, а не таймер, превращающий все неопределённые записи обратно в новые попытки.

Границы гарантии и что проверить дальше

Журнал не доказывает exactly-once выполнение у поставщика, не обеспечивает доставку и не сохраняет результат для последующего восстановления. Авария после получения ответа, но до записи completed оставит in_flight; повтор всё равно будет заблокирован. Для важных задач нужен отдельно спроектированный защищённый storage результатов и срок хранения журнала. Не удаляйте его записи раньше допустимого окна повторных обращений. Отдельно протестируйте конкретный клиент: отключение его retries, типы timeout-исключений, поведение при прерывании процесса, повторном клике и конкурентной обработке. Здесь проверяется политика приложения на fake transport. Ни один платный запрос и ни одна проверка биллинга поставщика этим примером не выполняются.

Источники и проверка