Skip to main content

Runtime anti-pattern scanner for async Python — detects what AI-generated code gets wrong

Project description

python-vibe-guard

tests

The incident

A FastAPI service that handled 50 concurrent requests in staging started timing out in production at 200 rps. The team spent two days adding replicas, tweaking Gunicorn workers, and profiling CPU — nothing helped. The p99 latency was 8 seconds for an endpoint that should take 80ms.

Scenario based on a common pattern observed in async Python codebases. Reproduced in controlled testing with locust against a FastAPI service.

Root cause: one engineer had asked an AI assistant to "add a retry with backoff" to an async handler. The AI generated time.sleep(2) inside the async def. In staging with a handful of requests it was invisible. In production it froze the entire event loop for 2 seconds per request, serializing all 200 concurrent calls through a single bottleneck.

The code passed every unit test. It passed the integration tests. It shipped to production in a Friday deploy.

python-vibe-guard catches this in CI before it reaches staging.


What it detects

Twenty patterns that AI models generate repeatedly, that pass all static checks, and that silently destroy async performance under real load:

A. Event Loop Blocking

Synchronous calls inside async def — each stalls the event loop for its entire duration, serializing all concurrent requests.

Rule Pattern Gate Runtime effect
PYVIBE-001 time.sleep() inside async def async def Freezes entire event loop for sleep duration
PYVIBE-002 requests.* inside async def async def Blocks OS thread, serializes all concurrent I/O
PYVIBE-007 subprocess.run/call/check_output/Popen inside async def async def Blocks OS thread for entire subprocess duration
PYVIBE-008 sqlite3.connect() inside async def async def Synchronous file I/O blocks the event loop
PYVIBE-009 open() builtin inside async def async def Synchronous file I/O blocks the event loop
PYVIBE-010 httpx.get/post/put/… inside async def async def httpx sync API blocks OS thread for full HTTP round-trip
PYVIBE-011 os.system/popen/waitpid inside async def async def Blocking OS calls with no direct async equivalent
PYVIBE-016 httpx.Client() instantiated inside async def async def Sync client blocks OS thread per request; httpx.AsyncClient() is excluded

B. Async Lifecycle Misuse

Incorrect use of asyncio primitives that raise RuntimeError at runtime or silently discard tasks mid-execution.

Rule Pattern Gate Runtime effect
PYVIBE-003 asyncio.run() inside async def async def RuntimeError: This event loop is already running
PYVIBE-012 asyncio.create_task() with discarded return value async def Task GC'd mid-execution; exceptions silently swallowed
PYVIBE-013 asyncio.gather() without return_exceptions=True async def First exception leaks remaining tasks; no per-task error handling
PYVIBE-014 asyncio.ensure_future() with discarded return value async def Same GC hazard as PYVIBE-012; pre-3.7 API still common in older codebases
PYVIBE-015 loop.run_until_complete() inside async def async def RuntimeError: This event loop is already running

C. Concurrency & State Hazards

Patterns that introduce data races, swallowed errors, or runaway loops under concurrent async load.

Rule Pattern Gate Runtime effect
PYVIBE-004 threading.Lock/RLock/… inside async def async def Blocks event loop under contention
PYVIBE-005 @app.task / @shared_task without soft_time_limit or time_limit decorator Worker hangs forever if external call never returns
PYVIBE-006 ContextVar.set() inside async def without try/finally reset() async def Context leaks into sibling async tasks
PYVIBE-017 except Exception: pass / bare except: pass (empty body) any Swallows all errors silently; bare except also catches KeyboardInterrupt/SystemExit
PYVIBE-018 while True: inside async def with no await in body async def Event loop blocked indefinitely; CPU hits 100%
PYVIBE-019 retry for/while loop in async def with no backoff in except async def Tight retry loop on failure: thousands of failed requests/sec, cascading failures
PYVIBE-020 put_nowait() without asyncio.QueueFull handler any QueueFull propagates unhandled; item is silently lost on bounded queues

