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.
Files changed (78) hide show
  1. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/CHANGELOG.md +11 -0
  2. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/PKG-INFO +1 -1
  3. fastapi_loopguard-0.6.1/assets/demo.gif +0 -0
  4. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/demo.tape +1 -1
  5. fastapi_loopguard-0.6.1/assets/error-page.gif +0 -0
  6. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/pyproject.toml +129 -129
  7. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/middleware.py +74 -30
  8. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_middleware.py +1337 -1252
  9. fastapi_loopguard-0.6.0/assets/demo.gif +0 -0
  10. fastapi_loopguard-0.6.0/assets/error-page.gif +0 -0
  11. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/dependabot.yml +0 -0
  12. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/workflows/ci.yml +0 -0
  13. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.github/workflows/publish.yml +0 -0
  14. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/.gitignore +0 -0
  15. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/CLAUDE.md +0 -0
  16. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/FINDINGS.md +0 -0
  17. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/LICENSE +0 -0
  18. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/README.md +0 -0
  19. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/error-page-screenshot-console.png +0 -0
  20. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/error-page-screenshot-endpoint.png +0 -0
  21. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/assets/loopguard-logo.webp +0 -0
  22. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/docs/AI-HARNESS.md +0 -0
  23. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/docs/CONFIGURATION.md +0 -0
  24. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/README.md +0 -0
  25. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/runner.py +0 -0
  26. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/app_skeleton.py +0 -0
  27. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/checks.py +0 -0
  28. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/helpers.py +0 -0
  29. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/reference/blocking.py +0 -0
  30. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/reference/clean.py +0 -0
  31. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/01-user-lookup/task.md +0 -0
  32. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/app_skeleton.py +0 -0
  33. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/checks.py +0 -0
  34. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/helpers.py +0 -0
  35. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/reference/blocking.py +0 -0
  36. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/reference/clean.py +0 -0
  37. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/02-price-fanout/task.md +0 -0
  38. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/app_skeleton.py +0 -0
  39. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/checks.py +0 -0
  40. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/helpers.py +0 -0
  41. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/reference/blocking.py +0 -0
  42. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/reference/clean.py +0 -0
  43. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/03-report-export/task.md +0 -0
  44. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/app_skeleton.py +0 -0
  45. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/checks.py +0 -0
  46. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/helpers.py +0 -0
  47. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/reference/blocking.py +0 -0
  48. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/reference/clean.py +0 -0
  49. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/04-image-thumbnail/task.md +0 -0
  50. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/app_skeleton.py +0 -0
  51. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/checks.py +0 -0
  52. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/helpers.py +0 -0
  53. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/reference/blocking.py +0 -0
  54. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/reference/clean.py +0 -0
  55. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/evals/tasks/05-audit-log/task.md +0 -0
  56. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/demo_app.py +0 -0
  57. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/locustfile.py +0 -0
  58. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/run_stress_test.py +0 -0
  59. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/examples/stress_app.py +0 -0
  60. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/__init__.py +0 -0
  61. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/config.py +0 -0
  62. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/context.py +0 -0
  63. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/logging.py +0 -0
  64. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/metrics.py +0 -0
  65. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/monitor.py +0 -0
  66. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/py.typed +0 -0
  67. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/src/fastapi_loopguard/pytest_plugin.py +0 -0
  68. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/__init__.py +0 -0
  69. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_config.py +0 -0
  70. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_context.py +0 -0
  71. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_cumulative_blocking.py +0 -0
  72. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_enforcement_mode.py +0 -0
  73. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_evals_runner.py +0 -0
  74. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_logging.py +0 -0
  75. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_metrics.py +0 -0
  76. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_monitor.py +0 -0
  77. {fastapi_loopguard-0.6.0 → fastapi_loopguard-0.6.1}/tests/test_packaging.py +0 -0
  78. {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.0
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 -B 2 -A 18 'LOOPGUARD' /tmp/loopguard-demo.log"
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)"
@@ -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.0"
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
- warning = f"""
301
- {"=" * 72}
302
- {"!" * 72}
303
- LOOPGUARD: Event Loop Blocked!
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
- <div class="code-block bad">
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
- <div class="code-block good">
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>