fastapi-loopguard 0.3.1__py3-none-any.whl

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.
@@ -0,0 +1,135 @@
1
+ """Pytest plugin for detecting event loop blocking in tests.
2
+
3
+ Usage:
4
+ # pytest.ini
5
+ [pytest]
6
+ loopguard_threshold_ms = 50
7
+
8
+ # In test files
9
+ import pytest
10
+
11
+ @pytest.mark.no_blocking
12
+ async def test_my_endpoint():
13
+ # If this test blocks the event loop, it will fail
14
+ ...
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ from collections.abc import Generator
22
+ from typing import Any
23
+
24
+ import pytest
25
+
26
+ # Marker for tests that should fail on blocking
27
+ MARKER_NAME = "no_blocking"
28
+
29
+
30
+ def pytest_configure(config: pytest.Config) -> None:
31
+ """Register the no_blocking marker."""
32
+ config.addinivalue_line(
33
+ "markers",
34
+ f"{MARKER_NAME}: fail test if event loop blocking is detected",
35
+ )
36
+
37
+
38
+ def pytest_addoption(parser: pytest.Parser) -> None:
39
+ """Add loopguard options to pytest."""
40
+ parser.addini(
41
+ "loopguard_threshold_ms",
42
+ "Blocking detection threshold in milliseconds",
43
+ type="string",
44
+ default="50",
45
+ )
46
+
47
+
48
+ class BlockingDetector:
49
+ """Detects event loop blocking during test execution."""
50
+
51
+ def __init__(self, threshold_ms: float = 50.0) -> None:
52
+ self.threshold_ms = threshold_ms
53
+ self.blocking_events: list[float] = []
54
+ self._running = False
55
+ self._task: asyncio.Task[None] | None = None
56
+
57
+ async def start(self) -> None:
58
+ """Start the blocking detector."""
59
+ self._running = True
60
+ self._task = asyncio.create_task(self._monitor())
61
+
62
+ async def stop(self) -> None:
63
+ """Stop the blocking detector."""
64
+ self._running = False
65
+ if self._task:
66
+ self._task.cancel()
67
+ with contextlib.suppress(asyncio.CancelledError):
68
+ await self._task
69
+
70
+ async def _monitor(self) -> None:
71
+ """Monitor for blocking."""
72
+ loop = asyncio.get_running_loop()
73
+ interval = 0.005 # 5ms
74
+
75
+ while self._running:
76
+ start = loop.time()
77
+ await asyncio.sleep(interval)
78
+ elapsed = loop.time() - start
79
+ lag_ms = (elapsed - interval) * 1000
80
+
81
+ if lag_ms > self.threshold_ms:
82
+ self.blocking_events.append(lag_ms)
83
+
84
+
85
+ @pytest.fixture
86
+ def loopguard_detector(
87
+ request: pytest.FixtureRequest,
88
+ ) -> Generator[BlockingDetector, None, None]:
89
+ """Fixture that provides a blocking detector for tests."""
90
+ threshold_str = request.config.getini("loopguard_threshold_ms")
91
+ threshold = float(threshold_str) if threshold_str else 50.0
92
+
93
+ detector = BlockingDetector(threshold_ms=threshold)
94
+ yield detector
95
+
96
+
97
+ @pytest.hookimpl(tryfirst=True)
98
+ def pytest_runtest_call(item: pytest.Item) -> None:
99
+ """Check for blocking after test execution."""
100
+ marker = item.get_closest_marker(MARKER_NAME)
101
+ if marker is None:
102
+ return
103
+
104
+ # Only works with Function items (which have obj attribute)
105
+ if not isinstance(item, pytest.Function):
106
+ return
107
+
108
+ # Store the original test function
109
+ original_func = item.obj
110
+
111
+ if asyncio.iscoroutinefunction(original_func):
112
+ # Wrap async test with blocking detection
113
+ async def wrapped(*args: Any, **kwargs: Any) -> Any:
114
+ threshold_str = item.config.getini("loopguard_threshold_ms")
115
+ threshold = float(threshold_str) if threshold_str else 50.0
116
+
117
+ detector = BlockingDetector(threshold_ms=threshold)
118
+ await detector.start()
119
+
120
+ try:
121
+ result = await original_func(*args, **kwargs)
122
+ finally:
123
+ await detector.stop()
124
+
125
+ if detector.blocking_events:
126
+ max_lag = max(detector.blocking_events)
127
+ pytest.fail(
128
+ f"Event loop blocking detected! "
129
+ f"{len(detector.blocking_events)} blocking event(s), "
130
+ f"max lag: {max_lag:.2f}ms (threshold: {threshold}ms)"
131
+ )
132
+
133
+ return result
134
+
135
+ item.obj = wrapped
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-loopguard
3
+ Version: 0.3.1
4
+ Summary: Detect event-loop blocking in FastAPI/Starlette with per-request attribution
5
+ Project-URL: Homepage, https://github.com/parhamdavari/fastapi-loopguard
6
+ Project-URL: Documentation, https://github.com/parhamdavari/fastapi-loopguard#readme
7
+ Project-URL: Repository, https://github.com/parhamdavari/fastapi-loopguard
8
+ Project-URL: Issues, https://github.com/parhamdavari/fastapi-loopguard/issues
9
+ Author: Parham
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asyncio,blocking,event-loop,fastapi,middleware,monitoring,performance,starlette
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.12
25
+ Requires-Dist: starlette<1.0,>=0.37.0
26
+ Provides-Extra: all
27
+ Requires-Dist: prometheus-client>=0.19.0; extra == 'all'
28
+ Requires-Dist: structlog>=24.1.0; extra == 'all'
29
+ Provides-Extra: dev
30
+ Requires-Dist: coverage>=7.4.0; extra == 'dev'
31
+ Requires-Dist: fastapi>=0.110.0; extra == 'dev'
32
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.8.0; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
35
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.3.0; extra == 'dev'
37
+ Provides-Extra: prometheus
38
+ Requires-Dist: prometheus-client>=0.19.0; extra == 'prometheus'
39
+ Provides-Extra: stress
40
+ Requires-Dist: locust>=2.20.0; extra == 'stress'
41
+ Requires-Dist: uvicorn>=0.27.0; extra == 'stress'
42
+ Provides-Extra: structlog
43
+ Requires-Dist: structlog>=24.1.0; extra == 'structlog'
44
+ Description-Content-Type: text/markdown
45
+
46
+ <p align="center">
47
+ <img src="assets/loopguard-logo.webp" alt="LoopGuard logo" width="320" />
48
+ </p>
49
+
50
+ <p align="center">
51
+ Detect event-loop blocking in FastAPI/Starlette with <strong>per-request attribution</strong>.
52
+ </p>
53
+
54
+ ---
55
+
56
+ [![PyPI version](https://badge.fury.io/py/fastapi-loopguard.svg)](https://badge.fury.io/py/fastapi-loopguard)
57
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
58
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
59
+
60
+ `fastapi-loopguard` monitors your event loop and tells you exactly which request caused the blocking. It handles high-concurrency environments with adaptive thresholds and integrates seamlessly with Prometheus.
61
+
62
+ ###
63
+
64
+ ### Quick Start
65
+
66
+ ```bash
67
+ pip install fastapi-loopguard
68
+ ```
69
+
70
+ ```python
71
+ from fastapi import FastAPI
72
+ from fastapi_loopguard import LoopGuardMiddleware
73
+
74
+ app = FastAPI()
75
+ app.add_middleware(LoopGuardMiddleware)
76
+ ```
77
+
78
+ ### Features
79
+
80
+ * **Per-Request Attribution**: Pinpoint the exact endpoint causing latency.
81
+ * **Cumulative Blocking Detection**: Catch "death by a thousand cuts" where frequent small blocks freeze the loop.
82
+ * **Adaptive Thresholds**: Smart baselines that adjust to server load.
83
+ * **Zero-Overhead Sentinel**: Cooperative monitoring with negligible CPU usage.
84
+ * **Observability Ready**: Built-in support for Prometheus and structured logging.
85
+
86
+ ### Configuration
87
+
88
+ LoopGuard is highly configurable to suit your environment:
89
+
90
+ ```python
91
+ from fastapi import FastAPI
92
+ from fastapi_loopguard import LoopGuardMiddleware, LoopGuardConfig
93
+
94
+ config = LoopGuardConfig(
95
+ # Basic settings
96
+ monitor_interval_ms=10.0,
97
+ fallback_threshold_ms=50.0,
98
+
99
+ # Enable Cumulative Blocking Detection
100
+ # Detects frequent small blocks (e.g., 20ms) that sum up to a large freeze
101
+ cumulative_blocking_enabled=True,
102
+ cumulative_blocking_threshold_ms=200.0, # Alert if total block > 200ms...
103
+ cumulative_window_ms=1000.0, # ...within any 1-second window
104
+ )
105
+
106
+ app = FastAPI()
107
+ app.add_middleware(LoopGuardMiddleware, config=config)
108
+ ```
109
+
110
+ ### Request/Response flows
111
+
112
+ ```mermaid
113
+ sequenceDiagram
114
+ autonumber
115
+ actor Client
116
+ participant MW as Middleware<br/>(middleware.py)
117
+ participant Reg as Context Registry<br/>(context.py)
118
+ participant Mon as Sentinel Monitor<br/>(monitor.py)
119
+ participant App as FastAPI App<br/>(Your Code)
120
+
121
+ Note over MW, App: Request Flow
122
+
123
+ Client->>MW: HTTP Request
124
+ activate MW
125
+
126
+ MW->>Reg: Register Context
127
+ activate Reg
128
+ Reg-->>MW: Done
129
+ deactivate Reg
130
+
131
+ MW->>App: Forward Request
132
+ activate App
133
+
134
+ par Async Monitoring
135
+ Mon->>Reg: Detect Blocking &<br/>Update Contexts
136
+ activate Mon
137
+ activate Reg
138
+ Reg-->>Mon: Updated
139
+ deactivate Reg
140
+ deactivate Mon
141
+ and App Processing
142
+ App-->>MW: Process Complete
143
+ deactivate App
144
+ end
145
+
146
+ MW->>Reg: Unregister Context
147
+ activate Reg
148
+ Reg-->>MW: Done
149
+ deactivate Reg
150
+
151
+ MW->>Client: Response + Headers
152
+ deactivate MW
153
+ ```
154
+
@@ -0,0 +1,14 @@
1
+ fastapi_loopguard/__init__.py,sha256=Rd6OC1wfGYQ7RCt6GRmCfd2S-rU_tz-u4Hwfw9TollU,1744
2
+ fastapi_loopguard/config.py,sha256=KpeUHrGOxituZP6JRmvRKmIf__Oxd7xCFIVoQgRMazM,4166
3
+ fastapi_loopguard/context.py,sha256=5K9R_cf_QY3vm9MX7BdY2ZH6CJ5ie2CXXS7Xd3pKi4g,6199
4
+ fastapi_loopguard/logging.py,sha256=dOPTt0kqL8TddJU8UCdYm06DgKapVNVwn9KzgdtvoVk,2822
5
+ fastapi_loopguard/metrics.py,sha256=h8nv9C4s3E8MgRswpMFkRESB0tE5MWAwGlz_BnMUSSo,6009
6
+ fastapi_loopguard/middleware.py,sha256=2HXtQE6YKqqlOFePyIULcsubj6y6g5N5SkIfi29-32c,7667
7
+ fastapi_loopguard/monitor.py,sha256=X3wKkIJ3XBhorZPPMR2MkbBecW3ER10yhMWfB9-erp0,14477
8
+ fastapi_loopguard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ fastapi_loopguard/pytest_plugin.py,sha256=ZPqnEoiRViiz3uAk1ZlJZvl3DfqlFTZvUbKFiP9BaLA,4097
10
+ fastapi_loopguard-0.3.1.dist-info/METADATA,sha256=c9M9CU3UJYTR_YeAIvNlpJLVNbNp6pnEF18beGaigYs,5071
11
+ fastapi_loopguard-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ fastapi_loopguard-0.3.1.dist-info/entry_points.txt,sha256=tCJjzUcjpSL0ZWR_CFypskuZk_rCJRVEpTYNxzpfiII,55
13
+ fastapi_loopguard-0.3.1.dist-info/licenses/LICENSE,sha256=X7DWiPGillxEDm4x1pGjpNCBDCFSovy0P009ODioYUg,1084
14
+ fastapi_loopguard-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ loopguard = fastapi_loopguard.pytest_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Parham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.