Severity notes:

  • PYVIBE-005: CRITICAL — checks per-task decorator arguments only. If your project sets a global task_time_limit via app.conf.task_time_limit, app.conf.update(...), or in celeryconfig.py / settings.py, tasks already covered by that global limit will still be flagged. Add per-task limits (self-documenting, immune to config drift) or suppress with # noqa: PYVIBE-005.
  • PYVIBE-009: CRITICAL in production files; automatically downgraded to WARNING in test files (test_*.py, *_test.py, tests/). Context matters: open() in a hot-path request handler blocks all concurrent coroutines (CRITICAL); open() in a startup/lifespan function runs before requests are served and has zero practical impact. The rule cannot distinguish these contexts via AST — if you use open() in an async def lifespan or one-time initializer, the idiomatic fix is to use plain def instead (FastAPI's own docs do this), which avoids the flag entirely.
  • PYVIBE-017: bare exceptCRITICAL (catches KeyboardInterrupt/SystemExit); except Exception with empty body → WARNING. Specific exceptions (except ValueError: pass) are not flagged.
  • PYVIBE-013 in test files: automatically downgraded to WARNING in files matching test_*.py, *_test.py, or paths under tests/ — exceptions should propagate for assertions in test code.
  • PYVIBE-019: WARNING — flags except that ends with continue or is solely pass with no sleep/backoff call. Suppressed when an escalation pattern (if … : raise/break) is present.
  • PYVIBE-020: WARNING — fires in any function context (sync and async). Suppressed when the put_nowait() is inside a try whose handlers include asyncio.QueueFull, QueueFull, bare except, or Exception.

Validation

100-repo sweep (automated, v0.7.0)

Automated scan of 100 GitHub repos (stars:>100, updated last year, async Python keywords). Script: validation/run_massive_scan.py.

Metric Value
Repos scanned 100
.py files 64,335
Total violations 3,639

Rule prevalence (top 10):

Rule Hits Repos affected % of repos
PYVIBE-017 except Exception: pass 999 57 57%
PYVIBE-013 gather() no return_exceptions 766 47 47%
PYVIBE-019 retry loop no backoff 408 43 43%
PYVIBE-009 open() in async def 178 33 33%
PYVIBE-005 @task no time_limit 882 15 15%
PYVIBE-001 time.sleep in async 55 16 16%
PYVIBE-012 orphaned create_task() 58 13 13%
PYVIBE-008 sqlite3 in async def 121 10 10%
PYVIBE-018 while True no await 36 10 10%
PYVIBE-006 ContextVar no reset 19 10 10%

Full data: validation/aggregate.json · validation/massive-results.md

Reference scan — 4 repos (reproducible)

Repo .py files Violations
fastapi/fastapi 1 121 5
celery/celery 416 363
aio-libs/aiohttp 164 29
encode/httpx 60 0
Total 1 761 397

Raw data: validation/breakdown.json

Notable findings:

  • home-assistant/core — 418 violations across 17,702 files; 331 PYVIBE-013, 21 PYVIBE-018.
  • Celery — 352 tasks without time_limit; workers can hang indefinitely on broker timeouts.
  • aiortc, anyio, uvicornwhile True loops without await in production async I/O code (PYVIBE-018, 29 confirmed real hits after async-generator FP fix).
  • aiohttp examplesopen() in async WebSocket handlers; synchronous I/O in production-facing code.

Installation

pip install python-vibe-guard

Or run without installing:

pip install -e .

Usage

# Scan a file
python -m pyvibe path/to/file.py

# Scan a directory recursively
python -m pyvibe src/

# JSON output for CI/CD pipelines
python -m pyvibe src/ --json

# SARIF 2.1.0 output for GitHub Code Scanning (writes results.sarif)
python -m pyvibe src/ --sarif
python -m pyvibe src/ --sarif --sarif-output custom-path.sarif

# Exclude directories (adds to built-in defaults: venv, .venv, __pycache__, …)
python -m pyvibe src/ --exclude tests

# Show the research evidence behind a rule (accuracy, false positives, sources)
python -m pyvibe explain PYVIBE-002

# Exit code: 0 = clean, 1 = violations found, 2 = path error

Example output

  python-vibe-guard
  ─────────────────────────────────────────────

  demo/bad_async.py

  [CRITICAL] [PYVIBE-001] — line 14
     Function : process_order()
     Problem  : time.sleep() blocks the entire event loop
     Fix      : Use `await asyncio.sleep(n)` instead

  [CRITICAL] [PYVIBE-002] — line 20
     Function : fetch_user()
     Problem  : requests.get() is synchronous — blocks the event loop
     Fix      : Use `async with httpx.AsyncClient() as c: await c.get(url)`

  [CRITICAL] [PYVIBE-003] — line 26
     Function : orchestrate()
     Problem  : asyncio.run() inside async def raises RuntimeError at runtime
     Fix      : Use `await coroutine()` directly — asyncio.run() is for sync entrypoints only

  [CRITICAL] [PYVIBE-004] — line 32
     Function : update_counter()
     Problem  : threading.Lock() blocks the event loop under contention
     Fix      : Use `asyncio.Lock()` with `async with lock:` instead

  ─────────────────────────────────────────────
  4 violation(s) in 1 file(s)

pyvibe explain

Every rule's accuracy claims come from research/accepted/PYVIBE-XXX.md — a per-rule evidence file with repo-sweep data, an evidence-level grade, and a hit-by-hit precision audit. pyvibe explain surfaces that in the terminal instead of making you go dig for it:

python -m pyvibe explain PYVIBE-002
  PYVIBE-002 — requests.* inside async def
  ───────────────────────────────────────────────

  Problema:
    requests.* inside async def

  Por qué ocurre:
    `requests` is a synchronous HTTP library. Calling it inside an async function
    blocks the OS thread running the event loop. Under concurrent load this
    serialises all I/O and eliminates any benefit of async.

  Visto en: 4.0% (10/250 repos, sweep-250 dataset)
  Nivel de evidencia: B
  Precisión auditada: ~83%
  Falsos positivos conocidos: EXECUTOR_WRAPPER; INNER_SYNC_FUNCTION_EXECUTOR; ...

  Fix sugerido:
    use `httpx.AsyncClient` or `aiohttp.ClientSession` with await.

  Full report: research/accepted/PYVIBE-002.md

If a rule has no evidence file, it prints a clear No evidence file found for PYVIBE-XXX and exits non-zero — it never invents data.


CI/CD integration

Add to your GitHub Actions workflow:

- name: python-vibe-guard scan
  run: |
    pip install python-vibe-guard
    python -m pyvibe src/ --json

The scanner exits with code 1 when violations are found, failing the CI job.

GitHub Code Scanning (SARIF)

- name: python-vibe-guard scan
  run: |
    pip install python-vibe-guard
    python -m pyvibe src/ --sarif
  continue-on-error: true  # let the upload step surface results in the PR instead

- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: results.sarif

Findings then show up as annotations on the PR diff and in the repo's Security tab, each linking back to its research/accepted/PYVIBE-XXX.md evidence file via helpUri.


Pre-commit integration

Add to your .pre-commit-config.yaml:

repos:
  - repo: https://github.com/Joaquinriosheredia/python-vibe-guard
    rev: v0.7.0
    hooks:
      - id: python-vibe-guard

Then install the hook:

pre-commit install

The hook runs on every git commit, scans all Python files in the project, and blocks the commit if any violations are found.


Design

  • Pure AST, zero runtime dependencies — uses only Python's built-in ast module
  • Each rule is an independent ast.NodeVisitor — easy to add, disable, or extend
  • Gate on async def — most rules check _current_async_func before firing; PYVIBE-017 (silent except) fires in any function context
  • No import resolution — works on any Python file without installing its dependencies
  • Test-aware severity — PYVIBE-013 is automatically downgraded to WARNING in test files

Run the demo

python -m pyvibe demo/bad_async.py

Expected: 20 findings (17 CRITICAL + 3 WARNING for PYVIBE-017 except Exception, PYVIBE-019 retry without backoff, and PYVIBE-020 put_nowait without handler), one per rule. demo/bad_async.py also contains a sync function that mirrors the async-specific patterns — those produce zero findings.


Run the tests

python -m pytest tests/ -v
# or
python tests/test_rules.py

226 tests: true positives + false-positive guards for every rule, plus SARIF output and pyvibe explain coverage.


Ecosystem

This project is part of the vibe-guard family of runtime anti-pattern scanners:

  • java-vibe-guard — Spring Boot / async Java (blocking Kafka, @Transactional + Virtual Threads)
  • python-vibe-guard — FastAPI / asyncio (this project)

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_vibe_guard-0.8.0.tar.gz (44.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_vibe_guard-0.8.0-py3-none-any.whl (43.3 kB view details)

Uploaded Python 3

File details

Details for the file python_vibe_guard-0.8.0.tar.gz.

File metadata

  • Download URL: python_vibe_guard-0.8.0.tar.gz
  • Upload date:
  • Size: 44.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for python_vibe_guard-0.8.0.tar.gz
Algorithm Hash digest
SHA256 1cc94a793eade2e56060bb28b251534f2bdd5fe7225414abd4df43fb2c179540
MD5 294bbe7706ba65847f630c6778ca7a5d
BLAKE2b-256 715e8056c483bb65e5a0561876808975b649380b5ac7c07a75a5157ac935747e

See more details on using hashes here.

File details

Details for the file python_vibe_guard-0.8.0-py3-none-any.whl.

File metadata

File hashes

Hashes for python_vibe_guard-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bcd4a33885d6b1653d679282aca6d0d6441c8fd3849b035fc7b53c2951f1429c
MD5 3a5a1112f51edabfcfab35f1dee38c7e
BLAKE2b-256 b909b6b69fc532626c7f1e90938370c751d570b014a70a12f9e9f70e087505e2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page