Python asyncio skill for Codex

Concurrency that cancels cleanly and never blocks. Codex scans async def bodies for blocking calls with a bundled ast-based script before touching anything, then restructures fan-out work into TaskGroups with semaphores or queue-fed worker pools. It handles ExceptionGroups with except*, keeps CancelledError propagating, stores references to background tasks, and proves the speedup with timings instead of assuming it.

  • Languages
  • $python-asyncio
  • 209 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- python-asyncio

Installs to ~/.agents/skills/python-asyncio/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $python-asyncio, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and fixes Python asyncio code - TaskGroup structured concurrency, timeouts, cancellation that propagates correctly, bounded concurrency with semaphores and queues, offloading blocking calls with to_thread, and debugging stuck or slow event loops. Use when async Python code hangs, leaks tasks, swallows exceptions, runs slower than sync code, mixes requests or time.sleep into async def, or when the user asks to parallelize I/O with asyncio.

Use it when

  • Async Python code hangs, leaks tasks or loses exceptions
  • You need bounded concurrent I/O over many items
  • Timeouts or cancellation do not stop work the way you expect
  • Blocking libraries are being called from coroutines

Use something else when

  • The code is slow because of CPU work or you need to find where time goes: use python-profiling
  • You are designing FastAPI routes and dependencies rather than concurrency: use fastapi-services

How to check the result

The bundled blocking-call scanner exits clean, a debug-mode run shows no slow-callback warnings on the hot path, and timings show requests overlapping.

The mistake it prevents

Calling requests or time.sleep inside async def, which freezes every other coroutine on the loop while it waits.

~/.agents/skills/python-asyncio/SKILL.md

--- name: python-asyncio description: Writes and fixes Python asyncio code - TaskGroup structured concurrency, timeouts, cancellation that propagates correctly, bounded concurrency with… license: MIT ---

Done well means: every task has an owner that awaits it, cancellation and timeouts reach every coroutine, concurrency is bounded, and nothing in an async def blocks the loop for more than a few milliseconds.

Before changing anything

  • Read AGENTS.md and requires-python. TaskGroup and asyncio.timeout() need 3.11+; on 3.10 use anyio task groups or asyncio.wait_for instead.
  • Identify the framework that owns the loop (FastAPI/Starlette, aiohttp, Django async, a worker). Never call asyncio.run() inside code that already runs in a loop.
  • Check whether the project uses anyio or trio. If so, write anyio APIs, not raw asyncio.
  • Run the blocking-call scanner over the code you will touch:
python3 scripts/find_blocking_calls.py src/

It parses files with ast and lists time.sleep, requests.*, subprocess.run, open(), sync DB drivers and similar calls made directly inside async def. Exit code 1 means findings. It cannot see blocking calls hidden inside your own sync helpers; follow those by hand.

Decision table

NeedUseAvoid
Run N coroutines, fail fast if one failsasyncio.TaskGroupgather without handling the rest
Run N, collect every result and errorgather(*aws, return_exceptions=True) then inspectIgnoring the exception objects in the result list
Deadline on a block of awaitsasync with asyncio.timeout(5):wait_for around each call separately
Limit concurrent requestsasyncio.Semaphore(n) or a worker pool over asyncio.QueueCreating 10,000 tasks at once
Call blocking library codeawait asyncio.to_thread(fn, *args)Calling it directly in async def
CPU-heavy workloop.run_in_executor(ProcessPoolExecutor(), fn, arg)Threads (GIL) unless on free-threaded 3.13t+
Fire-and-forget background workA long-lived TaskGroup owned by the app lifespan, or a real job queueBare asyncio.create_task() with no stored reference
Sync code needs one async resultasyncio.run(coro()) at the top level onlyloop.run_until_complete inside running loops

Structured concurrency with TaskGroup

import asyncio
import httpx

async def fetch_all(urls: list[str], limit: int = 10) -> dict[str, int]:
    sem = asyncio.Semaphore(limit)
    results: dict[str, int] = {}

    async with httpx.AsyncClient(timeout=10) as client:
        async def fetch(url: str) -> None:
            async with sem:
                r = await client.get(url)
                results[url] = r.status_code

        async with asyncio.TaskGroup() as tg:
            for url in urls:
                tg.create_task(fetch(url))
    return results
  • When any task raises, the group cancels the others and raises an ExceptionGroup. Handle it with except*:
try:
    await fetch_all(urls)
except* httpx.TimeoutException as eg:
    log.warning("timeouts: %d", len(eg.exceptions))
except* httpx.HTTPError as eg:
    raise RuntimeError("fetch failed") from eg
  • One shared client per group, not one per request: connection pooling is most of the speedup.
  • For thousands of items, prefer a fixed pool of workers reading from a Queue over one task per item, so memory stays flat.

