fastapi-loopguard 0.6.0__tar.gz → 0.6.1__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.
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/CHANGELOG.md +11 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/PKG-INFO +1 -1
- fastapi_loopguard-0.6.1/assets/demo.gif +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/demo.tape +1 -1
- fastapi_loopguard-0.6.1/assets/error-page.gif +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/pyproject.toml +129 -129
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/middleware.py +74 -30
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_middleware.py +1337 -1252
- fastapi_loopguard-0.6.0/assets/demo.gif +0 -0
- fastapi_loopguard-0.6.0/assets/error-page.gif +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/dependabot.yml +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/workflows/ci.yml +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/workflows/publish.yml +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.gitignore +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/CLAUDE.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/FINDINGS.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/LICENSE +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/README.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/error-page-screenshot-console.png +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/error-page-screenshot-endpoint.png +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/loopguard-logo.webp +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/docs/AI-HARNESS.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/docs/CONFIGURATION.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/README.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/runner.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/app_skeleton.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/checks.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/helpers.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/reference/blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/reference/clean.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/task.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/app_skeleton.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/checks.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/helpers.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/reference/blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/reference/clean.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/task.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/app_skeleton.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/checks.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/helpers.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/reference/blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/reference/clean.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/task.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/app_skeleton.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/checks.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/helpers.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/reference/blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/reference/clean.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/task.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/app_skeleton.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/checks.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/helpers.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/reference/blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/reference/clean.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/task.md +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/demo_app.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/locustfile.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/run_stress_test.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/stress_app.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/__init__.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/config.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/context.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/logging.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/metrics.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/monitor.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/py.typed +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/pytest_plugin.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/__init__.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_config.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_context.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_cumulative_blocking.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_enforcement_mode.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_evals_runner.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_logging.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_metrics.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_monitor.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_packaging.py +0 -0
- {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_pytest_plugin.py +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1 (2026-08-12)
|
|
4
|
+
|
|
5
|
+
- The console blocking banner is restyled: a slim rule frame instead of
|
|
6
|
+
the `=`/`!` walls, with ANSI color hierarchy (headline red, explanation
|
|
7
|
+
dim, fixes cyan) on real terminals. Redirected streams and `NO_COLOR`
|
|
8
|
+
environments get identical plain text, so log grep patterns keep
|
|
9
|
+
working. (#9)
|
|
10
|
+
- The 503 page's BAD/GOOD code examples render each snippet on its own
|
|
11
|
+
line again: the blocks are now `<pre>` elements; the previous `<div>`s
|
|
12
|
+
collapsed the newlines. (#10)
|
|
13
|
+
|
|
3
14
|
## 0.6.0 (2026-08-12)
|
|
4
15
|
|
|
5
16
|
Detection-core hardening plus the AI test harness. Fixes found by a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-loopguard
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
Binary file
|
|
@@ -46,7 +46,7 @@ Sleep 4.5s
|
|
|
46
46
|
Ctrl+L
|
|
47
47
|
Type "# the server console explains what happened"
|
|
48
48
|
Enter
|
|
49
|
-
Type "grep -
|
|
49
|
+
Type "grep -A 17 'LOOPGUARD' /tmp/loopguard-demo.log"
|
|
50
50
|
Enter
|
|
51
51
|
Sleep 2.5s
|
|
52
52
|
Type "# pip install fastapi-loopguard -> app.add_middleware(LoopGuardMiddleware)"
|
|
Binary file
|
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "fastapi-loopguard"
|
|
7
|
-
version = "0.6.
|
|
8
|
-
description = "Detect event-loop blocking in FastAPI/Starlette with per-request attribution"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
license = "MIT"
|
|
11
|
-
requires-python = ">=3.12"
|
|
12
|
-
authors = [
|
|
13
|
-
{ name = "Parham" }
|
|
14
|
-
]
|
|
15
|
-
keywords = [
|
|
16
|
-
"fastapi",
|
|
17
|
-
"starlette",
|
|
18
|
-
"asyncio",
|
|
19
|
-
"middleware",
|
|
20
|
-
"monitoring",
|
|
21
|
-
"performance",
|
|
22
|
-
"blocking",
|
|
23
|
-
"event-loop",
|
|
24
|
-
]
|
|
25
|
-
classifiers = [
|
|
26
|
-
"Development Status :: 4 - Beta",
|
|
27
|
-
"Framework :: AsyncIO",
|
|
28
|
-
"Framework :: FastAPI",
|
|
29
|
-
"Intended Audience :: Developers",
|
|
30
|
-
"License :: OSI Approved :: MIT License",
|
|
31
|
-
"Programming Language :: Python :: 3",
|
|
32
|
-
"Programming Language :: Python :: 3.12",
|
|
33
|
-
"Programming Language :: Python :: 3.13",
|
|
34
|
-
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
-
"Topic :: System :: Monitoring",
|
|
36
|
-
"Typing :: Typed",
|
|
37
|
-
]
|
|
38
|
-
dependencies = [
|
|
39
|
-
"starlette>=0.37.0,<2.0",
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
[project.optional-dependencies]
|
|
43
|
-
prometheus = ["prometheus-client>=0.19.0"]
|
|
44
|
-
structlog = ["structlog>=24.1.0"]
|
|
45
|
-
all = [
|
|
46
|
-
"prometheus-client>=0.19.0",
|
|
47
|
-
"structlog>=24.1.0",
|
|
48
|
-
]
|
|
49
|
-
dev = [
|
|
50
|
-
"pytest>=8.0.0",
|
|
51
|
-
"pytest-asyncio>=0.23.0",
|
|
52
|
-
"httpx>=0.27.0",
|
|
53
|
-
"fastapi>=0.110.0",
|
|
54
|
-
# Without this, tests/test_metrics.py importorskip-skips silently and
|
|
55
|
-
# metrics.py sits at 0% coverage in CI
|
|
56
|
-
"prometheus-client>=0.19.0",
|
|
57
|
-
"mypy>=1.8.0",
|
|
58
|
-
"ruff>=0.3.0",
|
|
59
|
-
"coverage>=7.4.0",
|
|
60
|
-
]
|
|
61
|
-
stress = [
|
|
62
|
-
"locust>=2.20.0",
|
|
63
|
-
"uvicorn>=0.27.0",
|
|
64
|
-
]
|
|
65
|
-
|
|
66
|
-
[project.urls]
|
|
67
|
-
Homepage = "https://github.com/parhamdavari/fastapi-loopguard"
|
|
68
|
-
Documentation = "https://github.com/parhamdavari/fastapi-loopguard#readme"
|
|
69
|
-
Repository = "https://github.com/parhamdavari/fastapi-loopguard"
|
|
70
|
-
Issues = "https://github.com/parhamdavari/fastapi-loopguard/issues"
|
|
71
|
-
|
|
72
|
-
[project.entry-points.pytest11]
|
|
73
|
-
loopguard = "fastapi_loopguard.pytest_plugin"
|
|
74
|
-
|
|
75
|
-
[tool.hatch.build.targets.wheel]
|
|
76
|
-
packages = ["src/fastapi_loopguard"]
|
|
77
|
-
|
|
78
|
-
[tool.hatch.build.targets.sdist]
|
|
79
|
-
exclude = [
|
|
80
|
-
"/.claude",
|
|
81
|
-
"/stress_results*.csv",
|
|
82
|
-
"/.coverage",
|
|
83
|
-
"/.pytest_cache",
|
|
84
|
-
"/.mypy_cache",
|
|
85
|
-
"/.ruff_cache",
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
[tool.pytest.ini_options]
|
|
89
|
-
asyncio_mode = "auto"
|
|
90
|
-
asyncio_default_fixture_loop_scope = "function"
|
|
91
|
-
testpaths = ["tests"]
|
|
92
|
-
|
|
93
|
-
[tool.mypy]
|
|
94
|
-
python_version = "3.12"
|
|
95
|
-
strict = true
|
|
96
|
-
warn_return_any = true
|
|
97
|
-
warn_unused_configs = true
|
|
98
|
-
|
|
99
|
-
[[tool.mypy.overrides]]
|
|
100
|
-
module = "prometheus_client.*"
|
|
101
|
-
ignore_missing_imports = true
|
|
102
|
-
|
|
103
|
-
[[tool.mypy.overrides]]
|
|
104
|
-
module = "locust.*"
|
|
105
|
-
ignore_missing_imports = true
|
|
106
|
-
|
|
107
|
-
[tool.ruff]
|
|
108
|
-
target-version = "py312"
|
|
109
|
-
line-length = 88
|
|
110
|
-
src = ["src", "tests"]
|
|
111
|
-
|
|
112
|
-
[tool.ruff.lint]
|
|
113
|
-
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM", "ANN"]
|
|
114
|
-
ignore = ["ANN401"]
|
|
115
|
-
|
|
116
|
-
[tool.ruff.format]
|
|
117
|
-
quote-style = "double"
|
|
118
|
-
indent-style = "space"
|
|
119
|
-
|
|
120
|
-
[tool.coverage.run]
|
|
121
|
-
source = ["src/fastapi_loopguard"]
|
|
122
|
-
branch = true
|
|
123
|
-
|
|
124
|
-
[tool.coverage.report]
|
|
125
|
-
exclude_lines = [
|
|
126
|
-
"pragma: no cover",
|
|
127
|
-
"if TYPE_CHECKING:",
|
|
128
|
-
"raise NotImplementedError",
|
|
129
|
-
]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastapi-loopguard"
|
|
7
|
+
version = "0.6.1"
|
|
8
|
+
description = "Detect event-loop blocking in FastAPI/Starlette with per-request attribution"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Parham" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"fastapi",
|
|
17
|
+
"starlette",
|
|
18
|
+
"asyncio",
|
|
19
|
+
"middleware",
|
|
20
|
+
"monitoring",
|
|
21
|
+
"performance",
|
|
22
|
+
"blocking",
|
|
23
|
+
"event-loop",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Framework :: AsyncIO",
|
|
28
|
+
"Framework :: FastAPI",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
"Topic :: System :: Monitoring",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"starlette>=0.37.0,<2.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
prometheus = ["prometheus-client>=0.19.0"]
|
|
44
|
+
structlog = ["structlog>=24.1.0"]
|
|
45
|
+
all = [
|
|
46
|
+
"prometheus-client>=0.19.0",
|
|
47
|
+
"structlog>=24.1.0",
|
|
48
|
+
]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.0.0",
|
|
51
|
+
"pytest-asyncio>=0.23.0",
|
|
52
|
+
"httpx>=0.27.0",
|
|
53
|
+
"fastapi>=0.110.0",
|
|
54
|
+
# Without this, tests/test_metrics.py importorskip-skips silently and
|
|
55
|
+
# metrics.py sits at 0% coverage in CI
|
|
56
|
+
"prometheus-client>=0.19.0",
|
|
57
|
+
"mypy>=1.8.0",
|
|
58
|
+
"ruff>=0.3.0",
|
|
59
|
+
"coverage>=7.4.0",
|
|
60
|
+
]
|
|
61
|
+
stress = [
|
|
62
|
+
"locust>=2.20.0",
|
|
63
|
+
"uvicorn>=0.27.0",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[project.urls]
|
|
67
|
+
Homepage = "https://github.com/parhamdavari/fastapi-loopguard"
|
|
68
|
+
Documentation = "https://github.com/parhamdavari/fastapi-loopguard#readme"
|
|
69
|
+
Repository = "https://github.com/parhamdavari/fastapi-loopguard"
|
|
70
|
+
Issues = "https://github.com/parhamdavari/fastapi-loopguard/issues"
|
|
71
|
+
|
|
72
|
+
[project.entry-points.pytest11]
|
|
73
|
+
loopguard = "fastapi_loopguard.pytest_plugin"
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.wheel]
|
|
76
|
+
packages = ["src/fastapi_loopguard"]
|
|
77
|
+
|
|
78
|
+
[tool.hatch.build.targets.sdist]
|
|
79
|
+
exclude = [
|
|
80
|
+
"/.claude",
|
|
81
|
+
"/stress_results*.csv",
|
|
82
|
+
"/.coverage",
|
|
83
|
+
"/.pytest_cache",
|
|
84
|
+
"/.mypy_cache",
|
|
85
|
+
"/.ruff_cache",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.pytest.ini_options]
|
|
89
|
+
asyncio_mode = "auto"
|
|
90
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
91
|
+
testpaths = ["tests"]
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
python_version = "3.12"
|
|
95
|
+
strict = true
|
|
96
|
+
warn_return_any = true
|
|
97
|
+
warn_unused_configs = true
|
|
98
|
+
|
|
99
|
+
[[tool.mypy.overrides]]
|
|
100
|
+
module = "prometheus_client.*"
|
|
101
|
+
ignore_missing_imports = true
|
|
102
|
+
|
|
103
|
+
[[tool.mypy.overrides]]
|
|
104
|
+
module = "locust.*"
|
|
105
|
+
ignore_missing_imports = true
|
|
106
|
+
|
|
107
|
+
[tool.ruff]
|
|
108
|
+
target-version = "py312"
|
|
109
|
+
line-length = 88
|
|
110
|
+
src = ["src", "tests"]
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint]
|
|
113
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM", "ANN"]
|
|
114
|
+
ignore = ["ANN401"]
|
|
115
|
+
|
|
116
|
+
[tool.ruff.format]
|
|
117
|
+
quote-style = "double"
|
|
118
|
+
indent-style = "space"
|
|
119
|
+
|
|
120
|
+
[tool.coverage.run]
|
|
121
|
+
source = ["src/fastapi_loopguard"]
|
|
122
|
+
branch = true
|
|
123
|
+
|
|
124
|
+
[tool.coverage.report]
|
|
125
|
+
exclude_lines = [
|
|
126
|
+
"pragma: no cover",
|
|
127
|
+
"if TYPE_CHECKING:",
|
|
128
|
+
"raise NotImplementedError",
|
|
129
|
+
]
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import sys
|
|
12
13
|
import uuid
|
|
13
14
|
from typing import TYPE_CHECKING
|
|
@@ -25,6 +26,71 @@ from .monitor import SentinelMonitor
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from .config import LoopGuardConfig
|
|
27
28
|
|
|
29
|
+
# ANSI styles for the console banner
|
|
30
|
+
_RESET = "\x1b[0m"
|
|
31
|
+
_BOLD_RED = "\x1b[1;31m"
|
|
32
|
+
_DIM = "\x1b[2m"
|
|
33
|
+
_CYAN = "\x1b[36m"
|
|
34
|
+
|
|
35
|
+
_BANNER_WIDTH = 72
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _console_supports_color() -> bool:
|
|
39
|
+
"""Color only on a real terminal, and never against NO_COLOR.
|
|
40
|
+
|
|
41
|
+
Escape codes in a redirected stream would pollute log files and
|
|
42
|
+
aggregators, so anything that is not a TTY gets plain text.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
is_tty = sys.stderr.isatty()
|
|
46
|
+
except (AttributeError, ValueError):
|
|
47
|
+
is_tty = False
|
|
48
|
+
return is_tty and not os.environ.get("NO_COLOR")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_console_warning(ctx: RequestContext, use_color: bool) -> str:
|
|
52
|
+
"""Render the blocking banner, with or without ANSI color.
|
|
53
|
+
|
|
54
|
+
The plain rendering is the colored one minus escape codes — same
|
|
55
|
+
lines, same order — so log output and terminal output stay greppable
|
|
56
|
+
with the same patterns.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def paint(code: str, text: str) -> str:
|
|
60
|
+
return f"{code}{text}{_RESET}" if use_color else text
|
|
61
|
+
|
|
62
|
+
title = "LOOPGUARD: Event Loop Blocked!"
|
|
63
|
+
top_rule = f"── {title} " + "─" * max(0, _BANNER_WIDTH - len(title) - 4)
|
|
64
|
+
lines = [
|
|
65
|
+
"",
|
|
66
|
+
paint(_BOLD_RED, top_rule),
|
|
67
|
+
"",
|
|
68
|
+
f" In-flight request: {ctx.method} {ctx.path}",
|
|
69
|
+
f" Request ID: {ctx.request_id}",
|
|
70
|
+
f" Blocked: {ctx.blocking_count} time(s), {ctx.total_blocking_ms:.1f}ms total",
|
|
71
|
+
"",
|
|
72
|
+
paint(
|
|
73
|
+
_DIM,
|
|
74
|
+
" Blocking was detected while this request was in flight. "
|
|
75
|
+
"The culprit\n may be this request or any other concurrent "
|
|
76
|
+
"request.\n ALL requests were frozen while the event loop "
|
|
77
|
+
"was blocked.",
|
|
78
|
+
),
|
|
79
|
+
"",
|
|
80
|
+
" Common fixes:",
|
|
81
|
+
paint(
|
|
82
|
+
_CYAN,
|
|
83
|
+
" time.sleep(n) -> await asyncio.sleep(n)\n"
|
|
84
|
+
" requests.get(url) -> await httpx.AsyncClient().get(url)\n"
|
|
85
|
+
" open(f).read() -> await aiofiles.open(f)\n"
|
|
86
|
+
" subprocess.run(...) -> await asyncio.create_subprocess_exec(...)",
|
|
87
|
+
),
|
|
88
|
+
"",
|
|
89
|
+
" Docs: https://fastapi.tiangolo.com/async/",
|
|
90
|
+
paint(_BOLD_RED, "─" * _BANNER_WIDTH),
|
|
91
|
+
]
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
28
94
|
|
|
29
95
|
class LoopGuardMiddleware:
|
|
30
96
|
"""Pure ASGI middleware that detects event loop blocking per-request.
|
|
@@ -297,30 +363,10 @@ class LoopGuardMiddleware:
|
|
|
297
363
|
|
|
298
364
|
def _log_console_warning(self, ctx: RequestContext) -> None:
|
|
299
365
|
"""Print attention-grabbing console warning to stderr."""
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
{"!" * 72}
|
|
305
|
-
|
|
306
|
-
In-flight request: {ctx.method} {ctx.path}
|
|
307
|
-
Request ID: {ctx.request_id}
|
|
308
|
-
Blocked: {ctx.blocking_count} time(s), {ctx.total_blocking_ms:.1f}ms total
|
|
309
|
-
|
|
310
|
-
Blocking was detected while this request was in flight. The culprit
|
|
311
|
-
may be this request or any other concurrent request.
|
|
312
|
-
ALL requests were frozen while the event loop was blocked.
|
|
313
|
-
|
|
314
|
-
Common fixes:
|
|
315
|
-
time.sleep(n) -> await asyncio.sleep(n)
|
|
316
|
-
requests.get(url) -> await httpx.AsyncClient().get(url)
|
|
317
|
-
open(f).read() -> await aiofiles.open(f)
|
|
318
|
-
subprocess.run(...) -> await asyncio.create_subprocess_exec(...)
|
|
319
|
-
|
|
320
|
-
Docs: https://fastapi.tiangolo.com/async/
|
|
321
|
-
{"=" * 72}
|
|
322
|
-
"""
|
|
323
|
-
print(warning, file=sys.stderr)
|
|
366
|
+
print(
|
|
367
|
+
_format_console_warning(ctx, _console_supports_color()),
|
|
368
|
+
file=sys.stderr,
|
|
369
|
+
)
|
|
324
370
|
|
|
325
371
|
def _generate_error_html(self, ctx: RequestContext) -> str:
|
|
326
372
|
"""Generate educational HTML error page for strict mode."""
|
|
@@ -417,7 +463,7 @@ class LoopGuardMiddleware:
|
|
|
417
463
|
<h2>Common Causes & Fixes</h2>
|
|
418
464
|
|
|
419
465
|
<div class="bad-label">BAD - These block the event loop:</div>
|
|
420
|
-
<
|
|
466
|
+
<pre class="code-block bad">
|
|
421
467
|
<span class="comment"># Sleeping</span>
|
|
422
468
|
time.sleep(1)
|
|
423
469
|
|
|
@@ -428,11 +474,10 @@ requests.get("https://api.example.com")
|
|
|
428
474
|
open("data.json").read()
|
|
429
475
|
|
|
430
476
|
<span class="comment"># Subprocess</span>
|
|
431
|
-
subprocess.run(["ls", "-la"])
|
|
432
|
-
</div>
|
|
477
|
+
subprocess.run(["ls", "-la"])</pre>
|
|
433
478
|
|
|
434
479
|
<div class="good-label">GOOD - Use async alternatives:</div>
|
|
435
|
-
<
|
|
480
|
+
<pre class="code-block good">
|
|
436
481
|
<span class="comment"># Sleeping</span>
|
|
437
482
|
await asyncio.sleep(1)
|
|
438
483
|
|
|
@@ -446,8 +491,7 @@ async with aiofiles.open("data.json") as f:
|
|
|
446
491
|
|
|
447
492
|
<span class="comment"># Subprocess</span>
|
|
448
493
|
proc = await asyncio.create_subprocess_exec("ls", "-la")
|
|
449
|
-
await proc.wait()
|
|
450
|
-
</div>
|
|
494
|
+
await proc.wait()</pre>
|
|
451
495
|
|
|
452
496
|
<h2>Quick Fixes</h2>
|
|
453
497
|
<ul>
|