fastapi-loopguard 0.4.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/.github/workflows/ci.yml +1 -1
  2. fastapi_loopguard-0.6.0/CHANGELOG.md +104 -0
  3. fastapi_loopguard-0.6.0/CLAUDE.md +109 -0
  4. fastapi_loopguard-0.6.0/FINDINGS.md +105 -0
  5. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/PKG-INFO +31 -8
  6. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/README.md +27 -5
  7. fastapi_loopguard-0.6.0/assets/demo.gif +0 -0
  8. fastapi_loopguard-0.6.0/assets/demo.tape +59 -0
  9. fastapi_loopguard-0.6.0/assets/error-page.gif +0 -0
  10. fastapi_loopguard-0.6.0/docs/AI-HARNESS.md +111 -0
  11. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/docs/CONFIGURATION.md +16 -5
  12. fastapi_loopguard-0.6.0/evals/README.md +64 -0
  13. fastapi_loopguard-0.6.0/evals/runner.py +104 -0
  14. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/app_skeleton.py +8 -0
  15. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/checks.py +15 -0
  16. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/helpers.py +16 -0
  17. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/reference/blocking.py +10 -0
  18. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/reference/clean.py +10 -0
  19. fastapi_loopguard-0.6.0/evals/tasks/01-user-lookup/task.md +11 -0
  20. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/app_skeleton.py +8 -0
  21. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/checks.py +20 -0
  22. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/helpers.py +16 -0
  23. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/reference/blocking.py +12 -0
  24. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/reference/clean.py +14 -0
  25. fastapi_loopguard-0.6.0/evals/tasks/02-price-fanout/task.md +13 -0
  26. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/app_skeleton.py +8 -0
  27. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/checks.py +15 -0
  28. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/helpers.py +9 -0
  29. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/reference/blocking.py +11 -0
  30. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/reference/clean.py +13 -0
  31. fastapi_loopguard-0.6.0/evals/tasks/03-report-export/task.md +14 -0
  32. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/app_skeleton.py +8 -0
  33. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/checks.py +15 -0
  34. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/helpers.py +9 -0
  35. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/reference/blocking.py +12 -0
  36. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/reference/clean.py +14 -0
  37. fastapi_loopguard-0.6.0/evals/tasks/04-image-thumbnail/task.md +13 -0
  38. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/app_skeleton.py +8 -0
  39. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/checks.py +18 -0
  40. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/helpers.py +18 -0
  41. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/reference/blocking.py +11 -0
  42. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/reference/clean.py +11 -0
  43. fastapi_loopguard-0.6.0/evals/tasks/05-audit-log/task.md +11 -0
  44. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/examples/demo_app.py +4 -3
  45. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/pyproject.toml +5 -2
  46. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/__init__.py +8 -3
  47. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/config.py +16 -0
  48. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/context.py +21 -8
  49. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/logging.py +17 -0
  50. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/middleware.py +96 -53
  51. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/monitor.py +151 -60
  52. fastapi_loopguard-0.6.0/src/fastapi_loopguard/pytest_plugin.py +262 -0
  53. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_config.py +49 -0
  54. fastapi_loopguard-0.6.0/tests/test_cumulative_blocking.py +228 -0
  55. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_enforcement_mode.py +245 -44
  56. fastapi_loopguard-0.6.0/tests/test_evals_runner.py +57 -0
  57. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_logging.py +64 -1
  58. fastapi_loopguard-0.6.0/tests/test_middleware.py +1252 -0
  59. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_monitor.py +1236 -804
  60. fastapi_loopguard-0.6.0/tests/test_packaging.py +42 -0
  61. fastapi_loopguard-0.6.0/tests/test_pytest_plugin.py +579 -0
  62. fastapi_loopguard-0.4.1/CLAUDE.md +0 -79
  63. fastapi_loopguard-0.4.1/assets/error-page-screenshot.png +0 -0
  64. fastapi_loopguard-0.4.1/src/fastapi_loopguard/pytest_plugin.py +0 -135
  65. fastapi_loopguard-0.4.1/tests/test_cumulative_blocking.py +0 -111
  66. fastapi_loopguard-0.4.1/tests/test_middleware.py +0 -618
  67. fastapi_loopguard-0.4.1/tests/test_pytest_plugin.py +0 -280
  68. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/.github/dependabot.yml +0 -0
  69. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/.github/workflows/publish.yml +0 -0
  70. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/.gitignore +0 -0
  71. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/LICENSE +0 -0
  72. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/assets/error-page-screenshot-console.png +0 -0
  73. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/assets/error-page-screenshot-endpoint.png +0 -0
  74. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/assets/loopguard-logo.webp +0 -0
  75. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/examples/locustfile.py +0 -0
  76. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/examples/run_stress_test.py +0 -0
  77. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/examples/stress_app.py +0 -0
  78. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/metrics.py +0 -0
  79. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/src/fastapi_loopguard/py.typed +0 -0
  80. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/__init__.py +0 -0
  81. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_context.py +0 -0
  82. {fastapi_loopguard-0.4.1 → fastapi_loopguard-0.6.0}/tests/test_metrics.py +0 -0
@@ -41,7 +41,7 @@ jobs:
41
41
  coverage xml
42
42
 
43
43
  - name: Upload coverage to Codecov
44
- uses: codecov/codecov-action@v5
44
+ uses: codecov/codecov-action@v7
45
45
  if: matrix.python-version == '3.12'
46
46
  with:
47
47
  files: ./coverage.xml
