asyncio TaskGroup, timeouts and semaphores tested with pytest-asyncio: working versions (tested 2026-10-15)
Bounded concurrent fetches with asyncio.Semaphore, per-call asyncio.timeout and TaskGroup error handling, tested offline with pytest-asyncio loop scope config and async fixtures.
passed5 passed · 0 failed2026-10-15 13:24 UTC- Status
- passed
- Last passed
- 2026-10-15 13:24 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pytest-asyncio-taskgroup-timeouts") (install), or see them on GitHub.
pytest-asyncio==1.4.0 pytest==9.1.1
import asyncio
from collections.abc import Awaitable, Callable, Iterable
Getter = Callable[[int], Awaitable[int]]
async def run_bounded(items: Iterable[int], get: Getter, limit: int = 3, timeout: float = 1.0) -> list[int]:
items = list(items)
sem = asyncio.Semaphore(limit)
results: dict[int, int] = {}
async def worker(i: int, item: int) -> None:
async with sem: # bounds concurrent calls, not task creation
# timeout starts after the semaphore is acquired, so queue wait is not counted
async with asyncio.timeout(timeout):
results[i] = await get(item)
# first failure cancels siblings and raises an ExceptionGroup
async with asyncio.TaskGroup() as tg:
for i, item in enumerate(items):
tg.create_task(worker(i, item))
return [results[i] for i in range(len(items))]
import asyncio
import pytest
import pytest_asyncio
from workers import run_bounded
class FakeAPI:
def __init__(self):
self.closed = False
self.active = 0
self.peak = 0
async def get(self, item: int) -> int:
self.active += 1
self.peak = max(self.peak, self.active)
try:
await asyncio.sleep(0.01)
if item < 0:
raise ValueError(f"bad item {item}")
return item * 2
finally:
self.active -= 1
async def aclose(self):
self.closed = True
@pytest_asyncio.fixture
async def api():
fake = FakeAPI()
yield fake
await fake.aclose()
@pytest.mark.asyncio
async def test_preserves_input_order(api):
assert await run_bounded([3, 1, 2], api.get) == [6, 2, 4]
@pytest.mark.asyncio
async def test_semaphore_caps_concurrency(api):
await run_bounded(range(10), api.get, limit=3)
assert api.peak == 3
@pytest.mark.asyncio
async def test_failure_cancels_siblings():
cancelled = []
async def flaky(item: int) -> int:
if item == 0:
await asyncio.sleep(0.01) # let siblings start first
raise ValueError("boom")
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
cancelled.append(item)
raise
return item
with pytest.raises(ExceptionGroup) as ei:
await run_bounded([0, 1, 2], flaky, limit=3, timeout=5)
assert [type(e) for e in ei.value.exceptions] == [ValueError]
assert sorted(cancelled) == [1, 2]
@pytest.mark.asyncio
async def test_timeout_surfaces_as_timeouterror_in_group():
async def stuck(item: int) -> int:
await asyncio.sleep(10)
return item
with pytest.raises(ExceptionGroup) as ei:
await run_bounded([1], stuck, timeout=0.05)
assert isinstance(ei.value.exceptions[0], TimeoutError)
@pytest.mark.asyncio
async def test_timeout_excludes_semaphore_wait():
async def slow(item: int) -> int:
await asyncio.sleep(0.03)
return item
# each call takes 0.03s, under the 0.05s timeout, even though the total is 0.09s
assert await run_bounded(range(3), slow, limit=1, timeout=0.05) == [0, 1, 2]
[pytest] pythonpath = . asyncio_mode = strict asyncio_default_fixture_loop_scope = function asyncio_default_test_loop_scope = function
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Async repository pattern with SQLAlchemy 2 AsyncSession on aiosqlite
A typed async repository over SQLAlchemy 2 AsyncSession with eager loading, foreign keys enforced on SQLite, and offline pytest-asyncio tests against an in-memory database.
FastAPI with async SQLAlchemy 2 on aiosqlite, tested offline with per-test in-memory DBs
A FastAPI app using AsyncSession and async_sessionmaker on aiosqlite, tested with httpx.AsyncClient over ASGITransport, overriding the session dependency with a fresh in-memory database for each test.
SQLAlchemy 2 async ORM + aiosqlite with pytest-asyncio
SQLAlchemy 2.x typed async ORM (Mapped, mapped_column, async_sessionmaker, selectinload) on aiosqlite, with pytest-asyncio fixtures.