Worker pool over a queue

async def worker(q: asyncio.Queue[str], out: list[str]) -> None:
    while True:
        item = await q.get()
        try:
            out.append(await process(item))
        finally:
            q.task_done()

async def run(items: list[str], n: int = 8) -> list[str]:
    q: asyncio.Queue[str] = asyncio.Queue(maxsize=n * 2)
    out: list[str] = []
    async with asyncio.TaskGroup() as tg:
        workers = [tg.create_task(worker(q, out)) for _ in range(n)]
        for item in items:
            await q.put(item)          # backpressure via maxsize
        await q.join()
        for w in workers:
            w.cancel()
    return out

On 3.13+, q.shutdown() replaces the cancel loop and makes get() raise QueueShutDown.

Cancellation rules

  • asyncio.CancelledError derives from BaseException. except Exception does not catch it; a bare except: or except BaseException: does and must re-raise.
  • Clean up in finally, then let cancellation propagate. Never return from a handler that caught CancelledError unless you also call asyncio.current_task().uncancel() and know why.
  • asyncio.timeout() turns its internal cancellation into TimeoutError at the async with boundary. Catch TimeoutError, not CancelledError, around it.
  • Cleanup that must finish even under cancellation (closing a transaction, releasing a lock in an external system): await asyncio.shield(cleanup()), and still bound it with a timeout.
  • Long CPU loops inside a coroutine never see cancellation; add await asyncio.sleep(0) per batch, or move the work to an executor.
async def handler(conn) -> None:
    try:
        async with asyncio.timeout(30):
            await conn.process()
    except TimeoutError:
        await conn.send_error("timeout")
    finally:
        await conn.close()

Background tasks without leaks

The loop keeps only weak references to tasks. An unreferenced create_task() result can be garbage collected mid-flight, and its exception is only logged at GC time.

background: set[asyncio.Task[None]] = set()

def spawn(coro) -> None:
    task = asyncio.create_task(coro)
    background.add(task)
    task.add_done_callback(background.discard)

Better: create the TaskGroup in the application lifespan (FastAPI lifespan, aiohttp cleanup_ctx) and spawn into it, so shutdown waits for or cancels in-flight work.

Mixing sync and async

DirectionPattern
async calls blocking functionawait asyncio.to_thread(fn, arg); default pool is min(32, cpu+4) threads
sync code calls coroutine, no loop runningasyncio.run(coro()) once at the entry point
sync thread calls into the running loopasyncio.run_coroutine_threadsafe(coro, loop).result(timeout=5)
Library has both APIsUse the async one (httpx.AsyncClient, asyncpg, psycopg async, redis.asyncio)

asyncio.Lock, Event and Queue are not thread-safe. Across threads use loop.call_soon_threadsafe.

Debugging a hung or slow loop

PYTHONASYNCIODEBUG=1 python app.py      # logs callbacks slower than 100 ms and never-awaited coroutines
python -X dev app.py                    # dev mode also enables asyncio debug
python -m asyncio pstree <PID>          # 3.14+: live task tree of a running process
  • asyncio.all_tasks() plus task.print_stack() in a debug endpoint shows where each task waits.
  • “Task was destroyed but it is pending” means a lost reference or a loop closed with running tasks.
  • “coroutine was never awaited” means a missing await; the call did nothing.
  • Throughput no better than sequential: look for a shared lock, a per-request client, a blocking call, or a semaphore set to 1.

Testing

import pytest

@pytest.mark.asyncio            # pytest-asyncio; or use anyio's pytest plugin
async def test_fetch_all_limits_concurrency(httpx_mock):
    ...
  • Assert timeouts with a tiny budget (asyncio.timeout(0.01)) rather than sleeping for real.
  • Test cancellation explicitly: start the task, task.cancel(), await it inside pytest.raises(asyncio.CancelledError), then assert cleanup ran.

Gotchas

  • gather() without return_exceptions=True raises the first error but leaves the other tasks running unsupervised.
  • async for over a sync generator is a TypeError; sync iteration of a huge list inside a coroutine is legal and blocks the loop.
  • DNS resolution, logging handlers writing to slow disks, and json.loads on 50 MB bodies all block. Measure before assuming the network is the bottleneck.
  • asyncio.get_event_loop() in a coroutine: use asyncio.get_running_loop().
  • Never share one AsyncClient or DB pool across different event loops (common in tests that create a loop per test).