@@ -0,0 +1,104 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 (2026-08-12)
4
+
5
+ Detection-core hardening plus the AI test harness. Fixes found by a
6
+ line-by-line audit of the detection path; every item below changes
7
+ observable behavior.
8
+
9
+ - The adaptive threshold can no longer ratchet itself blind. Recalculation
10
+ is clamped to the `fallback_threshold_ms` ceiling; previously each raise
11
+ widened the censor gate that admits the next round of samples, compounding
12
+ 50 -> 241 -> 573ms within seconds on merely-noisy traffic.
13
+ - Cumulative detection no longer counts timer jitter. The window now sums
14
+ baseline-corrected excess lag (`max(0, lag - baseline)`); raw per-tick
15
+ platform jitter (~2ms idle on macOS) alone could previously cross the
16
+ 200ms default within one 1000ms window on a completely idle loop.
17
+ - Lazy stop/start race fixed: a request arriving while the monitor was
18
+ stopping (last request just finished) used to observe stale lifecycle
19
+ flags and run unmonitored. Flags are now cleared before the stop awaits,
20
+ so a mid-stop request starts a fresh monitor.
21
+ - `stop()` no longer swallows a cancellation aimed at the calling task.
22
+ It runs inside request `finally` blocks; the old
23
+ `suppress(CancelledError)` turned client disconnects and server shutdown
24
+ into requests that ignored their own cancellation.
25
+ - `stop()` issued during a blocking `start()` calibration now vetoes the
26
+ loop start instead of being a silent no-op.
27
+ - A lifespan app that raises no longer leaks the monitor tasks.
28
+ - One blocking event now logs exactly one summary line instead of one line
29
+ per in-flight request (O(N) log formatting on the loop thread right after
30
+ a stall). The per-context `on_blocking` callback contract is unchanged.
31
+ - New config validation: `fallback_threshold_ms >= monitor_interval_ms`,
32
+ `adaptive_update_interval_ms >= monitor_interval_ms`, and `exclude_paths`
33
+ may not be a bare string (it would silently become a substring match).
34
+ - Strict mode's clean-path headers are now computed from the request
35
+ context instead of hardcoded zero literals.
36
+ - Calibration clamps a negative raw baseline (timers may fire up to
37
+ clock_resolution early) to 0.
38
+
39
+ AI test harness (pytest plugin):
40
+
41
+ - New `loopguard_all_async` mode (ini or `--loopguard-all-async`): every
42
+ async test is treated as `@pytest.mark.no_blocking`, with a new
43
+ `@pytest.mark.allow_blocking` opt-out marker. Built for gating
44
+ AI-generated code without per-test annotations.
45
+ - New `loopguard_report` option (ini or `--loopguard-report=PATH`): writes
46
+ a JSON verdict file at session end with per-test blocking events and
47
+ concrete sync->async fix hints. See `docs/AI-HARNESS.md`.
48
+ - The detector is now armed before the test body runs. A test that blocked
49
+ before its first real await (an ASGI request dispatch does exactly that)
50
+ was previously invisible to the gate.
51
+ - BREAKING: the `loopguard_detector` fixture was removed. It yielded a
52
+ detector that was never started, so assertions on its (always empty)
53
+ `blocking_events` passed unconditionally.
54
+ - `@pytest.mark.no_blocking` on a synchronous test now emits a warning
55
+ instead of silently doing nothing.
56
+ - The plugin no longer calls `asyncio.iscoroutinefunction` (deprecated on
57
+ Python 3.14), so downstream suites running `-W error` stay green.
58
+ - `BlockingDetector.stop()` no longer swallows the test's own cancellation.
59
+
60
+ Also:
61
+
62
+ - New `evals/` directory: a starter benchmark (5 FastAPI tasks + runner)
63
+ scoring whether an AI model writes non-blocking async code.
64
+ - `configure_logging()` is idempotent and disables propagation;
65
+ `StructuredFormatter` preserves `exc_info` tracebacks.
66
+ - `prometheus-client` joined the `dev` extra so the metrics tests run in CI.
67
+
68
+ ## 0.5.0 (2026-08-11)
69
+
70
+ Correctness release. Every fix below changes externally observable behavior.
71
+
72
+ - `dev_mode` no longer turns blocking into 503s. Previously, one endpoint's
73
+ sync block made every concurrent innocent request fail with 503 while the
74
+ actual culprit returned 200. `dev_mode` now only adds diagnostic headers;
75
+ a 503 requires explicitly setting `enforcement_mode="strict"`. Docs no
76
+ longer claim the library identifies which endpoint caused blocking — it
77
+ reports the requests that were in flight during the stall.
78
+ - A single blocking event is reported once, not twice. A 300ms block used to
79
+ produce two events summing to ~600ms in headers and logs (single-shot plus
80
+ a cumulative re-count of the same sample). Cumulative window sums are now
81
+ recorded separately from individual lag samples.
82
+ - Calibration can no longer be poisoned by the app's own blocking. Background
83
+ calibration during a busy blocking startup used to raise the detection
84
+ threshold to ~8x the fallback, after which real blocking went undetected.
85
+ A calibrated threshold now only ever tightens the fallback.
86
+ - The adaptive threshold no longer chases sustained blocking. ~120 blocks of
87
+ ~70ms used to yield ~9 detections and a final threshold near 380ms;
88
+ detection now keeps firing for the whole run.
89
+ - Adaptive mode no longer discards a calibration-tightened threshold. Its
90
+ floor used to be pinned at the fallback and its first update fired
91
+ immediately, snapping a calibrated 10ms threshold back to 50ms on the
92
+ first tick. The floor now follows the calibrated threshold.
93
+ - Responses using ASGI extension messages no longer hang in strict mode:
94
+ `http.response.pathsend` (Starlette `FileResponse` on Hypercorn/Granian),
95
+ `http.response.trailers`, and unknown message types now pass through.
96
+ - `"trailers": True` and any other key on `http.response.start` survive
97
+ header injection instead of being silently dropped.
98
+ - The monitor no longer leaks background tasks when startup fails
99
+ (`lifespan.startup.failed` / `lifespan.shutdown.failed` now stop it), and a
100
+ lazily started monitor (apps without lifespan, e.g. tests using
101
+ `httpx.ASGITransport`) stops when the last in-flight request finishes.
102
+ - `__version__` now matches the installed package metadata (it was hard-coded
103
+ to 0.3.0 while the package shipped as 0.4.1), and the 503 error page links
104
+ to the real repository.
@@ -0,0 +1,109 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ fastapi-loopguard — a middleware library that detects event-loop blocking in FastAPI/Starlette apps with per-request attribution. When synchronous code (`time.sleep`, blocking I/O, CPU-bound work) freezes the async event loop, LoopGuard reports which requests were in flight and, depending on enforcement mode, warns, logs, or fails the response with an educational 503.
8
+
9
+ **Division of docs — do not duplicate them here.** `README.md` owns the pitch, install, and quick-start snippet. `docs/CONFIGURATION.md` is the authoritative reference for every `LoopGuardConfig` option and the recommended recipes; add new options there. This file owns architecture, invariants, and workflow.
10
+
11
+ There is no CONTRIBUTING.md. Release history lives in `CHANGELOG.md` (since 0.5.0) and in git tags (`v0.3.0` …).
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ pip install -e ".[dev]" # dev deps: pytest, mypy, ruff, coverage, httpx, fastapi
17
+ pytest # full suite (asyncio_mode=auto, testpaths=tests)
18
+ pytest tests/test_middleware.py::TestLoopGuardMiddleware::test_dev_mode_headers # one test
19
+ mypy src/ # strict mode, src/ only
20
+ ruff check src/ tests/ # lint — CI runs this exact scope
21
+ ruff format --check src/ tests/ # CI verifies formatting; drop --check to rewrite
22
+ coverage run -m pytest tests/ && coverage report --fail-under=80 # the CI gate
23
+
24
+ pip install -e ".[stress]" # everything under examples/ needs uvicorn + locust
25
+ python examples/demo_app.py # demo on :8765 — /api/users returns 503 (strict mode)
26
+ python examples/stress_app.py # stress target on :8000
27
+ python examples/run_stress_test.py --skip-locust # validation suite against a running :8000
28
+ ```
29
+
30
+ CI lints and type-checks `src/` and `tests/` only — `examples/` is unchecked and does not satisfy the `ANN` rules the rest of the tree does. `tests/test_metrics.py` skips entirely unless `.[prometheus]` is installed, which is why `metrics.py` reports 0% coverage on a plain `.[dev]` run; the 80% gate still passes at ~88% overall.
31
+
32
+ ## Tech Stack (versions verified 2026-08-11)
33
+
34
+ - Python `>=3.12`; CI matrix is 3.12 and 3.13. mypy is pinned to `python_version = "3.12"` with `strict = true`.
35
+ - **One runtime dependency: `starlette>=0.37.0,<1.0`.** FastAPI is a *dev* dependency — the middleware is pure ASGI and must never import `fastapi` from `src/`. Adding any runtime dependency requires asking first.
36
+ - Extras: `prometheus` (prometheus-client), `structlog`, `all`, `dev`, `stress` (locust + uvicorn). The `structlog` extra is currently declared but unused — nothing imports structlog.
37
+ - ruff selects `["E","F","I","N","W","UP","B","C4","SIM","ANN"]`, ignoring only `ANN401`. **`ANN` means every function needs full annotations**, tests and fixtures included. Line length 88, double quotes.
38
+ - Build backend is hatchling; wheel packages `src/fastapi_loopguard`. Publishing triggers on a `v*` tag via PyPI trusted publishing (OIDC) — there is **no version-bump automation**, so `pyproject.toml` must be bumped by hand. `__init__.__version__` is derived from installed package metadata; after bumping, re-run `pip install -e .` or the version tests fail against stale metadata.
39
+ - `.claude/` is git-ignored and excluded from the sdist. Committing anything there needs an explicit `.gitignore` negation.
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ src/fastapi_loopguard/
45
+ config.py LoopGuardConfig — frozen slotted dataclass + __post_init__ validation
46
+ context.py RequestContext, RequestRegistry, module-global _registry, free functions
47
+ monitor.py SentinelMonitor sleep-measure loop, AdaptiveThreshold
48
+ middleware.py pure-ASGI LoopGuardMiddleware, enforcement modes, HTML/JSON error pages
49
+ logging.py StructuredFormatter (JSON), configure_logging, log_blocking_event
50
+ metrics.py optional Prometheus LoopGuardMetrics (see Known Gaps — not wired in)
51
+ pytest_plugin.py pytest11 entry point, @pytest.mark.no_blocking, BlockingDetector
52
+ ```
53
+
54
+ **Layering (strict — a module imports only lower layers):** `config`, `context` → `monitor` → `middleware`. `logging`, `metrics`, and `pytest_plugin` are leaves; nothing on the detection path imports them, and they must not import `middleware`. `middleware.py` imports `LoopGuardConfig` inside `__init__` with an "avoid circular imports" comment — the cycle no longer exists, but the deferred import is harmless and not worth churning.
55
+
56
+ `pytest_plugin.py` is registered as a `pytest11` entry point, so it auto-loads for **every** project that installs this package. Treat its hooks as public API and keep them cheap and side-effect-free for unmarked tests.
57
+
58
+ **Detection flow:** ASGI `lifespan.startup` starts the monitor → `_handle_http` skips `exclude_paths`, then registers a `RequestContext` and writes the id to `scope["state"]["loopguard_request_id"]` → `_monitor_loop` sleeps `monitor_interval_ms` and computes `lag_ms = (elapsed - interval) * 1000` → lag over threshold calls `_handle_blocking`, which calls `record_blocking` on every active context → the send wrapper reads `ctx.blocking_count` / `ctx.total_blocking_ms` and emits headers or a 503 → `finally` unregisters (and stops a lazily started monitor once the registry empties) → any terminal lifespan message (`shutdown.complete`, `shutdown.failed`, `startup.failed`) stops the monitor.
59
+
60
+ ## Detection Invariants (non-negotiable)
61
+
62
+ 1. **Pure ASGI — never `BaseHTTPMiddleware`.** It is deprecated, breaks contextvars, and leaks memory. `__call__` dispatches on `scope["type"]`; WebSocket and every other type pass through untouched, with no context registered.
63
+ 2. **Blocking attributes to ALL active requests.** The sentinel measures loop lag, not call stacks, so it cannot know which request blocked. `_handle_blocking` iterates every context from `get_active_requests()` and records the same lag on each. This over-reports under concurrency **by design** — narrowing it is a redesign, not a bug fix.
64
+ 3. **The registry needs no locks.** asyncio is single-threaded, so `RequestRegistry` is a plain dict keyed by `request_id`. Never add a lock, a `threading` primitive, or thread-safety without first changing that premise and saying so.
65
+ 4. **Calibration never blocks the first request, and can only tighten.** `start_with_background_calibration()` starts `_monitor_loop` immediately on `fallback_threshold_ms` and calibrates in a named background task. Baseline is the **minimum** of `calibration_iterations` samples (the idle floor — robust to contamination from live traffic), and the calibrated threshold is clamped to `[monitor_interval_ms, fallback_threshold_ms]`: it may lower the fallback, never raise it. The adaptive window is censored (only sub-threshold samples admitted), its floor follows the calibrated threshold, and its recalculated value is clamped to the `fallback_threshold_ms` ceiling — without the ceiling each raise widens the censor gate and the threshold ratchets upward unboundedly. So neither calibration nor adaptation can be poisoned upward by the app's own blocking or its noise. A failed or cancelled calibration keeps the fallback threshold and must never raise into startup. Config guarantees `fallback_threshold_ms >= monitor_interval_ms`, so the clamp can never invert.
66
+ 5. **Unregister always runs.** `_handle_http` wraps dispatch in `try/finally: unregister_request(request_id)`. An exception from the wrapped app must never leak a context into the registry — a leak makes every later blocking event attribute to a dead request forever.
67
+ 6. **`dev_mode` is headers-only; a 503 requires explicit strict mode.** `_get_effective_enforcement_mode()` always returns `enforcement_mode` — `dev_mode` never changes it. Because blocking is attributed to ALL in-flight requests (invariant 2), a status change punishes innocent bystanders, so it must stay opt-in via `enforcement_mode="strict"`. No text anywhere may claim the library identifies WHICH endpoint blocked — it narrows it to the requests in flight during the stall.
68
+ 7. **One blocking event is reported exactly once.** A sample that fires the single-shot detection is never appended to `_lag_history`, within one `_monitor_loop` iteration a single-lag trigger suppresses the cumulative one, and `_lag_history` is cleared after a cumulative fire so a window reports at most once. The window stores **baseline-corrected excess lag** (`max(0, lag - baseline)`), never raw lag — raw per-tick timer jitter alone would sum past the default cumulative threshold on an idle loop. Cumulative window sums are recorded in `RequestContext.cumulative_events`, separate from the individual-lag `blocking_events` list; `blocking_count` / `total_blocking_ms` sum both. One event also emits exactly **one log line** (a summary across in-flight requests), never one line per context — no O(N) work on the loop thread right after a stall.
69
+ 8. **Lifecycle calls are idempotent, race-free, and cancellation-safe; lazy monitors stop when idle.** `start()`, `start_with_background_calibration()`, and `stop()` all return early when already in the target state. `start()` marks `_running` **before** its blocking calibration so a concurrent `stop()` vetoes the loop start. `_stop_monitor` clears `_started`/`_lazy_started`/`_monitor` **before** awaiting the monitor's stop — that await yields, and a request arriving mid-stop must see `_started=False` and start a fresh monitor rather than run unmonitored. `stop()` awaits cancelled tasks via `_cancel_and_wait`, which re-raises when the *calling* task has a pending cancellation (it runs inside request `finally` blocks; a bare `suppress(CancelledError)` would make requests ignore their own cancellation). A lifespan app that raises gets the monitor stopped in the `except` path, not leaked. `_handle_http` lazily starts the monitor for apps that run without ASGI lifespan and stops it when the last in-flight request unregisters (so `httpx.ASGITransport` tests leak no tasks); lifespan-managed monitors persist between requests. Double-start must stay a no-op. Consequence: in lazy mode background calibration rarely completes and the fallback threshold governs.
70
+
71
+ ## Conventions
72
+
73
+ - **`LoopGuardConfig` is frozen — never mutate it, construct a new one.** All validation lives in `__post_init__` and raises `ValueError` with a message naming the field.
74
+ - Every new config option needs three things: a validation rule in `__post_init__`, a case in `TestLoopGuardConfig`, and a row in `docs/CONFIGURATION.md`. Missing any one of them is an incomplete change.
75
+ - **Response headers are lowercase bytes.** Pass-through responses carry `x-request-id`, `x-blocking-count`, `x-blocking-total-ms`, and `x-blocking-detected`, plus `x-loopguard-warning: blocking-detected` in warn mode. The strict 503 built by `_send_strict_error` carries `x-loopguard-enforcement: strict` and **omits `x-blocking-detected`** — a separate header set, not an extension of the first.
76
+ - `request_id` is the first 8 characters of a `uuid4` — short enough to read in a terminal, not a security token.
77
+ - **All internal timing uses `loop.time()` or `time.monotonic()`** — never `datetime` or wall-clock, which jump under NTP.
78
+ - Hot-path classes use `__slots__`: `LoopGuardConfig`, `RequestContext`, `RequestRegistry`, `SentinelMonitor`, `AdaptiveThreshold`, `LoopGuardMiddleware`. Adding an attribute means adding it to `__slots__`, and `test_context.py` asserts the absence of `__dict__`.
79
+ - Logger name is `"fastapi_loopguard"`, shared by `monitor.py` and `logging.py`. `monitor.py` logs inline with `%`-style lazy formatting; `logging.log_blocking_event()` is a helper for library *users* and is intentionally not called internally.
80
+ - `exclude_paths` is checked before anything else in `_handle_http`, so health checks cost nothing.
81
+ - Backward-compat shims are public API and stay: `get_current_request`, `set_current_request`, `reset_current_request`, `init_metrics`. `get_current_request` returns an arbitrary active context and is only correct for single-request cases — new code uses `get_active_requests()`.
82
+ - `logging`, `metrics`, and `pytest_plugin` are **not** re-exported from `__init__.py`; import them by module path.
83
+
84
+ ## Testing
85
+
86
+ - Definition of done: `pytest` green, `mypy src/` clean, `ruff check src/ tests/` and `ruff format --check src/ tests/` clean, and coverage at or above **80** (`--fail-under=80` is the CI gate).
87
+ - **There is no `conftest.py`.** The `clear_registry` fixture is duplicated per file, roughly 15 times. Match the local pattern in the file you are editing; introducing a shared conftest is its own change, not a side effect of an unrelated one.
88
+ - Blocking is simulated with `time.sleep(...)` followed by a short `await asyncio.sleep(...)` — the second call is what lets the sentinel observe the lag while the context is still registered. Omitting it makes the test pass for the wrong reason.
89
+ - HTTP tests use `httpx.AsyncClient(transport=ASGITransport(app=app))`. Plugin tests use the `pytester` fixture to run generated test files in-process.
90
+ - Each invariant has dedicated coverage: `test_enforcement_mode.py` for modes and dev-mode escalation, `test_monitor.py` for calibration, idempotency, and task cancellation, `test_cumulative_blocking.py` for the window, `test_context.py` for registry lifecycle and `__slots__`, `test_pytest_plugin.py` for the marker.
91
+ - Timing tests are inherently flaky under load. Prefer driving `SentinelMonitor` directly with an `on_blocking` callback (as `test_cumulative_blocking.py` does) over asserting on wall-clock durations.
92
+
93
+ ## Known Gaps (accurate as of 2026-08-11)
94
+
95
+ Recorded so they are not rediscovered. None are fixed yet; fixing any of them is its own task. The fuller list, including design tensions deferred from the 0.5 correctness pass, is `FINDINGS.md`.
96
+
97
+ 1. **`prometheus_enabled` is inert.** Neither `middleware.py` nor `monitor.py` imports `metrics.py`, so enabling the flag exposes nothing. Wiring it up means calling `record_blocking` / `record_request` / `set_threshold` from the monitor.
98
+ 2. **`docs/CONFIGURATION.md` names metrics that do not exist.** It lists `loopguard_blocking_events_total` and `loopguard_blocking_duration_ms`; the real names are `loopguard_blocking_total`, `loopguard_lag_seconds`, `loopguard_requests_monitored_total`, and `loopguard_threshold_seconds`.
99
+ 3. **`get_metrics()` can never find an instance.** It reads `_instances[prefix]` while `create_metrics` writes `f"{prefix}:{id(registry)}"`. `tests/test_metrics.py:188` documents the mismatch in a comment instead of failing on it.
100
+ 4. **`_generate_warning_banner()` is dead code** — defined in `middleware.py`, never called.
101
+
102
+ ## Fundamental Guidelines
103
+
104
+ *(Distilled from [andrej-karpathy-skills](https://github.com/forrestchang/andrej-karpathy-skills))*
105
+
106
+ - **Think first.** Surface assumptions and tradeoffs; if a request is ambiguous or a simpler approach exists, say so before coding — don't pick silently.
107
+ - **Simplicity first.** Minimum code that solves the problem. No speculative features, abstractions, config, or error handling for impossible cases.
108
+ - **Surgical changes.** Touch only what the task requires. Match existing style, don't refactor working code, remove only orphans your own change created.
109
+ - **Goal-driven.** Turn tasks into verifiable goals (e.g. "fix bug" → "write a failing test, make it pass"); state a brief plan for multi-step work.
@@ -0,0 +1,105 @@
1
+ # Findings not fixed in the 0.5/0.6 passes
2
+
3
+ Everything noticed while fixing the 0.5 correctness defects (and the 0.6
4
+ hardening pass) but deliberately left alone. Each item is its own future task.
5
+
6
+ ## Detection design
7
+
8
+ 1. ~~**Sub-threshold noise can still inflate the adaptive threshold.**~~
9
+ **Fixed in the 0.6 pass:** the adaptive threshold is now clamped to the
10
+ `fallback_threshold_ms` ceiling, closing both the one-step inflation and
11
+ the compounding ratchet (each raise widened the censor gate). Median+MAD
12
+ over the censored window remains a possible refinement below the ceiling.
13
+
14
+ 2. **Strict mode cannot fail a response after `http.response.start` has passed.**
15
+ If blocking is first detected mid-stream, the 200 and its headers are
16
+ already on the wire and the body keeps streaming; the response then claims
17
+ no blocking. Inherent to header-based reporting. Documented by test
18
+ (`test_enforcement_mode.py::TestStrictModeStreaming`). Related accepted
19
+ behavior, also documented by test: if the app raises after strict mode
20
+ swallowed its `http.response.start`, the exception wins — the client gets
21
+ the server's 500, not LoopGuard's 503.
22
+
23
+ 3. **Explicit strict mode still 503s all in-flight requests.**
24
+ The sentinel cannot name the culprit, so strict mode punishes bystanders
25
+ under concurrency. Now opt-in and documented, but the real per-callback
26
+ attribution rewrite (the 0.5+ plan) is what removes this.
27
+
28
+ 4. **Lazy mode (no ASGI lifespan) rarely completes calibration.**
29
+ The stop-when-idle lifecycle cancels background calibration when the last
30
+ request finishes, so lifespan-less deployments run on the fallback
31
+ threshold and have no between-request monitoring. Accepted tradeoff for
32
+ not leaking tasks; worth a README note if such deployments matter.
33
+
34
+ ## ASGI / architecture
35
+
36
+ 5. **`exclude_paths` matches the raw `scope["path"]`.**
37
+ Breaks under `root_path` and `Mount`, and `/health/live` is not excluded by
38
+ `/health`. Prefix or route-template matching would fix it.
39
+
40
+ 6. **Module-global `RequestRegistry` breaks with two apps in one process.**
41
+ A mounted sub-app or a second middleware-wrapped app cross-attributes
42
+ blocking between apps. Keying state per middleware instance (or per loop)
43
+ would fix it.
44
+
45
+ 7. **The package could be zero-dependency.**
46
+ `middleware.py` imports only type aliases from `starlette.types`; moving
47
+ that import under `TYPE_CHECKING` removes the runtime dependency entirely
48
+ (works on Litestar, Quart, Django ASGI, ...). Also, the `<1.0` upper bound
49
+ on starlette will block installs the day Starlette 1.0 ships.
50
+
51
+ 8. **`get_current_request()` returns an arbitrary active context.**
52
+ A backward-compat shim that silently gives wrong answers under concurrency.
53
+ Pre-1.0 with few users, removal is cheaper than the confusion.
54
+
55
+ ## Dead / inert code and docs drift
56
+
57
+ 9. **`prometheus_enabled` is inert** (known gap #1): nothing on the detection
58
+ path imports `metrics.py`. Out of scope here (`metrics.py` untouchable).
59
+
60
+ 10. **`_generate_warning_banner()` is dead code** (known gap #4).
61
+
62
+ 11. **`docs/CONFIGURATION.md` names metrics that do not exist** (known gap #2)
63
+ and `get_metrics()` can never find an instance (known gap #3).
64
+
65
+ 12. ~~**`docs/CONFIGURATION.md` describes `fallback_threshold_ms` as "Used if
66
+ calibration is unreliable"**~~ — fixed in the 0.6 pass; the docs now
67
+ describe it as the hard ceiling.
68
+
69
+ 13. ~~**`_handle_lifespan` locals `started` / `shutdown_complete` are
70
+ write-only.**~~ — removed in the 0.6 pass.
71
+
72
+ 14. **The `structlog` extra is declared but nothing imports structlog**
73
+ (already noted in CLAUDE.md).
74
+
75
+ ## Repro caveat worth keeping
76
+
77
+ 15. **Calibration poisoning (defect 2) required blocking to span the whole
78
+ calibration window.** With idle gaps, clean tail samples dominated and the
79
+ old `max(..., fallback)` floor masked the bug. The fix removes the
80
+ mechanism either way, but tests that "prove" poisoning need dense blocking.
81
+
82
+ ## Accepted in the 0.6 hardening pass (documented, not fixed)
83
+
84
+ 16. **Installing the middleware twice injects duplicate `x-*` headers.**
85
+ Nothing marks a scope as already instrumented; two instances register two
86
+ contexts per request and both send wrappers extend the headers. Low
87
+ priority — a doubled middleware is a user configuration error.
88
+
89
+ 17. **`Accept` negotiation is a bare substring test with last-header-wins.**
90
+ Duplicate `Accept` headers collapse via `dict()`, and q-values are
91
+ ignored, so `text/html;q=0.1, application/json;q=0.9` still gets the HTML
92
+ page. Cosmetic: only chooses the error-page format.
93
+
94
+ 18. **8-hex-char request ids (32 bits) can collide silently.** A collision
95
+ overwrites the registry entry and the first unregister removes the second
96
+ request's context. Birthday bound is ~77k concurrent in-flight requests;
97
+ accepted as unrealistic, noted here so it is a known trade.
98
+
99
+ ## Fixed since the first draft
100
+
101
+ - Adaptive mode discarding the calibrated threshold (floor pinned at the
102
+ fallback, first update firing immediately) — fixed on this branch; the
103
+ adaptive floor now follows the calibrated threshold.
104
+ - CLAUDE.md invariants 4 and 6 and known gaps 1-2 describing pre-0.5
105
+ behavior — CLAUDE.md updated on this branch.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: fastapi-loopguard
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Detect event-loop blocking in FastAPI/Starlette with per-request attribution
5
5
  Project-URL: Homepage, https://github.com/parhamdavari/fastapi-loopguard
6
6
  Project-URL: Documentation, https://github.com/parhamdavari/fastapi-loopguard#readme
@@ -22,7 +22,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Topic :: System :: Monitoring
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.12
25
- Requires-Dist: starlette<1.0,>=0.37.0
25
+ Requires-Dist: starlette<2.0,>=0.37.0
26
26
  Provides-Extra: all
27
27
  Requires-Dist: prometheus-client>=0.19.0; extra == 'all'
28
28
  Requires-Dist: structlog>=24.1.0; extra == 'all'
@@ -31,6 +31,7 @@ Requires-Dist: coverage>=7.4.0; extra == 'dev'
31
31
  Requires-Dist: fastapi>=0.110.0; extra == 'dev'
32
32
  Requires-Dist: httpx>=0.27.0; extra == 'dev'
33
33
  Requires-Dist: mypy>=1.8.0; extra == 'dev'
34
+ Requires-Dist: prometheus-client>=0.19.0; extra == 'dev'
34
35
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
35
36
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
36
37
  Requires-Dist: ruff>=0.3.0; extra == 'dev'
@@ -48,7 +49,7 @@ Description-Content-Type: text/markdown
48
49
  </p>
49
50
 
50
51
  <p align="center">
51
- <strong>Catch event-loop blocking in FastAPI with per-request attribution.</strong>
52
+ <strong>Catch event-loop blocking in FastAPI and see which requests were in flight.</strong>
52
53
  </p>
53
54
 
54
55
  <p align="center">
@@ -57,9 +58,13 @@ Description-Content-Type: text/markdown
57
58
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
58
59
  </p>
59
60
 
61
+ <p align="center">
62
+ <img src="assets/demo.gif" alt="LoopGuard demo: a fast endpoint passes, a blocking endpoint fails with a 503, and the console banner explains why" width="800" />
63
+ </p>
64
+
60
65
  ---
61
66
 
62
- When a request blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and tells you which endpoint caused it**.
67
+ When something blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and narrows it down to the requests that were in flight when the loop stalled**. The sentinel measures loop lag, so it cannot name the single guilty handler — it reports every request that was active during the stall.
63
68
 
64
69
  ## Install
65
70
 
@@ -88,9 +93,12 @@ app.add_middleware(LoopGuardMiddleware)
88
93
  ```python
89
94
  from fastapi_loopguard import LoopGuardConfig
90
95
 
91
- # Development: strict enforcement (503 on blocking)
96
+ # Development: diagnostic headers on every response
92
97
  config = LoopGuardConfig(dev_mode=True)
93
98
 
99
+ # Development / CI: fail loudly with an educational 503
100
+ config = LoopGuardConfig(enforcement_mode="strict")
101
+
94
102
  # Production: silent logging
95
103
  config = LoopGuardConfig(enforcement_mode="log")
96
104
 
@@ -103,7 +111,7 @@ app.add_middleware(LoopGuardMiddleware, config=config)
103
111
  Returns an educational 503 page that explains what went wrong and how to fix it:
104
112
 
105
113
  <p align="center">
106
- <img src="assets/error-page-screenshot.png" alt="Strict mode error page" width="600" />
114
+ <img src="assets/error-page.gif" alt="Strict mode error page" width="600" />
107
115
  </p>
108
116
 
109
117
  ---
@@ -118,7 +126,7 @@ Adds diagnostic headers to every response for debugging:
118
126
  ---
119
127
 
120
128
  ### Log Mode
121
- Writes structured logs with full request attribution:
129
+ Writes structured logs listing the requests that were in flight:
122
130
 
123
131
  <p align="center">
124
132
  <img src="assets/error-page-screenshot-console.png" alt="Console output" width="600" />
@@ -126,6 +134,21 @@ Writes structured logs with full request attribution:
126
134
 
127
135
  ---
128
136
 
137
+ ## Testing AI-Generated Code
138
+
139
+ AI agents write plausible async code that quietly blocks the event loop. The bundled pytest plugin turns that into a red test and a machine-readable report the agent can fix from — no per-test annotations:
140
+
141
+ ```ini
142
+ # pytest.ini
143
+ [pytest]
144
+ loopguard_all_async = true # every async test fails on blocking
145
+ loopguard_report = loopguard.json # verdicts + fix hints for the agent
146
+ ```
147
+
148
+ See [docs/AI-HARNESS.md](docs/AI-HARNESS.md) for the report schema, the `no_blocking` / `allow_blocking` markers, and a drop-in snippet for your project's agent instructions.
149
+
150
+ ---
151
+
129
152
  <p align="center">
130
153
  <a href="docs/CONFIGURATION.md"><strong>Full Configuration Reference</strong></a>
131
154
  </p>
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Catch event-loop blocking in FastAPI with per-request attribution.</strong>
6
+ <strong>Catch event-loop blocking in FastAPI and see which requests were in flight.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -12,9 +12,13 @@
12
12
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
13
13
  </p>
14
14
 
15
+ <p align="center">
16
+ <img src="assets/demo.gif" alt="LoopGuard demo: a fast endpoint passes, a blocking endpoint fails with a 503, and the console banner explains why" width="800" />
17
+ </p>
18
+
15
19
  ---
16
20
 
17
- When a request blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and tells you which endpoint caused it**.
21
+ When something blocks your event loop (via `time.sleep()`, blocking I/O, or CPU work), LoopGuard detects it **and narrows it down to the requests that were in flight when the loop stalled**. The sentinel measures loop lag, so it cannot name the single guilty handler — it reports every request that was active during the stall.
18
22
 
19
23
  ## Install
20
24
 
@@ -43,9 +47,12 @@ app.add_middleware(LoopGuardMiddleware)
43
47
  ```python
44
48
  from fastapi_loopguard import LoopGuardConfig
45
49
 
46
- # Development: strict enforcement (503 on blocking)
50
+ # Development: diagnostic headers on every response
47
51
  config = LoopGuardConfig(dev_mode=True)
48
52
 
53
+ # Development / CI: fail loudly with an educational 503
54
+ config = LoopGuardConfig(enforcement_mode="strict")
55
+
49
56
  # Production: silent logging
50
57
  config = LoopGuardConfig(enforcement_mode="log")
51
58
 
@@ -58,7 +65,7 @@ app.add_middleware(LoopGuardMiddleware, config=config)
58
65
  Returns an educational 503 page that explains what went wrong and how to fix it:
59
66
 
60
67
  <p align="center">
61
- <img src="assets/error-page-screenshot.png" alt="Strict mode error page" width="600" />
68
+ <img src="assets/error-page.gif" alt="Strict mode error page" width="600" />
62
69
  </p>
63
70
 
64
71
  ---
@@ -73,7 +80,7 @@ Adds diagnostic headers to every response for debugging:
73
80
  ---
74
81
 
75
82
  ### Log Mode
76
- Writes structured logs with full request attribution:
83
+ Writes structured logs listing the requests that were in flight:
77
84
 
78
85
  <p align="center">
79
86
  <img src="assets/error-page-screenshot-console.png" alt="Console output" width="600" />
@@ -81,6 +88,21 @@ Writes structured logs with full request attribution:
81
88
 
82
89
  ---
83
90
 
91
+ ## Testing AI-Generated Code
92
+
93
+ AI agents write plausible async code that quietly blocks the event loop. The bundled pytest plugin turns that into a red test and a machine-readable report the agent can fix from — no per-test annotations:
94
+
95
+ ```ini
96
+ # pytest.ini
97
+ [pytest]
98
+ loopguard_all_async = true # every async test fails on blocking
99
+ loopguard_report = loopguard.json # verdicts + fix hints for the agent
100
+ ```
101
+
102
+ See [docs/AI-HARNESS.md](docs/AI-HARNESS.md) for the report schema, the `no_blocking` / `allow_blocking` markers, and a drop-in snippet for your project's agent instructions.
103
+
104
+ ---
105
+
84
106
  <p align="center">
85
107
  <a href="docs/CONFIGURATION.md"><strong>Full Configuration Reference</strong></a>
86
108
  </p>
Binary file
@@ -0,0 +1,59 @@
1
+ # VHS tape for assets/demo.gif — reproduce with:
2
+ # brew install vhs
3
+ # pip install -e ".[stress]" (needs uvicorn; run from the repo root)
4
+ # vhs assets/demo.tape
5
+ #
6
+ # Story: pain -> catch -> install.
7
+ # 1. baseline: the async app answers in ~0.1s
8
+ # 2. one request runs blocking code -> EVERY request freezes with it
9
+ # (the hang is real: /api/items stalls ~2s on screen, then the
10
+ # blocking request comes back 503)
11
+ # 3. the LoopGuard console banner explains the fix + install CTA
12
+
13
+ Output assets/demo.gif
14
+
15
+ Set Shell bash
16
+ Set FontSize 14
17
+ Set Width 800
18
+ Set Height 540
19
+ Set Padding 12
20
+ Set TypingSpeed 20ms
21
+ Set Theme "Catppuccin Mocha"
22
+ Set WindowBar Colorful
23
+ Set BorderRadius 8
24
+
25
+ Hide
26
+ Type "source .venv/bin/activate && python examples/demo_app.py >/tmp/loopguard-demo.log 2>&1 & sleep 2; TIMEFORMAT=' took %Rs'; clear"
27
+ Enter
28
+ Sleep 2.5s
29
+ Show
30
+
31
+ Type "# a healthy async app"
32
+ Enter
33
+ Type "time curl -s localhost:8765/api/items"
34
+ Enter
35
+ Sleep 2s
36
+
37
+ Ctrl+L
38
+ Type "# one request runs blocking code - every request freezes with it"
39
+ Enter
40
+ Type "(curl -so /dev/null -w 'culprit -> %{http_code}\n' localhost:8765/api/users &)"
41
+ Enter
42
+ Type "time curl -s localhost:8765/api/items"
43
+ Enter
44
+ Sleep 4.5s
45
+
46
+ Ctrl+L
47
+ Type "# the server console explains what happened"
48
+ Enter
49
+ Type "grep -B 2 -A 18 'LOOPGUARD' /tmp/loopguard-demo.log"
50
+ Enter
51
+ Sleep 2.5s
52
+ Type "# pip install fastapi-loopguard -> app.add_middleware(LoopGuardMiddleware)"
53
+ Enter
54
+ Sleep 2.5s
55
+
56
+ Hide
57
+ Type "kill %1"
58
+ Enter
59
+ Sleep 500ms