Coverage for src/taipanstack/utils/concurrency.py: 100%
82 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"""
2Concurrency utilities.
4Provides a bulkhead pattern concurrency limiter decorator for both
5synchronous and asynchronous functions. Uses an `OverloadError` and
6returns a `Result` type.
7"""
9import asyncio
10import functools
11import inspect
12import math
13import threading
14from collections.abc import Awaitable, Callable
15from typing import ParamSpec, Protocol, TypeVar, cast, overload
17from taipanstack.core.result import Err, Ok, Result
19__all__ = ["OverloadError", "limit_concurrency"]
21P = ParamSpec("P")
22T = TypeVar("T")
25class OverloadError(Exception):
26 """Exception raised when a concurrency limit is exceeded or timed out."""
28 def __init__(self, message: str = "Concurrency limit reached") -> None:
29 """Initialize the OverloadError.
31 Args:
32 message: The error message to display.
33 Defaults to "Concurrency limit reached".
35 """
36 super().__init__(message)
39class ConcurrencyLimitDecorator(Protocol):
40 """Protocol for the concurrency limit decorator."""
42 @overload
43 def __call__(
44 self,
45 func: Callable[P, T],
46 ) -> Callable[P, Result[T, OverloadError]]: ...
48 @overload
49 def __call__(
50 self,
51 func: Callable[P, Awaitable[T]],
52 ) -> Callable[P, Awaitable[Result[T, OverloadError]]]: ...
55async def _acquire_with_timeout(
56 async_semaphore: asyncio.Semaphore,
57 timeout: float,
58) -> Result[None, OverloadError]:
59 try:
60 async with asyncio.timeout(timeout):
61 await async_semaphore.acquire()
62 return Ok(None)
63 except TimeoutError:
64 return Err(OverloadError())
67async def _acquire_no_timeout(
68 async_semaphore: asyncio.Semaphore,
69) -> Result[None, OverloadError]:
70 if async_semaphore.locked():
71 return Err(OverloadError())
72 await async_semaphore.acquire()
73 return Ok(None)
76async def _acquire_async_semaphore(
77 async_semaphore: asyncio.Semaphore,
78 timeout: float,
79) -> Result[None, OverloadError]:
80 """Acquire async semaphore with optional timeout."""
81 try:
82 if timeout <= 0.0:
83 return await _acquire_no_timeout(async_semaphore)
84 return await _acquire_with_timeout(async_semaphore, timeout)
85 except Exception as e:
86 return Err(OverloadError(f"Resource exhaustion: {e!s}"))
89def _handle_async_concurrency(
90 func: Callable[P, Awaitable[T]],
91 max_tasks: int,
92 timeout: float,
93) -> Callable[P, Awaitable[Result[T, OverloadError]]]:
94 """Handle asynchronous concurrency limiting."""
95 async_semaphore = asyncio.Semaphore(max_tasks)
97 @functools.wraps(func)
98 async def async_wrapper(
99 *args: P.args,
100 **kwargs: P.kwargs,
101 ) -> Result[T, OverloadError]:
102 acquire_result = await _acquire_async_semaphore(async_semaphore, timeout)
103 if isinstance(acquire_result, Err):
104 return acquire_result
106 try:
107 return Ok(await func(*args, **kwargs))
108 finally:
109 async_semaphore.release()
111 return async_wrapper # type: ignore[misc]
114def _acquire_sync_semaphore(
115 sync_semaphore: threading.Semaphore,
116 timeout: float,
117) -> Result[None, OverloadError]:
118 """Acquire sync semaphore with optional timeout."""
119 try:
120 if timeout <= 0.0:
121 acquired = sync_semaphore.acquire(blocking=False)
122 else:
123 acquired = sync_semaphore.acquire(timeout=timeout)
125 if not acquired:
126 return Err(OverloadError())
127 return Ok(None)
128 except Exception as e:
129 return Err(OverloadError(f"Resource exhaustion: {e!s}"))
132def _handle_sync_concurrency(
133 func: Callable[P, T],
134 max_tasks: int,
135 timeout: float,
136) -> Callable[P, Result[T, OverloadError]]:
137 """Handle synchronous concurrency limiting."""
138 sync_semaphore = threading.Semaphore(max_tasks)
140 @functools.wraps(func)
141 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, OverloadError]:
142 acquire_result = _acquire_sync_semaphore(sync_semaphore, timeout)
143 if isinstance(acquire_result, Err):
144 return acquire_result
146 try:
147 return Ok(func(*args, **kwargs))
148 finally:
149 sync_semaphore.release()
151 return wrapper
154def _validate_max_tasks(max_tasks: int) -> None:
155 """Validate max_tasks argument."""
156 if not isinstance(max_tasks, int) or max_tasks <= 0:
157 raise ValueError("max_tasks must be > 0")
160def _validate_timeout(timeout: float) -> None:
161 """Validate timeout argument."""
162 if (
163 not isinstance(timeout, (int, float))
164 or not math.isfinite(timeout)
165 or timeout < 0.0
166 ):
167 raise ValueError("timeout must be a finite non-negative number")
170def _validate_limit_concurrency_args(max_tasks: int, timeout: float) -> None:
171 """Validate arguments for limit_concurrency."""
172 _validate_max_tasks(max_tasks)
173 _validate_timeout(timeout)
176def limit_concurrency(
177 max_tasks: int,
178 timeout: float = 0.0,
179) -> ConcurrencyLimitDecorator:
180 """Decorate a function to apply the bulkhead concurrency limit pattern.
182 If the maximum concurrent executions are reached, the wrapper will wait up
183 to `timeout` seconds to acquire a execution slot. If it fails, it returns
184 an ``Err(OverloadError)``.
186 Args:
187 max_tasks: Maximum concurrent function executions allowed.
188 timeout: Maximum time in seconds to wait for a slot if limit is reached.
190 Returns:
191 Decorated function returning a ``Result[T, OverloadError]``.
193 Example:
194 >>> @limit_concurrency(max_tasks=2, timeout=0.1)
195 ... def process_data() -> str:
196 ... return "data"
197 >>> process_data()
198 Ok('data')
200 """
201 _validate_limit_concurrency_args(max_tasks, timeout)
203 def decorator(
204 func: Callable[P, T] | Callable[P, Awaitable[T]],
205 ) -> (
206 Callable[P, Result[T, OverloadError]]
207 | Callable[P, Awaitable[Result[T, OverloadError]]]
208 ):
209 if inspect.iscoroutinefunction(func):
210 return _handle_async_concurrency(
211 func, # type: ignore[misc]
212 max_tasks,
213 timeout,
214 )
216 return _handle_sync_concurrency(
217 cast(Callable[P, T], func),
218 max_tasks,
219 timeout,
220 )
222 return cast(ConcurrencyLimitDecorator, decorator)