Асинхронный сервис легко принимает больше работы, чем может сделать: принять соединение дёшево, а ждать можно бесконечно. Без таймаутов одна зависшая зависимость постепенно собирает в себе все задачи процесса, без ограничений параллелизма всплеск трафика превращается в тысячу одновременных запросов к базе. Разберём два инструмента, которые не дают сервису захлебнуться: ограничение времени и ограничение количества.
Каждый await наружу имеет предел
Таймаут нужен на каждом ожидании внешней системы: HTTP, база, кеш, брокер, блокировка. Без него задача может ждать вечно, а вместе с ней соединение из пула, память запроса и терпение клиента, который давно ушёл. Клиенты дают свои таймауты (httpx, asyncpg, redis), но они покрывают одну операцию, а обработчик делает несколько. Для ограничения целого участка кода есть asyncio.timeout:
async def get_order_view(order_id: int) -> OrderView:
async with asyncio.timeout(1.5):
order = await repo.get(order_id)
customer = await customers.get(order.customer_id)
rates = await fx.rates()
return build_view(order, customer, rates)
Если три вызова вместе не уложились в полторы секунды, текущий await отменяется, и блок поднимает встроенный TimeoutError. Проверено на Python 3.14: asyncio.timeout поднимает именно builtins.TimeoutError, а не отдельный класс из asyncio.
Чем это лучше wait_for: один блок на несколько await вместо обёртки вокруг каждого; дедлайн можно сдвигать по ходу через cm.reschedule(loop.time() + 2), что проверено, и проверять, сработал ли он, через cm.expired(); и отмена внутри реализована через механизм cancelling, поэтому TimeoutError не путается с внешней отменой задачи. wait_for остаётся для одиночного ожидания, когда нужен результат корутины одной строкой.
Бюджет времени на запрос
Таймауты внутри обработчика должны складываться в бюджет, а не назначаться по отдельности. Если обработчик обещает ответ за две секунды, а внутри три вызова по две секунды каждый, обещание нарушено при первом же медленном соседе. Бюджет считают от внешнего: клиент ждёт 3 секунды, значит, обработчик укладывается в 2,5, из них на базу 0,5, на соседа 1, на кеш 0,1, и остаток на себя. Внешний asyncio.timeout на весь обработчик страхует от ошибки в арифметике.
Бюджет передают дальше: если осталось 400 миллисекунд, нет смысла звать соседа с таймаутом в секунду. Остаток считают от дедлайна (deadline = loop.time() + budget в начале, remaining = deadline - loop.time() перед каждым вызовом) и передают соседу в заголовке, чтобы он тоже не работал зря. Это та же идея, что контекст с дедлайном в Go и gRPC; статья про таймауты на Python показывает, как это оформить в клиентском слое.
Семафор на соседа: ограничить количество
Таймаут ограничивает одно ожидание, но не защищает от тысячи одновременных ожиданий. Для этого ограничивают параллелизм на каждую внешнюю систему: asyncio.Semaphore вокруг вызова или размер пула клиента, что по сути то же самое.
_catalog_slots = asyncio.Semaphore(20)
async def get_product(product_id: int) -> Product:
async with asyncio.timeout(0.2): # ждать слот недолго
await _catalog_slots.acquire()
try:
async with asyncio.timeout(1.0):
response = await http.get(f"/products/{product_id}")
finally:
_catalog_slots.release()
return Product.model_validate(response.json())
Здесь два таймаута делают разное: короткий на ожидание слота означает «если двадцать запросов уже ждут каталог, не вставай в очередь, откажи сразу», длинный на сам вызов. Это защищает каталог от лавины и защищает ваш сервис от накопления задач, которые ждут занятого соседа. Такой лимит это переборка (bulkhead), и его размещение в архитектуре разбирает статья про изоляцию по системам.
Семафор действует внутри процесса; при четырёх воркерах uvicorn соседа защищают восемьдесят слотов, и это нужно учитывать при расчёте. Общий лимит на все поды делают на стороне принимающего (его пул, его ограничитель) или в Redis.
Ограниченные очереди: обратное давление в конвейере
Если производитель быстрее потребителей, неограниченная очередь растёт до исчерпания памяти. asyncio.Queue(maxsize=N) замедляет производителя: await queue.put ждёт, пока потребитель не освободит место. Это и есть обратное давление: скорость системы задаётся самым медленным звеном явно, а не выясняется из падения по памяти. Для потребителя Kafka это означает читать пачку, обработать с ограниченным параллелизмом, зафиксировать смещения, и только потом читать следующую, а не складывать всё прочитанное в очередь без дна. Как это устроено, рассказывает статья про потребителя Kafka.
Быстрый отказ вместо очереди ожидания
Когда нагрузка выше возможностей, у сервиса два варианта: принимать всё и отвечать всем медленно, пока не упадёт, или принимать столько, сколько может, и остальным отвечать быстро 503 с Retry-After. Второй вариант лучше для всех: клиенты, которым отказали, повторят позже или деградируют, а те, кого приняли, получат ответ вовремя. Реализация это уже знакомые инструменты: семафор на число одновременных запросов в обработке с коротким таймаутом ожидания слота, и ответ 503, когда слот не достался.
_inflight = asyncio.Semaphore(200)
@app.middleware("http")
async def shed_load(request: Request, call_next):
try:
async with asyncio.timeout(0.05):
await _inflight.acquire()
except TimeoutError:
return JSONResponse({"detail": "overloaded"}, status_code=503, headers={"Retry-After": "1"})
try:
return await call_next(request)
finally:
_inflight.release()
Число 200 выводят из измерений: сколько одновременных запросов сервис обрабатывает с приемлемой задержкой на одном воркере. У uvicorn есть похожий флаг --limit-concurrency, который отвечает 503 при превышении, но без ожидания слота; собственная прослойка даёт контроль над тем, какие маршруты защищать и что отвечать.
Что делать с тем, что не успели
Таймаут это не только исключение, это решение, что делать дальше. Варианты по убыванию предпочтительности: вернуть ответ без необязательной части (каталог не ответил, отдаём заказ без картинок товаров); отдать данные из кеша с пометкой о свежести; повторить один раз, если операция идемпотентна и бюджет позволяет; отказать с понятной ошибкой и Retry-After. Чего не делать: повторять в цикле без предела и без задержки, превращая медленного соседа в лежащего. Повторы и запасные варианты в деталях разбирает статья про устойчивость на Python.
Глубже: отмена по таймауту и что остаётся позадирасширенное
Когда asyncio.timeout отменяет await http.get(...), httpx закрывает соединение, и запрос к соседу обрывается на сетевом уровне; сосед, если он написан правильно, заметит разрыв и прекратит работу, а если нет, доделает её впустую. С базой сложнее: отмена await conn.fetch(...) в asyncpg посылает серверу запрос на отмену выполняющегося запроса, и соединение возвращается в пул пригодным; но если отмена застала транзакцию между запросами, транзакция откатывается при возврате соединения в пул. Это нужно знать, чтобы не строить логику на том, что «после таймаута данные записались наполовину»: внутри одной транзакции они не записались вовсе, а между транзакциями могли записаться полностью.
Операции, которые нельзя оборвать, в бюджет таймаута не включают: фиксацию платежа защищают asyncio.shield или выносят за границу таймаута, а ещё лучше отдают outbox, где у операции нет внешнего ожидания вообще. И про измерение: долю запросов, завершённых по таймауту, считают по каждому соседу отдельно; рост этой доли это ранний сигнал деградации соседа, который виден раньше, чем его собственные алерты.
Коротко
- Каждое ожидание внешней системы имеет таймаут;
asyncio.timeoutограничивает блок из несколькихawaitи поднимает встроенныйTimeoutError. asyncio.timeoutумеетrescheduleиexpired, не путает таймаут с внешней отменой;wait_forдля одиночного ожидания.- Таймауты складываются в бюджет от внешнего SLA; остаток бюджета передают соседу, внешний таймаут на весь обработчик страхует арифметику.
- Семафор на каждого соседа с коротким таймаутом ожидания слота: не вставать в очередь к занятому соседу; лимит действует на процесс, при нескольких воркерах умножается.
Queue(maxsize=N)замедляет производителя до скорости потребителей; потребитель Kafka читает следующую пачку только после обработки предыдущей.- Под перегрузкой лучше быстрый 503 с
Retry-Afterдля части запросов, чем медленные ответы всем; семафор на запросы в обработке в middleware. - После таймаута решают, что делать: ответ без необязательной части, кеш, один повтор для идемпотентной операции, отказ; не повторять без предела.
- Отмена по таймауту обрывает запрос к соседу и откатывает незавершённую транзакцию; неделимые операции выносят за границу таймаута или в outbox.
Что почитать дальше
- Синхронизация — семафоры и очереди как примитивы, из которых собрано обратное давление.
- Таймауты на Python — бюджет и дедлайн в клиентском слое сервиса.
- Устойчивость на Python — повторы, предохранители и запасные варианты поверх таймаутов.