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.
- fastapi_loopguard/__init__.py +62 -0
- fastapi_loopguard/config.py +88 -0
- fastapi_loopguard/context.py +210 -0
- fastapi_loopguard/logging.py +100 -0
- fastapi_loopguard/metrics.py +204 -0
- fastapi_loopguard/middleware.py +236 -0
- fastapi_loopguard/monitor.py +412 -0
- fastapi_loopguard/py.typed +0 -0
- fastapi_loopguard/pytest_plugin.py +135 -0
- fastapi_loopguard-0.3.1.dist-info/METADATA +154 -0
- fastapi_loopguard-0.3.1.dist-info/RECORD +14 -0
- fastapi_loopguard-0.3.1.dist-info/WHEEL +4 -0
- fastapi_loopguard-0.3.1.dist-info/entry_points.txt +2 -0
- fastapi_loopguard-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](https://badge.fury.io/py/fastapi-loopguard)
|
|
57
|
+
[](https://www.python.org/downloads/)
|
|
58
|
+
[](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,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.
|