Coverage for src/taipanstack/resilience/resilience.py: 100%
103 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +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 fallback(
36 fallback_value: T,
37 exceptions: tuple[type[Exception], ...] = (Exception,),
38) -> FallbackDecorator:
39 """Provide a fallback value on failures.
41 If the wrapped function returns an Err() or raises a specified exception,
42 the fallback value is returned wrapped in an Ok().
44 Args:
45 fallback_value: The value to return on failure.
46 exceptions: Exceptions to catch.
48 Returns:
49 Decorator function.
51 """
53 def decorator(
54 func: ResultFunc[P, T, E] | AsyncResultFunc[P, T, E],
55 ) -> ResultFunc[P, T, E] | AsyncResultFunc[P, T, E]:
56 if inspect.iscoroutinefunction(func):
58 @functools.wraps(func)
59 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
60 try:
61 # func is a coroutine function here
62 func_coro = cast(AsyncResultFunc[P, T, E], func)
63 result = await func_coro(*args, **kwargs)
64 if isinstance(result, Err):
65 return Ok(fallback_value)
66 elif isinstance(result, Ok):
67 return result
68 except Exception as e:
69 try:
70 if isinstance(e, exceptions):
71 return Ok(fallback_value)
72 except TypeError:
73 pass
74 raise
75 return Err(cast(E, RuntimeError("Unreachable"))) # type: ignore[unreachable]
77 return async_wrapper
79 @functools.wraps(func)
80 def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
81 try:
82 # func is a normal function here
83 func_sync = cast(ResultFunc[P, T, E], func)
84 result = func_sync(*args, **kwargs)
85 if isinstance(result, Err):
86 return Ok(fallback_value)
87 elif isinstance(result, Ok):
88 return result
89 except Exception as e:
90 try:
91 if isinstance(e, exceptions):
92 return Ok(fallback_value)
93 except TypeError:
94 pass
95 raise
96 return Err(cast(E, RuntimeError("Unreachable"))) # type: ignore[unreachable]
98 return sync_wrapper
100 return cast(FallbackDecorator, decorator)
103class TimeoutDecorator(Protocol):
104 """Protocol for the timeout decorator."""
106 @overload
107 def __call__(
108 self,
109 func: ResultFunc[P, T, E],
110 ) -> Callable[P, Result[T, TimeoutError | E]]: ...
112 @overload
113 def __call__(
114 self,
115 func: AsyncResultFunc[P, T, E],
116 ) -> Callable[P, Awaitable[Result[T, TimeoutError | E]]]: ...
119def timeout(seconds: float) -> TimeoutDecorator:
120 """Enforce a maximum execution time.
122 If the execution time exceeds the specified limit, returns Err(TimeoutError).
124 Args:
125 seconds: Maximum allowed execution time in seconds.
127 Returns:
128 Decorator function.
130 """
132 def decorator(
133 func: ResultFunc[P, T, E] | AsyncResultFunc[P, T, E],
134 ) -> (
135 Callable[P, Result[T, TimeoutError | E]]
136 | Callable[P, Awaitable[Result[T, TimeoutError | E]]]
137 ):
138 if inspect.iscoroutinefunction(func):
140 @functools.wraps(func)
141 async def async_wrapper(
142 *args: P.args,
143 **kwargs: P.kwargs,
144 ) -> Result[T, TimeoutError | E]:
145 if (
146 not isinstance(seconds, (int, float))
147 or not math.isfinite(seconds)
148 or seconds < 0
149 ):
150 return Err(
151 cast(
152 E,
153 ValueError("Timeout must be a finite non-negative number"),
154 ),
155 )
157 try:
158 func_coro = cast(
159 Callable[P, Awaitable[Result[T, TimeoutError | E]]],
160 func,
161 )
162 return await asyncio.wait_for(
163 func_coro(*args, **kwargs),
164 timeout=seconds,
165 )
166 except TimeoutError:
167 return Err(
168 TimeoutError(f"Execution timed out after {seconds} seconds."),
169 )
170 except RuntimeError as e:
171 return Err(cast(E, RuntimeError(f"Task exhaustion: {e!s}")))
172 except MemoryError as e:
173 return Err(cast(E, RuntimeError(f"Memory exhaustion: {e!s}")))
174 except (OSError, OverflowError) as e:
175 return Err(cast(E, RuntimeError(f"Resource exhaustion: {e!s}")))
177 return async_wrapper
179 @functools.wraps(func)
180 def sync_wrapper(
181 *args: P.args,
182 **kwargs: P.kwargs,
183 ) -> Result[T, TimeoutError | E]:
184 if (
185 not isinstance(seconds, (int, float))
186 or not math.isfinite(seconds)
187 or seconds < 0
188 ):
189 return Err(
190 cast(
191 E,
192 ValueError("Timeout must be a finite non-negative number"),
193 ),
194 )
196 result: list[Result[T, TimeoutError | E]] = []
197 exception: list[BaseException] = []
199 def worker() -> None:
200 try:
201 func_sync = cast(Callable[P, Result[T, TimeoutError | E]], func)
202 result.append(func_sync(*args, **kwargs))
203 except BaseException as e:
204 exception.append(e)
206 thread = threading.Thread(target=worker, daemon=True)
207 try:
208 thread.start()
209 thread.join(timeout=seconds)
210 except RuntimeError as e:
211 return Err(cast(E, RuntimeError(f"Thread exhaustion: {e!s}")))
212 except (OSError, OverflowError) as e:
213 return Err(cast(E, RuntimeError(f"Resource exhaustion: {e!s}")))
214 except MemoryError as e:
215 return Err(cast(E, RuntimeError(f"Memory exhaustion: {e!s}")))
217 if thread.is_alive():
218 return Err(
219 TimeoutError(f"Execution timed out after {seconds} seconds."),
220 )
222 if exception:
223 raise exception[0]
225 return result[0]
227 return sync_wrapper
229 return cast(TimeoutDecorator, decorator)