Coverage for src/taipanstack/utils/concurrency.py: 100%
78 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"""
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_async_semaphore(
56 async_semaphore: asyncio.Semaphore,
57 timeout: float,
58) -> Result[None, OverloadError]:
59 """Acquire async semaphore with optional timeout."""
60 try:
61 if timeout <= 0.0:
62 if async_semaphore.locked():
63 return Err(OverloadError())
64 await async_semaphore.acquire()
65 return Ok(None)
67 try:
68 async with asyncio.timeout(timeout):
69 await async_semaphore.acquire()
70 return Ok(None)
71 except TimeoutError:
72 return Err(OverloadError())
73 except Exception as e:
74 return Err(OverloadError(f"Resource exhaustion: {e!s}"))
77def _handle_async_concurrency(
78 func: Callable[P, Awaitable[T]],
79 max_tasks: int,
80 timeout: float,
81) -> Callable[P, Awaitable[Result[T, OverloadError]]]:
82 """Handle asynchronous concurrency limiting."""
83 async_semaphore = asyncio.Semaphore(max_tasks)
85 @functools.wraps(func)
86 async def async_wrapper(
87 *args: P.args,
88 **kwargs: P.kwargs,
89 ) -> Result[T, OverloadError]:
90 acquire_result = await _acquire_async_semaphore(async_semaphore, timeout)
91 if isinstance(acquire_result, Err):
92 return acquire_result
94 try:
95 return Ok(await func(*args, **kwargs))
96 finally:
97 async_semaphore.release()
99 return async_wrapper
102def _acquire_sync_semaphore(
103 sync_semaphore: threading.Semaphore,
104 timeout: float,
105) -> Result[None, OverloadError]:
106 """Acquire sync semaphore with optional timeout."""
107 try:
108 if timeout <= 0.0:
109 acquired = sync_semaphore.acquire(blocking=False)
110 else:
111 acquired = sync_semaphore.acquire(timeout=timeout)
113 if not acquired:
114 return Err(OverloadError())
115 return Ok(None)
116 except Exception as e:
117 return Err(OverloadError(f"Resource exhaustion: {e!s}"))
120def _handle_sync_concurrency(
121 func: Callable[P, T],
122 max_tasks: int,
123 timeout: float,
124) -> Callable[P, Result[T, OverloadError]]:
125 """Handle synchronous concurrency limiting."""
126 sync_semaphore = threading.Semaphore(max_tasks)
128 @functools.wraps(func)
129 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, OverloadError]:
130 acquire_result = _acquire_sync_semaphore(sync_semaphore, timeout)
131 if isinstance(acquire_result, Err):
132 return acquire_result
134 try:
135 return Ok(func(*args, **kwargs))
136 finally:
137 sync_semaphore.release()
139 return wrapper
142def _validate_max_tasks(max_tasks: int) -> None:
143 """Validate max_tasks argument."""
144 if not isinstance(max_tasks, int) or max_tasks <= 0:
145 raise ValueError("max_tasks must be > 0")
148def _validate_timeout(timeout: float) -> None:
149 """Validate timeout argument."""
150 if (
151 not isinstance(timeout, (int, float))
152 or not math.isfinite(timeout)
153 or timeout < 0.0
154 ):
155 raise ValueError("timeout must be a finite non-negative number")
158def _validate_limit_concurrency_args(max_tasks: int, timeout: float) -> None:
159 """Validate arguments for limit_concurrency."""
160 _validate_max_tasks(max_tasks)
161 _validate_timeout(timeout)
164def limit_concurrency(
165 max_tasks: int,
166 timeout: float = 0.0,
167) -> ConcurrencyLimitDecorator:
168 """Decorate a function to apply the bulkhead concurrency limit pattern.
170 If the maximum concurrent executions are reached, the wrapper will wait up
171 to `timeout` seconds to acquire a execution slot. If it fails, it returns
172 an ``Err(OverloadError)``.
174 Args:
175 max_tasks: Maximum concurrent function executions allowed.
176 timeout: Maximum time in seconds to wait for a slot if limit is reached.
178 Returns:
179 Decorated function returning a ``Result[T, OverloadError]``.
181 Example:
182 >>> @limit_concurrency(max_tasks=2, timeout=0.1)
183 ... def process_data() -> str:
184 ... return "data"
185 >>> process_data()
186 Ok('data')
188 """
189 _validate_limit_concurrency_args(max_tasks, timeout)
191 def decorator(
192 func: Callable[P, T] | Callable[P, Awaitable[T]],
193 ) -> (
194 Callable[P, Result[T, OverloadError]]
195 | Callable[P, Awaitable[Result[T, OverloadError]]]
196 ):
197 if inspect.iscoroutinefunction(func):
198 return _handle_async_concurrency(
199 func,
200 max_tasks,
201 timeout,
202 )
204 return _handle_sync_concurrency(
205 cast(Callable[P, T], func),
206 max_tasks,
207 timeout,
208 )
210 return cast(ConcurrencyLimitDecorator, decorator)