Coverage for src/taipanstack/resilience/watchdogs/resource_watcher.py: 100%
57 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Resource watcher — monitors CPU and memory usage.
4When usage breaches configurable thresholds, invokes a callback
5so the application can react (e.g. tighten rate limits).
6"""
8import logging
9import math
10import time
11from collections.abc import Callable
12from dataclasses import dataclass
14from taipanstack.core.result import Err, Ok, Result
15from taipanstack.resilience.watchdogs._base import BaseWatcher
17logger = logging.getLogger("taipanstack.resilience.watchdogs.resource")
19try:
20 import psutil
22 _HAS_PSUTIL = True
23except ImportError:
24 psutil = None
25 _HAS_PSUTIL = False
28@dataclass(frozen=True)
29class ResourceSnapshot:
30 """Point-in-time snapshot of system resource usage.
32 Attributes:
33 cpu_percent: Current CPU utilisation (0-100).
34 memory_percent: Current memory utilisation (0-100).
35 timestamp: Monotonic timestamp of the reading.
37 """
39 cpu_percent: float
40 memory_percent: float
41 timestamp: float
44def _validate_interval(interval: float) -> None:
45 """Validate interval parameter."""
46 if (
47 not isinstance(interval, (int, float))
48 or not math.isfinite(interval)
49 or interval <= 0
50 ):
51 raise ValueError("interval must be a finite positive number")
54def _validate_threshold(name: str, value: float) -> None:
55 """Validate a threshold parameter."""
56 if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
57 raise ValueError(f"{name} must be a finite non-negative number")
60def check_resources() -> Result[ResourceSnapshot, Exception]:
61 """Take a one-shot resource reading.
63 Returns:
64 ``Ok(ResourceSnapshot)`` on success, ``Err`` if psutil is
65 unavailable.
67 """
68 if not _HAS_PSUTIL:
69 return Err(
70 ImportError(
71 "psutil is required for resource monitoring. "
72 "Install with: pip install taipanstack[resilience]",
73 ),
74 )
76 cpu = psutil.cpu_percent(interval=0.1) # type: ignore[misc]
77 mem = psutil.virtual_memory().percent # type: ignore[misc]
78 return Ok(
79 ResourceSnapshot(
80 cpu_percent=cpu, # type: ignore[misc]
81 memory_percent=mem, # type: ignore[misc]
82 timestamp=time.monotonic(),
83 ),
84 )
87class ResourceWatcher(BaseWatcher):
88 """Background watcher that monitors CPU and memory.
90 When either metric exceeds its configured threshold the
91 ``on_threshold_breach`` callback is invoked with the resource
92 name (``"cpu"`` or ``"memory"``) and the current value.
94 Args:
95 interval: Seconds between checks.
96 cpu_threshold: CPU percentage that triggers a breach.
97 memory_threshold: Memory percentage that triggers a breach.
98 on_threshold_breach: Optional callback ``(resource, value) -> None``.
100 Example:
101 >>> watcher = ResourceWatcher(
102 ... cpu_threshold=80.0,
103 ... on_threshold_breach=lambda r, v: print(f"{r} at {v}%"),
104 ... )
105 >>> await watcher.start()
107 """
109 def __init__(
110 self,
111 *,
112 interval: float = 5.0,
113 cpu_threshold: float = 85.0,
114 memory_threshold: float = 85.0,
115 on_threshold_breach: Callable[[str, float], None] | None = None,
116 ) -> None:
117 """Initialize the resource watcher.
119 Args:
120 interval: Seconds between checks.
121 cpu_threshold: CPU percentage that triggers a breach.
122 memory_threshold: Memory percentage that triggers a breach.
123 on_threshold_breach: Optional breach callback.
125 """
126 _validate_interval(interval)
127 _validate_threshold("cpu_threshold", cpu_threshold)
128 _validate_threshold("memory_threshold", memory_threshold)
130 super().__init__(interval=interval)
131 self._cpu_threshold = cpu_threshold
132 self._memory_threshold = memory_threshold
133 self._on_threshold_breach = on_threshold_breach
135 async def start(self) -> Result[None, Exception]:
136 """Start the resource watcher.
138 Returns:
139 ``Err`` if psutil is not installed, otherwise delegates
140 to ``BaseWatcher.start()``.
142 """
143 if not _HAS_PSUTIL:
144 return Err(
145 ImportError(
146 "psutil is required for ResourceWatcher. "
147 "Install with: pip install taipanstack[resilience]",
148 ),
149 )
150 return await super().start()
152 def _check_threshold(self, name: str, value: float, threshold: float) -> None:
153 if value >= threshold:
154 logger.warning(
155 "%s threshold breached: %.1f%% >= %.1f%%",
156 name.capitalize(),
157 value,
158 threshold,
159 )
160 if self._on_threshold_breach is not None:
161 self._on_threshold_breach(name, value)
163 def _handle_snapshot(self, snapshot: ResourceSnapshot) -> None:
164 self._check_threshold("cpu", snapshot.cpu_percent, self._cpu_threshold)
165 self._check_threshold("memory", snapshot.memory_percent, self._memory_threshold)
167 async def _run(self) -> None:
168 """Execute a single resource check cycle."""
169 result = check_resources()
170 if isinstance(result, Ok):
171 self._handle_snapshot(result.ok_value)
172 else:
173 logger.error("Resource check failed: %s", result.err_value)