# Async Code Quality Patterns

When optimizing async code, prefer changes that are both faster AND cleaner. This reference covers patterns the agent should actively apply when already modifying code.

## Always Fix (Correctness/Performance Bugs)

### Swallowed CancelledError

```python
# BAD — task becomes unkillable, TaskGroup hangs
try:
    await long_operation()
except asyncio.CancelledError:
    pass

# GOOD — cleanup then propagate
try:
    await long_operation()
except asyncio.CancelledError:
    await cleanup()
    raise
```

### Fire-and-Forget Tasks

```python
# BAD — GC can destroy task mid-execution
asyncio.create_task(background_work())

# GOOD — keep strong reference
_tasks = set()
task = asyncio.create_task(background_work())
_tasks.add(task)
task.add_done_callback(_tasks.discard)
```

### Unnecessary async def

```python
# BAD — async overhead for pure computation
async def validate_email(email: str) -> bool:
    return "@" in email

# GOOD — sync function, no await needed
def validate_email(email: str) -> bool:
    return "@" in email
```

### Blocking Calls in async

Ruff ASYNC2xx rules catch these. Key ones:
- `open()` in async → `aiofiles.open()` or `run_in_executor` (ASYNC230)
- `time.sleep()` → `asyncio.sleep()` (ASYNC251)
- `requests.*` → async HTTP client (ASYNC210/212)
- `subprocess.*` → `asyncio.create_subprocess_exec()` (ASYNC220-222)

## Apply When Already Changing Code (Python 3.11+)

### TaskGroup over gather

```python
# BEFORE — gather swallows exceptions into results list
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
    if isinstance(r, Exception):
        handle(r)

# AFTER — structured concurrency, automatic cancellation on failure
try:
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(coro) for coro in coroutines]
except* ConnectionError as eg:
    for exc in eg.exceptions:
        handle(exc)
results = [t.result() for t in tasks]
```

### asyncio.timeout over wait_for

```python
# BEFORE
result = await asyncio.wait_for(fetch(url), timeout=10.0)

# AFTER — context manager, wraps multiple awaits, standard TimeoutError
async with asyncio.timeout(10.0):
    result = await fetch(url)
```

### async with for Resource Management

```python
# BEFORE — manual cleanup, leaks on exception between creation and try
session = aiohttp.ClientSession()
try:
    resp = await session.get(url)
    data = await resp.json()
finally:
    await session.close()

# AFTER — guaranteed cleanup even on CancelledError
async with aiohttp.ClientSession() as session:
    async with session.get(url) as resp:
        data = await resp.json()
```

### Flat TaskGroup over Nested gather

```python
# BEFORE — callback hell equivalent
users, orders = await asyncio.gather(
    asyncio.gather(*[fetch_user(uid) for uid in uids]),
    asyncio.gather(*[fetch_order(oid) for oid in oids]),
)

# AFTER — flat, readable, each category named
async with asyncio.TaskGroup() as tg:
    user_tasks = [tg.create_task(fetch_user(uid)) for uid in uids]
    order_tasks = [tg.create_task(fetch_order(oid)) for oid in oids]
users = [t.result() for t in user_tasks]
orders = [t.result() for t in order_tasks]
```

### Semaphore-Bounded Concurrency

```python
# BEFORE — unbounded, exhausts connections/FDs
await asyncio.gather(*[fetch(u) for u in urls])  # 10K URLs = 10K connections

# AFTER — bounded
sem = asyncio.Semaphore(50)
async def limited(url):
    async with sem:
        return await fetch(url)
async with asyncio.TaskGroup() as tg:
    tasks = [tg.create_task(limited(u)) for u in urls]
```

## Resource Lifecycle Patterns

### Factory-as-Context-Manager (not two-phase init)

```python
# BAD — caller must remember start() and stop()
pool = DatabasePool(dsn)
await pool.start()
# ... use pool ...
await pool.stop()

# GOOD — impossible to use uninitialized or forget cleanup
@asynccontextmanager
async def create_db_pool(dsn):
    pool = await asyncpg.create_pool(dsn)
    try:
        yield pool
    finally:
        await pool.close()

async with create_db_pool(dsn) as pool:
    await pool.execute(...)
```

### FastAPI Lifespan (deprecated on_event)

```python
# BAD — deprecated, globals, unpaired setup/teardown
@app.on_event("startup")
async def startup():
    global db_pool
    db_pool = await create_pool(DSN)

# GOOD — paired in one function, uses app.state
@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db_pool = await create_pool(DSN)
    yield
    await app.state.db_pool.close()

app = FastAPI(lifespan=lifespan)
```

### AsyncExitStack for Dynamic Resources

```python
# For variable numbers of context managers:
async with AsyncExitStack() as stack:
    handles = [await stack.enter_async_context(aiofiles.open(p)) for p in paths]
    # All handles closed in reverse order, even if one close() raises
```

## Separate I/O from Logic

```python
# BAD — business logic entangled with I/O
async def process_order(order_id):
    order = await db.fetch_order(order_id)
    if order.total > 1000:
        order.total -= order.total * 0.1
        await db.save_order(order)

# GOOD — pure function testable without mocking
def apply_discount(order: Order) -> Order:
    if order.total > 1000:
        return order.with_discount(order.total * 0.1)
    return order

async def process_order(order_id):
    order = await db.fetch_order(order_id)
    updated = apply_discount(order)
    if updated != order:
        await db.save_order(updated)
```
