Static analysis of blocking calls in async Python

This is the companion piece to AsyncIO’s blocking call problem. If you haven’t read that one, the short version is: one sync call inside an async def can quietly stall a FastAPI worker, and the existing toolchain only catches the easy cases[1]Specifically: they catch direct blocking calls, not nested ones..

What I wanted was a linter that catches the hard case before it ever reaches a production dashboard. That linter exists now, in early alpha[2]As in, don’t put it on your CI yet without a plan for false positives., and is called strato – repo at github.com/tasercake/strato, docs at strato.penukonda.me.

What Strato does

One thing: it flags transitive blocking calls from async contexts.

flake8-async and ruff’s ASYNC rules already catch the direct case[3]flake8-async and ruff’s ASYNC rules – both good, both deliberately scoped to direct calls.time.sleep(1) sitting inside an async def. They don’t catch the version that actually shows up in real codebases:

async def handler():
    do_the_thing()  # innocent-looking call to a sync helper
 
def do_the_thing():
    # ... 200 lines later, deep in some library ...
    requests.get("https://example.com")  # this is the bug

do_the_thing() is a fine synchronous function. Calling it from handler() is the bug, because somewhere down its call graph it reaches a blocking primitive. Strato follows that graph and points at the call site that introduced the problem.

That’s it. Everything else strato does is in service of that one diagnostic being correct.

How it works

Strato is built on top of Astral’s ty[4]A Rust-based type checker for Python. Strato is also Rust, mostly because that’s where the Python static-analysis ecosystem is converging. – the same type inference engine that’s going to underpin ruff’s deeper analyses. ty answers the hard parts strato can’t answer alone: which import does this name resolve to, what’s the type of this method receiver, which function does this obj.method() actually call. Without that, transitive analysis is guesswork.

On top of ty, strato does roughly three things:

  1. Seed. Start with a curated database of known blocking functions: time.sleep, requests.*, socket.recv, and so on. The current set is small and intentional[5]61 entries at time of writing, covering I/O, sync primitives, sleeps/waits, subprocess, and the common DB drivers. os.getpid and friends are excluded – yes they technically block, no they don’t matter.; the alternative is an unmaintainable long tail.
  2. Build the call graph for the project being analyzed, using ty to resolve calls across modules and methods.
  3. Propagate a “this function blocks” effect upward. Any function whose body reaches a seeded blocking call inherits the effect. Effects keep flowing until they hit an explicit offload point – asyncio.to_thread, loop.run_in_executor, anyio.to_thread.run_sync – at which point the effect is consumed and the parent function is fine.

A diagnostic fires when a function carrying the blocking effect is called from inside an async def without going through one of those offload points.

Escape hatches

Static analysis is wrong sometimes, and “wrong sometimes” makes a linter unusable if you can’t override it. Strato exposes three decorators from the strato runtime package[6]The package is pure-Python, type-stub-friendly, and has zero runtime cost. The decorators just return the function unchanged.:

from strato import blocking, non_blocking, unblocker
 
@blocking
def reads_a_huge_file(path: str) -> bytes:
    # Strato can't see the I/O (maybe it's behind a C extension or
    # a metaclass), so I'm telling it: treat this as blocking.
    ...
 
@non_blocking
def looks_scary_but_isnt() -> None:
    # Strato thinks this blocks. It doesn't. Trust me.
    ...
 
@unblocker
def my_threadpool_wrapper(fn, *args):
    # This function offloads its callable. Don't propagate the
    # blocking effect through it.
    ...

The decorators are no-ops at runtime; they exist purely as annotations strato can pick up during analysis. They’re the right answer when:

  • you’ve vendored or wrapped a C extension that strato can’t see into,
  • you have a metaprogramming-heavy codebase where ty can’t resolve types,
  • you’ve built your own offload primitive and want strato to recognise it.

For one-off suppressions there’s the usual # strato: ignore comment, but if you find yourself reaching for it more than once or twice, an annotation on the function itself is almost always the right move.

Output

Strato runs as a CLI:

cargo run -p strato_cli -- check path/to/your/project

Default output is human-readable diagnostics with source spans. For tooling, there’s --output json and --output sarif. SARIF[7]Static Analysis Results Interchange Format. Boring name, useful format. is a standard JSON-ish format for static analysis results that GitHub code-scanning, VS Code’s Problems panel, and most CI dashboards understand natively – if you want strato findings to show up as PR annotations or inline editor squiggles without writing custom glue, that’s the path.

Status

v0.1.0, early alpha. The transitive case works on the test fixtures; getting it to behave well on real codebases is the current focus.

If you’ve stared at a FastAPI throughput graph wondering which call ate the loop[8]With no corresponding CPU spike. You know the one., this is what I’m building for you. Bug reports, “here’s the blocking call ruff missed” stories, and weird codebases to break strato against are all welcome.