Coverage for src/taipanstack/resilience/adaptive/orchestrator.py: 100%
181 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 Orchestrator — compose multiple patterns into a pipeline.
4Provides a fluent builder to combine bulkhead, circuit breaker,
5retry, timeout, and fallback into a single execution pipeline.
7Execution order: bulkhead → circuit breaker → retry → timeout → fn → fallback.
8"""
10from __future__ import annotations
12import asyncio
13import logging
14import math
15from collections.abc import Awaitable, Callable
16from typing import Generic, ParamSpec, TypeVar, cast
18from taipanstack.core.result import Err, Ok, Result
19from taipanstack.resilience.adaptive.adaptive_breaker import AdaptiveCircuitBreaker
20from taipanstack.resilience.adaptive.adaptive_retry import AdaptiveRetry
21from taipanstack.resilience.adaptive.bulkhead import Bulkhead, BulkheadFullError
22from taipanstack.resilience.circuit_breaker import (
23 CircuitBreaker,
24 CircuitBreakerError,
25)
26from taipanstack.resilience.retry import RetryConfig, calculate_delay
28logger = logging.getLogger("taipanstack.resilience.adaptive.orchestrator")
30T = TypeVar("T")
31E = TypeVar("E", bound=Exception)
32P = ParamSpec("P")
35class ResilienceOrchestrator(Generic[T]):
36 """Compose resilience patterns into a single pipeline.
38 Provides a fluent builder API to add patterns in order.
39 Execution proceeds through each configured layer.
41 Args:
42 name: Pipeline name for logging.
44 Example:
45 >>> orch = (
46 ... ResilienceOrchestrator("api")
47 ... .with_bulkhead(max_concurrent=5)
48 ... .with_circuit_breaker(breaker)
49 ... .with_retry(RetryConfig(max_attempts=3))
50 ... .with_timeout(10.0)
51 ... .with_fallback({"status": "cached"})
52 ... )
53 >>> result = await orch.execute(call_api, endpoint)
55 """
57 def __init__(self, name: str = "default") -> None:
58 """Initialize the orchestrator.
60 Args:
61 name: Pipeline name.
63 """
64 self.name = name
65 self._bulkhead: Bulkhead | None = None
66 self._breaker: CircuitBreaker | None = None
67 self._adaptive_breaker: AdaptiveCircuitBreaker | None = None
68 self._retry_config: RetryConfig | None = None
69 self._adaptive_retry: AdaptiveRetry | None = None
70 self._timeout: float | None = None
71 self._fallback_value: T | object = _SENTINEL
73 def with_bulkhead(
74 self,
75 max_concurrent: int = 10,
76 max_queue: int = 50,
77 timeout: float = 30.0,
78 ) -> ResilienceOrchestrator[T]:
79 """Add a bulkhead concurrency limiter.
81 Args:
82 max_concurrent: Max concurrent executions.
83 max_queue: Max queued callers.
84 timeout: Permit acquisition timeout.
86 Returns:
87 self for chaining.
89 """
90 if not math.isfinite(timeout) or timeout < 0:
91 raise ValueError("timeout must be a finite non-negative number")
92 self._bulkhead = Bulkhead(
93 f"{self.name}-bulkhead",
94 max_concurrent=max_concurrent,
95 max_queue=max_queue,
96 timeout=timeout,
97 )
98 return self
100 def with_circuit_breaker(
101 self,
102 breaker: CircuitBreaker | AdaptiveCircuitBreaker,
103 ) -> ResilienceOrchestrator[T]:
104 """Add a circuit breaker.
106 Args:
107 breaker: Standard or adaptive circuit breaker.
109 Returns:
110 self for chaining.
112 """
113 if isinstance(breaker, AdaptiveCircuitBreaker):
114 self._adaptive_breaker = breaker
115 self._breaker = None
116 else:
117 self._breaker = breaker
118 return self
120 def with_retry(
121 self,
122 config: RetryConfig | AdaptiveRetry,
123 ) -> ResilienceOrchestrator[T]:
124 """Add retry logic.
126 Args:
127 config: Standard retry config or adaptive retry.
129 Returns:
130 self for chaining.
132 """
133 if isinstance(config, AdaptiveRetry):
134 self._adaptive_retry = config
135 self._retry_config = config.to_retry_config()
136 else:
137 self._retry_config = config
138 return self
140 def with_timeout(self, seconds: float) -> ResilienceOrchestrator[T]:
141 """Add a timeout.
143 Args:
144 seconds: Maximum execution time.
146 Returns:
147 self for chaining.
149 """
150 if not math.isfinite(seconds) or seconds < 0:
151 raise ValueError("timeout must be a finite non-negative number")
152 self._timeout = seconds
153 return self
155 def with_fallback(self, value: T) -> ResilienceOrchestrator[T]:
156 """Add a fallback value for failures.
158 Args:
159 value: Value to return on failure.
161 Returns:
162 self for chaining.
164 """
165 self._fallback_value = value
166 return self
168 async def _acquire_bulkhead(self, bh: Bulkhead) -> Result[None, Exception]:
169 """Attempt to acquire a bulkhead permit, handling timeouts and errors."""
170 try:
171 await asyncio.wait_for(
172 bh._semaphore.acquire(),
173 timeout=bh._timeout,
174 )
175 return Ok(None)
176 except TimeoutError:
177 return Err(
178 TimeoutError(f"Bulkhead '{bh.name}' timed out after {bh._timeout}s")
179 )
180 except (RuntimeError, OSError, MemoryError) as e:
181 return Err(RuntimeError(f"Resource exhaustion: {e!s}"))
183 async def _execute_with_bulkhead(
184 self,
185 bh: Bulkhead,
186 fn: Callable[P, Awaitable[T]],
187 *args: P.args,
188 **kwargs: P.kwargs,
189 ) -> Result[T, Exception]:
190 """Execute through the bulkhead layer."""
191 if bh.queued >= bh._max_queue:
192 result: Result[T, Exception] = Err(
193 BulkheadFullError(bh.name, bh._max_concurrent, bh._max_queue),
194 )
195 return self._apply_fallback(result)
197 bh._queued += 1
198 try:
199 acquire_result = await self._acquire_bulkhead(bh)
200 if isinstance(acquire_result, Err):
201 return self._apply_fallback(cast(Result[T, Exception], acquire_result))
202 finally:
203 bh._queued -= 1
205 bh._active += 1
206 try:
207 try:
208 return await self._execute_inner(fn, *args, **kwargs)
209 except Exception as exc:
210 return self._apply_fallback(Err(exc))
211 finally:
212 bh._active -= 1
213 bh._semaphore.release()
215 async def execute(
216 self,
217 fn: Callable[P, Awaitable[T]],
218 *args: P.args,
219 **kwargs: P.kwargs,
220 ) -> Result[T, Exception]:
221 """Execute the function through the resilience pipeline.
223 Order: bulkhead → circuit breaker → retry → timeout → fn → fallback.
225 Args:
226 fn: Async callable to execute.
227 *args: Positional arguments.
228 **kwargs: Keyword arguments.
230 Returns:
231 ``Ok(result)`` on success, ``Err`` on failure.
233 """
234 # Layer 1: Bulkhead — use semaphore directly to avoid double-wrapping
235 if self._bulkhead is not None:
236 return await self._execute_with_bulkhead(
237 self._bulkhead,
238 fn,
239 *args,
240 **kwargs,
241 )
243 try:
244 return await self._execute_inner(fn, *args, **kwargs)
245 except Exception as exc:
246 return self._apply_fallback(Err(exc))
248 def _evaluate_adaptive_breaker(self) -> Err[Exception] | None:
249 if (
250 self._adaptive_breaker is not None
251 and not self._adaptive_breaker.should_allow()
252 ):
253 return Err(
254 CircuitBreakerError(
255 f"Circuit '{self._adaptive_breaker.name}' is open",
256 state=self._adaptive_breaker.state,
257 ),
258 )
259 return None
261 def _evaluate_standard_breaker(self) -> Err[Exception] | None:
262 if self._breaker is not None and not self._breaker._should_attempt():
263 return Err(
264 CircuitBreakerError(
265 f"Circuit '{self._breaker.name}' is open",
266 state=self._breaker.state,
267 ),
268 )
269 return None
271 def _evaluate_circuit_breaker(self) -> Err[Exception] | None:
272 """Check if execution is allowed by the circuit breaker."""
273 if self._adaptive_breaker is not None:
274 return self._evaluate_adaptive_breaker()
275 return self._evaluate_standard_breaker()
277 def _record_success_outcome(self, attempt: int) -> None:
278 """Record a successful execution outcome."""
279 if self._adaptive_breaker is not None:
280 self._adaptive_breaker.record_success()
281 elif self._breaker is not None:
282 self._breaker._record_success()
284 if self._adaptive_retry is not None:
285 self._adaptive_retry.record_outcome(attempt, True, 0.0)
287 def _record_failure_outcome(self, error: Exception, attempt: int) -> None:
288 """Record a failed execution outcome."""
289 if self._adaptive_breaker is not None:
290 self._adaptive_breaker.record_failure(error)
291 elif self._breaker is not None:
292 self._breaker._record_failure(error)
294 if self._adaptive_retry is not None:
295 self._adaptive_retry.record_outcome(attempt, False, 0.0)
297 def _calculate_retry_delay(self, attempt: int) -> float:
298 """Calculate the retry delay for the given attempt."""
299 if self._adaptive_retry is not None:
300 return self._adaptive_retry.get_delay(attempt)
301 if self._retry_config is not None:
302 return calculate_delay(attempt, self._retry_config)
303 return 0.0
305 async def _execute_inner(
306 self,
307 fn: Callable[P, Awaitable[T]],
308 *args: P.args,
309 **kwargs: P.kwargs,
310 ) -> Result[T, Exception]:
311 """Execute through breaker → retry → timeout → fn layers."""
312 max_attempts = (
313 self._retry_config.max_attempts if self._retry_config is not None else 1
314 )
315 return await self._execute_with_retries(max_attempts, fn, *args, **kwargs)
317 async def _handle_retry_failure(
318 self,
319 error: Exception,
320 attempt: int,
321 max_attempts: int,
322 ) -> bool:
323 self._record_failure_outcome(error, attempt)
324 if self._retry_config is not None and attempt < max_attempts:
325 delay = self._calculate_retry_delay(attempt)
326 await asyncio.sleep(min(delay, 3600.0))
327 return True
328 return False
330 def _handle_circuit_breaker_open(
331 self,
332 cb_err: Err[Exception],
333 attempt: int,
334 ) -> Result[T, Exception] | Exception:
335 """Handle circuit breaker evaluation logic."""
336 if attempt == 1:
337 return self._apply_fallback(cb_err)
338 return cb_err.err_value
340 async def _execute_single_attempt(
341 self,
342 attempt: int,
343 fn: Callable[P, Awaitable[T]],
344 *args: P.args,
345 **kwargs: P.kwargs,
346 ) -> Result[T, Exception]:
347 """Execute a single attempt including timeout."""
348 result = await self._execute_with_timeout(fn, *args, **kwargs)
349 if isinstance(result, Ok):
350 self._record_success_outcome(attempt)
351 return result
353 def _check_circuit_breaker_for_attempt(
354 self,
355 attempt: int,
356 ) -> Result[T, Exception] | Exception | None:
357 """Evaluate circuit breaker for the current attempt."""
358 cb_err = self._evaluate_circuit_breaker()
359 if cb_err is not None:
360 return self._handle_circuit_breaker_open(cb_err, attempt)
361 return None
363 async def _process_retry_attempt(
364 self,
365 attempt: int,
366 max_attempts: int,
367 fn: Callable[P, Awaitable[T]],
368 *args: P.args,
369 **kwargs: P.kwargs,
370 ) -> Result[T, Exception] | tuple[bool, Exception]:
371 """Process a single retry attempt including circuit breaker check."""
372 cb_res = self._check_circuit_breaker_for_attempt(attempt)
373 if cb_res is not None:
374 if isinstance(cb_res, Exception):
375 return False, cb_res
376 return cb_res
378 result = await self._execute_single_attempt(attempt, fn, *args, **kwargs)
379 if isinstance(result, Ok):
380 return result
382 last_error = result.err_value
383 should_continue = await self._handle_retry_failure(
384 last_error, attempt, max_attempts
385 )
386 return should_continue, last_error
388 async def _execute_with_retries(
389 self,
390 max_attempts: int,
391 fn: Callable[P, Awaitable[T]],
392 *args: P.args,
393 **kwargs: P.kwargs,
394 ) -> Result[T, Exception]:
395 last_error: Exception = RuntimeError("Execution failed")
396 for attempt in range(1, max_attempts + 1):
397 outcome = await self._process_retry_attempt(
398 attempt, max_attempts, fn, *args, **kwargs
399 )
400 if not isinstance(outcome, tuple):
401 return outcome
403 should_continue, last_error = outcome
404 if not should_continue:
405 break
407 return self._apply_fallback(Err(last_error))
409 async def _run_fn_with_timeout(
410 self,
411 fn: Callable[P, Awaitable[T]],
412 *args: P.args,
413 **kwargs: P.kwargs,
414 ) -> T | Result[T, Exception]:
415 if self._timeout is not None:
416 return await asyncio.wait_for(
417 fn(*args, **kwargs),
418 timeout=self._timeout,
419 )
420 return await fn(*args, **kwargs)
422 async def _execute_with_timeout(
423 self,
424 fn: Callable[P, Awaitable[T]],
425 *args: P.args,
426 **kwargs: P.kwargs,
427 ) -> Result[T, Exception]:
428 """Execute fn with optional timeout.
430 Args:
431 fn: Async callable.
432 *args: Positional arguments.
433 **kwargs: Keyword arguments.
435 Returns:
436 ``Ok(result)`` or ``Err``.
438 """
439 try:
440 result = await self._run_fn_with_timeout(fn, *args, **kwargs)
442 if isinstance(result, (Ok, Err)):
443 return cast(Result[T, Exception], result)
444 return Ok(result)
445 except TimeoutError:
446 return Err(
447 TimeoutError(
448 f"Pipeline '{self.name}' timed out after {self._timeout}s"
449 ),
450 )
451 except Exception as exc:
452 return Err(exc)
454 def _apply_fallback(
455 self,
456 result: Result[T, Exception],
457 ) -> Result[T, Exception]:
458 """Apply fallback if configured and result is Err.
460 Args:
461 result: The result to potentially replace.
463 Returns:
464 Original result or ``Ok(fallback_value)``.
466 """
467 if isinstance(result, Err) and self._fallback_value is not _SENTINEL:
468 return Ok(cast(T, self._fallback_value))
469 return result
472# Sentinel for distinguishing "no fallback" from "fallback=None"
473_SENTINEL = object()