Coverage for src/taipanstack/resilience/resilience.py: 100%
143 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"""
2Resilience decorators.
4Provides tools for graceful fallback and timeouts using the Result monad.
5"""
7import asyncio
8import functools
9import inspect
10import math
11import threading
12from collections.abc import Awaitable, Callable
13from typing import ParamSpec, Protocol, TypeAlias, TypeVar, cast, overload
15from taipanstack.core.result import Err, Ok, Result
17P = ParamSpec("P")
18T = TypeVar("T")
19E = TypeVar("E", bound=Exception)
21ResultFunc: TypeAlias = Callable[P, Result[T, E]]
22AsyncResultFunc: TypeAlias = Callable[P, Awaitable[Result[T, E]]]
25class FallbackDecorator(Protocol):
26 """Protocol for the fallback decorator."""
28 @overload
29 def __call__(self, func: ResultFunc[P, T, E]) -> ResultFunc[P, T, E]: ...
31 @overload
32 def __call__(self, func: AsyncResultFunc[P, T, E]) -> AsyncResultFunc[P, T, E]: ...
35def _handle_fallback_exception(
36 e: Exception,
37 exceptions: tuple[type[Exception], ...],
38 fallback_value: T,
39) -> Result[T, E] | None:
40 try:
41 if isinstance(e, exceptions):
42 return Ok(fallback_value)
43 except TypeError:
44 pass
45 return None
48def _process_fallback_result(
49 result: Result[T, E], fallback_value: T
50) -> Result[T, E] | None:
51 if isinstance(result, Err):
52 return Ok(fallback_value)
53 if isinstance(result, Ok):
54 return result
55 return None # type: ignore[unreachable]
58async def _run_fallback_async(
59 func_coro: AsyncResultFunc[P, T, E],
60 fallback_value: T,
61 exceptions: tuple[type[Exception], ...],
62 *args: P.args,
63 **kwargs: P.kwargs,
64) -> Result[T, E]:
65 try:
66 result = await func_coro(*args, **kwargs)
67 processed_res: Result[T, E] | None = _process_fallback_result(
68 result, fallback_value
69 )
70 if processed_res is not None:
71 return processed_res
72 except Exception as e:
73 fallback_res: Result[T, E] | None = _handle_fallback_exception(
74 e, exceptions, fallback_value
75 )
76 if fallback_res is not None:
77 return fallback_res
78 raise
79 return Err(cast(E, RuntimeError("Unreachable")))
82def _execute_fallback_async_wrapper(
83 func_coro: AsyncResultFunc[P, T, E],
84 fallback_value: T,
85 exceptions: tuple[type[Exception], ...],
86) -> AsyncResultFunc[P, T, E]:
87 @functools.wraps(func_coro)
88 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
89 return await _run_fallback_async(
90 func_coro, fallback_value, exceptions, *args, **kwargs
91 )
93 return async_wrapper # type: ignore[misc]
96def _run_fallback_sync(
97 func_sync: ResultFunc[P, T, E],
98 fallback_value: T,
99 exceptions: tuple[type[Exception], ...],
100 *args: P.args,
101 **kwargs: P.kwargs,
102) -> Result[T, E]:
103 try:
104 result = func_sync(*args, **kwargs)
105 processed_res: Result[T, E] | None = _process_fallback_result(
106 result, fallback_value
107 )
108 if processed_res is not None:
109 return processed_res
110 except Exception as e:
111 fallback_res: Result[T, E] | None = _handle_fallback_exception(
112 e, exceptions, fallback_value
113 )
114 if fallback_res is not None:
115 return fallback_res
116 raise
117 return Err(cast(E, RuntimeError("Unreachable")))
120def _execute_fallback_sync_wrapper(
121 func_sync: ResultFunc[P, T, E],
122 fallback_value: T,
123 exceptions: tuple[type[Exception], ...],
124) -> ResultFunc[P, T, E]:
125 @functools.wraps(func_sync)
126 def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
127 return _run_fallback_sync(
128 func_sync, fallback_value, exceptions, *args, **kwargs
129 )
131 return sync_wrapper
134def fallback(
135 fallback_value: T,
136 exceptions: tuple[type[Exception], ...] = (Exception,),
137) -> FallbackDecorator:
138 """Provide a fallback value on failures.
140 If the wrapped function returns an Err() or raises a specified exception,
141 the fallback value is returned wrapped in an Ok().
143 Args:
144 fallback_value: The value to return on failure.
145 exceptions: Exceptions to catch.
147 Returns:
148 Decorator function.
150 """
152 def decorator(
153 func: ResultFunc[P, T, E] | AsyncResultFunc[P, T, E],
154 ) -> ResultFunc[P, T, E] | AsyncResultFunc[P, T, E]:
155 if inspect.iscoroutinefunction(func):
156 func_coro = cast(AsyncResultFunc[P, T, E], func)
157 return _execute_fallback_async_wrapper(
158 func_coro, fallback_value, exceptions
159 )
161 func_sync = cast(ResultFunc[P, T, E], func)
162 return _execute_fallback_sync_wrapper(func_sync, fallback_value, exceptions)
164 return cast(FallbackDecorator, decorator)
167class TimeoutDecorator(Protocol):
168 """Protocol for the timeout decorator."""
170 @overload
171 def __call__(
172 self,
173 func: ResultFunc[P, T, E],
174 ) -> Callable[P, Result[T, TimeoutError | E]]: ...
176 @overload
177 def __call__(
178 self,
179 func: AsyncResultFunc[P, T, E],
180 ) -> Callable[P, Awaitable[Result[T, TimeoutError | E]]]: ...
183def _handle_timeout_exception(
184 e: BaseException, context: str
185) -> Result[T, TimeoutError | E]:
186 if isinstance(e, (SystemExit, KeyboardInterrupt, GeneratorExit)):
187 raise e
188 if isinstance(e, MemoryError):
189 return Err(cast(E, RuntimeError(f"Memory exhaustion: {e!s}")))
190 if isinstance(e, (OSError, OverflowError)):
191 return Err(cast(E, RuntimeError(f"Resource exhaustion: {e!s}")))
192 return Err(cast(E, RuntimeError(f"{context} exhaustion: {e!s}")))
195def _is_valid_timeout_value(seconds: float) -> bool:
196 """Check if timeout is a valid finite non-negative number."""
197 if type(seconds) not in (int, float) or isinstance(seconds, bool):
198 return False
199 return bool(math.isfinite(seconds) and seconds >= 0)
202def _validate_timeout(seconds: float) -> Result[None, E] | None:
203 if not _is_valid_timeout_value(seconds):
204 return Err(
205 cast(
206 E,
207 ValueError("Timeout must be a finite non-negative number"),
208 ),
209 )
210 return None
213async def _run_async_with_timeout(
214 func_coro: Callable[P, Awaitable[Result[T, TimeoutError | E]]],
215 seconds: float,
216 *args: P.args,
217 **kwargs: P.kwargs,
218) -> Result[T, TimeoutError | E]:
219 try:
220 return await asyncio.wait_for(
221 func_coro(*args, **kwargs),
222 timeout=seconds,
223 )
224 except TimeoutError:
225 return Err(TimeoutError(f"Execution timed out after {seconds} seconds."))
226 except asyncio.CancelledError:
227 raise
228 except BaseException as e:
229 return _handle_timeout_exception(e, "Task")
232def _execute_timeout_async_wrapper(
233 func_coro: Callable[P, Awaitable[Result[T, TimeoutError | E]]],
234 seconds: float,
235) -> Callable[P, Awaitable[Result[T, TimeoutError | E]]]:
236 @functools.wraps(func_coro)
237 async def async_wrapper(
238 *args: P.args,
239 **kwargs: P.kwargs,
240 ) -> Result[T, TimeoutError | E]:
241 val_err: Result[None, E] | None = _validate_timeout(seconds)
242 if val_err is not None:
243 return cast(Result[T, TimeoutError | E], val_err)
244 return await _run_async_with_timeout(func_coro, seconds, *args, **kwargs)
246 return async_wrapper # type: ignore[misc]
249def _process_sync_timeout_result(
250 seconds: float,
251 thread: threading.Thread,
252 result: list[Result[T, TimeoutError | E]],
253 exception: list[BaseException],
254) -> Result[T, TimeoutError | E]:
255 if thread.is_alive():
256 return Err(TimeoutError(f"Execution timed out after {seconds} seconds."))
258 if exception:
259 raise exception[0]
261 if not result:
262 err_msg = "Thread died abruptly without a result or exception"
263 return Err(cast(E, RuntimeError(err_msg)))
265 return result[0]
268def _run_sync_with_timeout(
269 func_sync: Callable[P, Result[T, TimeoutError | E]],
270 seconds: float,
271 *args: P.args,
272 **kwargs: P.kwargs,
273) -> Result[T, TimeoutError | E]:
274 result: list[Result[T, TimeoutError | E]] = []
275 exception: list[BaseException] = []
277 def worker() -> None:
278 try:
279 result.append(func_sync(*args, **kwargs))
280 except BaseException as e:
281 exception.append(e)
283 try:
284 thread = threading.Thread(target=worker, daemon=True)
285 thread.start()
286 thread.join(timeout=seconds)
287 except BaseException as e:
288 return _handle_timeout_exception(e, "Thread")
290 return _process_sync_timeout_result(seconds, thread, result, exception)
293def _execute_timeout_sync_wrapper(
294 func_sync: Callable[P, Result[T, TimeoutError | E]],
295 seconds: float,
296) -> Callable[P, Result[T, TimeoutError | E]]:
297 @functools.wraps(func_sync)
298 def sync_wrapper(
299 *args: P.args,
300 **kwargs: P.kwargs,
301 ) -> Result[T, TimeoutError | E]:
302 val_err: Result[None, E] | None = _validate_timeout(seconds)
303 if val_err is not None:
304 return cast(Result[T, TimeoutError | E], val_err)
305 return _run_sync_with_timeout(func_sync, seconds, *args, **kwargs)
307 return sync_wrapper
310def timeout(seconds: float) -> TimeoutDecorator:
311 """Enforce a maximum execution time.
313 If the execution time exceeds the specified limit, returns Err(TimeoutError).
315 Args:
316 seconds: Maximum allowed execution time in seconds.
318 Returns:
319 Decorator function.
321 """
323 def decorator(
324 func: ResultFunc[P, T, E] | AsyncResultFunc[P, T, E],
325 ) -> (
326 Callable[P, Result[T, TimeoutError | E]]
327 | Callable[P, Awaitable[Result[T, TimeoutError | E]]]
328 ):
329 if inspect.iscoroutinefunction(func):
330 func_coro = cast(Callable[P, Awaitable[Result[T, TimeoutError | E]]], func)
331 return _execute_timeout_async_wrapper(func_coro, seconds)
333 func_sync = cast(Callable[P, Result[T, TimeoutError | E]], func)
334 return _execute_timeout_sync_wrapper(func_sync, seconds)
336 return cast(TimeoutDecorator, decorator)