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,236 @@
1
+ """LoopGuard middleware for FastAPI/Starlette.
2
+
3
+ This is a pure ASGI middleware implementation that avoids the issues
4
+ with BaseHTTPMiddleware (deprecated, breaks contextvars, memory leaks).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import TYPE_CHECKING
11
+
12
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
13
+
14
+ from .context import RequestContext, register_request, unregister_request
15
+ from .monitor import SentinelMonitor
16
+
17
+ if TYPE_CHECKING:
18
+ from .config import LoopGuardConfig
19
+
20
+
21
+ class LoopGuardMiddleware:
22
+ """Pure ASGI middleware that detects event loop blocking per-request.
23
+
24
+ This middleware:
25
+ 1. Handles ASGI lifespan for proper startup/shutdown
26
+ 2. Registers request contexts for attribution
27
+ 3. Manages the sentinel monitor lifecycle
28
+ 4. Adds debug headers in dev mode via send wrapper
29
+
30
+ Usage:
31
+ from fastapi import FastAPI
32
+ from fastapi_loopguard import LoopGuardMiddleware, LoopGuardConfig
33
+
34
+ app = FastAPI()
35
+ config = LoopGuardConfig(dev_mode=True)
36
+ app.add_middleware(LoopGuardMiddleware, config=config)
37
+
38
+ Improvements in v0.2.0:
39
+ - Pure ASGI implementation (no BaseHTTPMiddleware)
40
+ - Proper lifespan handling for monitor lifecycle
41
+ - Background calibration (first request not blocked)
42
+ - Send wrapper for header injection
43
+ """
44
+
45
+ __slots__ = ("app", "_config", "_monitor", "_started")
46
+
47
+ def __init__(
48
+ self,
49
+ app: ASGIApp,
50
+ config: LoopGuardConfig | None = None,
51
+ ) -> None:
52
+ """Initialize the middleware.
53
+
54
+ Args:
55
+ app: The ASGI application to wrap.
56
+ config: Optional configuration. Uses defaults if not provided.
57
+ """
58
+ self.app = app
59
+
60
+ # Import here to avoid circular imports
61
+ from .config import LoopGuardConfig as ConfigClass
62
+
63
+ self._config = config or ConfigClass()
64
+ self._monitor: SentinelMonitor | None = None
65
+ self._started = False
66
+
67
+ async def __call__(
68
+ self,
69
+ scope: Scope,
70
+ receive: Receive,
71
+ send: Send,
72
+ ) -> None:
73
+ """ASGI interface implementation.
74
+
75
+ Args:
76
+ scope: The connection scope.
77
+ receive: Async callable to receive messages.
78
+ send: Async callable to send messages.
79
+ """
80
+ if scope["type"] == "lifespan":
81
+ await self._handle_lifespan(scope, receive, send)
82
+ elif scope["type"] == "http":
83
+ await self._handle_http(scope, receive, send)
84
+ else:
85
+ # WebSocket or other types - pass through
86
+ await self.app(scope, receive, send)
87
+
88
+ async def _handle_lifespan(
89
+ self,
90
+ scope: Scope,
91
+ receive: Receive,
92
+ send: Send,
93
+ ) -> None:
94
+ """Handle lifespan events for proper startup/shutdown.
95
+
96
+ Intercepts lifespan messages to start/stop the monitor.
97
+ """
98
+ started = False
99
+ shutdown_complete = False
100
+
101
+ async def receive_wrapper() -> Message:
102
+ nonlocal started
103
+ message = await receive()
104
+
105
+ if message["type"] == "lifespan.startup":
106
+ # Start monitor before signaling startup complete
107
+ if self._config.enabled and not self._started:
108
+ await self._start_monitor()
109
+ started = True
110
+
111
+ return message
112
+
113
+ async def send_wrapper(message: Message) -> None:
114
+ nonlocal shutdown_complete
115
+
116
+ if message["type"] == "lifespan.shutdown.complete":
117
+ # Stop monitor after app signals shutdown complete
118
+ if self._monitor:
119
+ await self._monitor.stop()
120
+ self._monitor = None
121
+ self._started = False
122
+ shutdown_complete = True
123
+
124
+ await send(message)
125
+
126
+ await self.app(scope, receive_wrapper, send_wrapper)
127
+
128
+ async def _start_monitor(self) -> None:
129
+ """Start the sentinel monitor with background calibration."""
130
+ if self._started:
131
+ return
132
+
133
+ self._monitor = SentinelMonitor(self._config)
134
+ # Use background calibration so first request isn't blocked
135
+ await self._monitor.start_with_background_calibration()
136
+ self._started = True
137
+
138
+ async def _handle_http(
139
+ self,
140
+ scope: Scope,
141
+ receive: Receive,
142
+ send: Send,
143
+ ) -> None:
144
+ """Handle HTTP requests with context tracking.
145
+
146
+ Registers request context, calls app, adds debug headers.
147
+ """
148
+ path = scope.get("path", "")
149
+
150
+ # Skip monitoring for excluded paths
151
+ if path in self._config.exclude_paths:
152
+ await self.app(scope, receive, send)
153
+ return
154
+
155
+ # Skip if disabled
156
+ if not self._config.enabled:
157
+ await self.app(scope, receive, send)
158
+ return
159
+
160
+ # Lazy start for apps without lifespan events
161
+ if not self._started:
162
+ await self._start_monitor()
163
+
164
+ # Create and register request context
165
+ request_id = str(uuid.uuid4())[:8]
166
+ method = scope.get("method", "UNKNOWN")
167
+
168
+ ctx = RequestContext(
169
+ request_id=request_id,
170
+ path=path,
171
+ method=method,
172
+ )
173
+
174
+ # Store request_id in scope state for handlers to access
175
+ if "state" not in scope:
176
+ scope["state"] = {}
177
+ scope["state"]["loopguard_request_id"] = request_id
178
+
179
+ register_request(ctx)
180
+
181
+ try:
182
+ if self._config.dev_mode:
183
+ # Use send wrapper to inject headers
184
+ await self._handle_with_headers(scope, receive, send, ctx)
185
+ else:
186
+ await self.app(scope, receive, send)
187
+ finally:
188
+ unregister_request(request_id)
189
+
190
+ async def _handle_with_headers(
191
+ self,
192
+ scope: Scope,
193
+ receive: Receive,
194
+ send: Send,
195
+ ctx: RequestContext,
196
+ ) -> None:
197
+ """Handle request with debug header injection.
198
+
199
+ Uses a send wrapper to add X-Request-Id, X-Blocking-Count, etc.
200
+ headers to the response.
201
+ """
202
+ response_started = False
203
+
204
+ async def send_wrapper(message: Message) -> None:
205
+ nonlocal response_started
206
+
207
+ if message["type"] == "http.response.start" and not response_started:
208
+ response_started = True
209
+
210
+ # Get existing headers and add our debug headers
211
+ headers = list(message.get("headers", []))
212
+ headers.extend(
213
+ [
214
+ (b"x-request-id", ctx.request_id.encode()),
215
+ (b"x-blocking-count", str(ctx.blocking_count).encode()),
216
+ (
217
+ b"x-blocking-total-ms",
218
+ f"{ctx.total_blocking_ms:.2f}".encode(),
219
+ ),
220
+ (
221
+ b"x-blocking-detected",
222
+ b"true" if ctx.blocking_count > 0 else b"false",
223
+ ),
224
+ ]
225
+ )
226
+
227
+ # Create new message with updated headers
228
+ message = {
229
+ "type": message["type"],
230
+ "status": message.get("status", 200),
231
+ "headers": headers,
232
+ }
233
+
234
+ await send(message)
235
+
236
+ await self.app(scope, receive, send_wrapper)
@@ -0,0 +1,412 @@
1
+ """Sentinel monitor for detecting event loop blocking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from collections import deque
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .context import get_active_requests
13
+
14
+ if TYPE_CHECKING:
15
+ from .config import LoopGuardConfig
16
+
17
+ logger = logging.getLogger("fastapi_loopguard")
18
+
19
+
20
+ class AdaptiveThreshold:
21
+ """Adaptive threshold based on sliding window of recent lag samples.
22
+
23
+ Uses percentile-based calculation to automatically adjust the blocking
24
+ threshold based on observed latency patterns. This reduces false positives
25
+ in high-concurrency environments.
26
+ """
27
+
28
+ __slots__ = (
29
+ "_samples",
30
+ "_window_size",
31
+ "_percentile",
32
+ "_multiplier",
33
+ "_min_threshold_ms",
34
+ "_min_samples",
35
+ "_current_threshold_ms",
36
+ )
37
+
38
+ def __init__(
39
+ self,
40
+ window_size: int,
41
+ percentile: float,
42
+ multiplier: float,
43
+ min_threshold_ms: float,
44
+ min_samples: int,
45
+ ) -> None:
46
+ """Initialize the adaptive threshold.
47
+
48
+ Args:
49
+ window_size: Maximum samples in sliding window.
50
+ percentile: Percentile (0.0-1.0) for baseline calculation.
51
+ multiplier: Threshold = baseline × multiplier.
52
+ min_threshold_ms: Minimum threshold value.
53
+ min_samples: Minimum samples before adapting.
54
+ """
55
+ self._samples: deque[float] = deque(maxlen=window_size)
56
+ self._window_size = window_size
57
+ self._percentile = percentile
58
+ self._multiplier = multiplier
59
+ self._min_threshold_ms = min_threshold_ms
60
+ self._min_samples = min_samples
61
+ self._current_threshold_ms = min_threshold_ms
62
+
63
+ @property
64
+ def current_threshold_ms(self) -> float:
65
+ """Current calculated threshold in milliseconds."""
66
+ return self._current_threshold_ms
67
+
68
+ @property
69
+ def sample_count(self) -> int:
70
+ """Number of samples currently in the window."""
71
+ return len(self._samples)
72
+
73
+ def add_sample(self, lag_ms: float) -> None:
74
+ """Add a new lag sample to the sliding window.
75
+
76
+ Args:
77
+ lag_ms: The lag value in milliseconds.
78
+ """
79
+ self._samples.append(lag_ms)
80
+
81
+ def recalculate(self) -> float:
82
+ """Recalculate the threshold based on current samples.
83
+
84
+ Returns:
85
+ The new threshold in milliseconds.
86
+ """
87
+ if len(self._samples) < self._min_samples:
88
+ return self._current_threshold_ms
89
+
90
+ sorted_samples = sorted(self._samples)
91
+ idx = int(len(sorted_samples) * self._percentile)
92
+ idx = min(idx, len(sorted_samples) - 1) # Bounds check
93
+ baseline = sorted_samples[idx]
94
+
95
+ self._current_threshold_ms = max(
96
+ baseline * self._multiplier,
97
+ self._min_threshold_ms,
98
+ )
99
+ return self._current_threshold_ms
100
+
101
+
102
+ class SentinelMonitor:
103
+ """Background task that monitors event loop health.
104
+
105
+ The sentinel works by scheduling short sleeps and measuring how long
106
+ they actually take. If the actual time significantly exceeds the
107
+ expected time, it indicates the event loop was blocked.
108
+
109
+ When blocking is detected, the monitor iterates ALL active requests
110
+ and attributes the lag to each of them, since we cannot determine
111
+ which specific request caused the blocking.
112
+
113
+ Improvements in v0.2.0:
114
+ - Background calibration: First request is not blocked
115
+ - Multi-context attribution: All active requests are notified
116
+ - Named tasks: Easier debugging
117
+ - Clean shutdown: Proper task cancellation
118
+ """
119
+
120
+ __slots__ = (
121
+ "_config",
122
+ "_on_blocking",
123
+ "_task",
124
+ "_calibration_task",
125
+ "_running",
126
+ "_baseline_ms",
127
+ "_threshold_ms",
128
+ "_calibrated",
129
+ "_adaptive",
130
+ "_last_adapt_time",
131
+ "_lag_history",
132
+ )
133
+
134
+ def __init__(
135
+ self,
136
+ config: LoopGuardConfig,
137
+ on_blocking: Callable[[float, str | None, str | None], None] | None = None,
138
+ ) -> None:
139
+ """Initialize the sentinel monitor.
140
+
141
+ Args:
142
+ config: The LoopGuard configuration.
143
+ on_blocking: Optional callback called when blocking is detected.
144
+ Receives (lag_ms, path, method).
145
+ """
146
+ self._config = config
147
+ self._on_blocking = on_blocking
148
+ self._task: asyncio.Task[None] | None = None
149
+ self._calibration_task: asyncio.Task[None] | None = None
150
+ self._running = False
151
+ self._baseline_ms: float = 0.0
152
+ self._threshold_ms: float = config.fallback_threshold_ms
153
+ self._calibrated = False
154
+
155
+ # Initialize adaptive threshold if enabled
156
+ if config.adaptive_threshold:
157
+ self._adaptive: AdaptiveThreshold | None = AdaptiveThreshold(
158
+ window_size=config.adaptive_window_size,
159
+ percentile=config.adaptive_percentile,
160
+ multiplier=config.threshold_multiplier,
161
+ min_threshold_ms=config.fallback_threshold_ms,
162
+ min_samples=config.adaptive_min_samples,
163
+ )
164
+ else:
165
+ self._adaptive = None
166
+ self._last_adapt_time: float = 0.0
167
+ self._lag_history: deque[tuple[float, float]] = deque()
168
+
169
+ @property
170
+ def is_running(self) -> bool:
171
+ """Whether the monitor is currently running."""
172
+ return self._running
173
+
174
+ @property
175
+ def is_calibrated(self) -> bool:
176
+ """Whether calibration has completed."""
177
+ return self._calibrated
178
+
179
+ @property
180
+ def threshold_ms(self) -> float:
181
+ """Current blocking threshold in milliseconds."""
182
+ return self._threshold_ms
183
+
184
+ @property
185
+ def baseline_ms(self) -> float:
186
+ """Calibrated baseline latency in milliseconds."""
187
+ return self._baseline_ms
188
+
189
+ async def calibrate(self) -> float:
190
+ """Calibrate the baseline event loop latency.
191
+
192
+ Runs a series of sleep calls to measure the typical latency
193
+ of yielding to the event loop under normal conditions.
194
+
195
+ Returns:
196
+ The calibrated threshold in milliseconds.
197
+ """
198
+ loop = asyncio.get_running_loop()
199
+ interval_sec = self._config.monitor_interval_ms / 1000.0
200
+ samples: list[float] = []
201
+
202
+ for _ in range(self._config.calibration_iterations):
203
+ start = loop.time()
204
+ await asyncio.sleep(interval_sec)
205
+ elapsed = loop.time() - start
206
+ lag_ms = (elapsed - interval_sec) * 1000.0
207
+ samples.append(lag_ms)
208
+
209
+ # Use P75 as baseline to be robust against outliers
210
+ samples.sort()
211
+ p75_index = int(len(samples) * 0.75)
212
+ self._baseline_ms = samples[p75_index]
213
+
214
+ # Calculate threshold
215
+ self._threshold_ms = max(
216
+ self._baseline_ms * self._config.threshold_multiplier,
217
+ self._config.fallback_threshold_ms,
218
+ )
219
+ self._calibrated = True
220
+
221
+ logger.info(
222
+ "LoopGuard calibrated: baseline=%.2fms, threshold=%.2fms",
223
+ self._baseline_ms,
224
+ self._threshold_ms,
225
+ )
226
+
227
+ return self._threshold_ms
228
+
229
+ async def _background_calibrate(self) -> None:
230
+ """Run calibration in background without blocking requests."""
231
+ try:
232
+ await self.calibrate()
233
+ except asyncio.CancelledError:
234
+ logger.debug("LoopGuard calibration cancelled during shutdown")
235
+ except Exception:
236
+ logger.exception(
237
+ "LoopGuard calibration failed, using fallback threshold=%.2fms",
238
+ self._threshold_ms,
239
+ )
240
+
241
+ async def _monitor_loop(self) -> None:
242
+ """The main monitoring loop."""
243
+ loop = asyncio.get_running_loop()
244
+ interval_sec = self._config.monitor_interval_ms / 1000.0
245
+ adapt_interval_sec = self._config.adaptive_update_interval_ms / 1000.0
246
+
247
+ while self._running:
248
+ try:
249
+ start = loop.time()
250
+ await asyncio.sleep(interval_sec)
251
+ elapsed = loop.time() - start
252
+
253
+ lag_ms = (elapsed - interval_sec) * 1000.0
254
+
255
+ # Adaptive threshold processing
256
+ if self._adaptive:
257
+ self._adaptive.add_sample(lag_ms)
258
+ now = loop.time()
259
+ if now - self._last_adapt_time >= adapt_interval_sec:
260
+ old_threshold = self._threshold_ms
261
+ new_threshold = self._adaptive.recalculate()
262
+ if new_threshold != old_threshold:
263
+ self._threshold_ms = new_threshold
264
+ logger.debug(
265
+ "Adaptive threshold updated: %.2fms -> %.2fms",
266
+ old_threshold,
267
+ new_threshold,
268
+ )
269
+ self._last_adapt_time = now
270
+
271
+ triggered = False
272
+ if lag_ms > self._threshold_ms:
273
+ self._handle_blocking(lag_ms)
274
+ triggered = True
275
+
276
+ # Cumulative blocking detection
277
+ if self._config.cumulative_blocking_enabled:
278
+ now = loop.time()
279
+ self._lag_history.append((now, lag_ms))
280
+
281
+ # Prune old samples
282
+ window_start = now - (self._config.cumulative_window_ms / 1000.0)
283
+ while self._lag_history and self._lag_history[0][0] < window_start:
284
+ self._lag_history.popleft()
285
+
286
+ # Calculate total lag in window
287
+ cumulative_lag = sum(lag for _, lag in self._lag_history)
288
+
289
+ if (
290
+ cumulative_lag > self._config.cumulative_blocking_threshold_ms
291
+ and not triggered
292
+ ):
293
+ self._handle_blocking(cumulative_lag, is_cumulative=True)
294
+ # Clear history to avoid repeated triggering for the same window
295
+ self._lag_history.clear()
296
+ except Exception:
297
+ logger.exception("Error in LoopGuard monitor loop")
298
+ # Wait a bit before retrying to avoid tight loop on persistent error
299
+ await asyncio.sleep(1.0)
300
+
301
+ def _handle_blocking(self, lag_ms: float, is_cumulative: bool = False) -> None:
302
+ """Handle a detected blocking event.
303
+
304
+ Attributes blocking to ALL currently active requests, since we
305
+ cannot determine which specific request caused the blocking.
306
+ """
307
+ # Get all active request contexts
308
+ active_contexts = list(get_active_requests())
309
+
310
+ msg_type = (
311
+ "Cumulative event loop blocking" if is_cumulative else "Event loop blocked"
312
+ )
313
+
314
+ if not active_contexts:
315
+ # No active requests - log as background blocking
316
+ if self._config.log_blocking_events:
317
+ logger.warning(
318
+ "%s for %.2fms (no active request)",
319
+ msg_type,
320
+ lag_ms,
321
+ )
322
+ if self._on_blocking:
323
+ self._on_blocking(lag_ms, None, None)
324
+ return
325
+
326
+ # Attribute to all active requests
327
+ for ctx in active_contexts:
328
+ ctx.record_blocking(lag_ms)
329
+
330
+ if self._config.log_blocking_events:
331
+ logger.warning(
332
+ "%s for %.2fms during %s %s (request_id=%s)",
333
+ msg_type,
334
+ lag_ms,
335
+ ctx.method,
336
+ ctx.path,
337
+ ctx.request_id,
338
+ )
339
+
340
+ if self._on_blocking:
341
+ self._on_blocking(lag_ms, ctx.path, ctx.method)
342
+
343
+ async def start_with_background_calibration(self) -> None:
344
+ """Start monitoring immediately with background calibration.
345
+
346
+ Uses fallback threshold initially, calibrates in background.
347
+ First request is not blocked waiting for calibration.
348
+ """
349
+ if self._running:
350
+ return
351
+
352
+ self._running = True
353
+
354
+ # Start monitoring immediately with fallback threshold
355
+ self._task = asyncio.create_task(
356
+ self._monitor_loop(),
357
+ name="loopguard-monitor",
358
+ )
359
+
360
+ # Calibrate in background
361
+ self._calibration_task = asyncio.create_task(
362
+ self._background_calibrate(),
363
+ name="loopguard-calibrate",
364
+ )
365
+
366
+ logger.info(
367
+ "LoopGuard started with fallback threshold=%.2fms, "
368
+ "calibrating in background",
369
+ self._threshold_ms,
370
+ )
371
+
372
+ async def start(self) -> None:
373
+ """Start the sentinel monitor.
374
+
375
+ Performs calibration first (blocking), then starts the monitoring loop.
376
+ For non-blocking startup, use start_with_background_calibration().
377
+ """
378
+ if self._running:
379
+ return
380
+
381
+ if not self._calibrated:
382
+ await self.calibrate()
383
+
384
+ self._running = True
385
+ self._task = asyncio.create_task(
386
+ self._monitor_loop(),
387
+ name="loopguard-monitor",
388
+ )
389
+ logger.info("LoopGuard sentinel started")
390
+
391
+ async def stop(self) -> None:
392
+ """Stop the sentinel monitor gracefully."""
393
+ if not self._running:
394
+ return
395
+
396
+ self._running = False
397
+
398
+ # Cancel calibration if still running
399
+ if self._calibration_task and not self._calibration_task.done():
400
+ self._calibration_task.cancel()
401
+ with contextlib.suppress(asyncio.CancelledError):
402
+ await self._calibration_task
403
+ self._calibration_task = None
404
+
405
+ # Cancel monitoring task
406
+ if self._task:
407
+ self._task.cancel()
408
+ with contextlib.suppress(asyncio.CancelledError):
409
+ await self._task
410
+ self._task = None
411
+
412
+ logger.info("LoopGuard sentinel stopped")
File without changes