Verification

  • python3 scripts/find_blocking_calls.py <changed dirs> exits 0, or every remaining hit is justified (startup code, a CLI path).
  • Run once with PYTHONASYNCIODEBUG=1: no slow-callback warnings above 100 ms on the hot path, no never-awaited warnings.
  • A timing check proves the concurrency: N requests take close to max(latency) * ceil(N/limit), not sum(latency).
  • Tests cover the failure path: one task raising cancels its siblings and surfaces the error.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Python asyncio"
  short_description: "Concurrency that cancels cleanly and never blocks"
  default_prompt: "$python-asyncio This async scraper is no faster than the sync version, figure out why"
scripts/find_blocking_calls.py115 lines, 4.1 KB
#!/usr/bin/env python3
"""Flag blocking calls made directly inside `async def` bodies.

Usage: python3 find_blocking_calls.py <path> [<path> ...]

Walks .py files, parses them with ast (no imports executed), and reports calls
that block the event loop when made from a coroutine: time.sleep, requests.*,
urllib.request.urlopen, subprocess.run/call/check_output, socket.create_connection,
sync file I/O via open(), input(), and common sync DB drivers. Calls inside
nested sync functions or lambdas are ignored, as are calls wrapped in
asyncio.to_thread / loop.run_in_executor (the callable is passed, not called).

Exit code: 0 when nothing is found, 1 when findings exist, 2 on usage errors.
"""
from __future__ import annotations

import ast
import sys
from pathlib import Path

BLOCKING_EXACT = {
    "time.sleep": "use await asyncio.sleep()",
    "urllib.request.urlopen": "use an async HTTP client (httpx.AsyncClient, aiohttp)",
    "subprocess.run": "use asyncio.create_subprocess_exec()",
    "subprocess.call": "use asyncio.create_subprocess_exec()",
    "subprocess.check_call": "use asyncio.create_subprocess_exec()",
    "subprocess.check_output": "use asyncio.create_subprocess_exec()",
    "os.system": "use asyncio.create_subprocess_shell() or exec()",
    "socket.create_connection": "use asyncio.open_connection()",
    "open": "wrap file I/O in asyncio.to_thread() or use aiofiles/anyio",
    "input": "read stdin via a thread (asyncio.to_thread(input))",
    "psycopg2.connect": "use psycopg (v3) async or asyncpg",
    "pymysql.connect": "use aiomysql/asyncmy",
    "sqlite3.connect": "use aiosqlite or asyncio.to_thread()",
}
BLOCKING_PREFIX = {
    "requests.": "use httpx.AsyncClient or aiohttp",
}


def dotted(node: ast.AST) -> str | None:
    parts: list[str] = []
    while isinstance(node, ast.Attribute):
        parts.append(node.attr)
        node = node.value
    if isinstance(node, ast.Name):
        parts.append(node.id)
        return ".".join(reversed(parts))
    return None


class Finder(ast.NodeVisitor):
    def __init__(self, path: Path) -> None:
        self.path = path
        self.async_depth = 0
        self.findings: list[str] = []

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self.async_depth += 1
        self.generic_visit(node)
        self.async_depth -= 1

    def _sync_scope(self, node: ast.AST) -> None:
        saved, self.async_depth = self.async_depth, 0
        self.generic_visit(node)
        self.async_depth = saved

    visit_FunctionDef = _sync_scope  # type: ignore[assignment]
    visit_Lambda = _sync_scope  # type: ignore[assignment]

    def visit_Call(self, node: ast.Call) -> None:
        if self.async_depth:
            name = dotted(node.func)
            hint = None
            if name in BLOCKING_EXACT:
                hint = BLOCKING_EXACT[name]
            elif name:
                for prefix, h in BLOCKING_PREFIX.items():
                    if name.startswith(prefix):
                        hint = h
            if hint:
                self.findings.append(f"{self.path}:{node.lineno}:{node.col_offset + 1}: {name}() in async def -> {hint}")
        self.generic_visit(node)


def iter_files(paths: list[str]):
    for p in map(Path, paths):
        if p.is_dir():
            yield from (f for f in p.rglob("*.py") if ".venv" not in f.parts and "node_modules" not in f.parts)
        elif p.suffix == ".py":
            yield p


def main(argv: list[str]) -> int:
    if not argv:
        print(__doc__.strip().splitlines()[2], file=sys.stderr)
        return 2
    findings: list[str] = []
    for f in iter_files(argv):
        try:
            tree = ast.parse(f.read_text(encoding="utf-8"), filename=str(f))
        except (SyntaxError, UnicodeDecodeError) as exc:
            print(f"{f}: skipped ({exc.__class__.__name__})", file=sys.stderr)
            continue
        finder = Finder(f)
        finder.visit(tree)
        findings.extend(finder.findings)
    for line in findings:
        print(line)
    print(f"{len(findings)} blocking call(s) found in async code", file=sys.stderr)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem