Coverage for src/taipanstack/resilience/adaptive/orchestrator.py: 100%
185 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 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 @staticmethod
74 def _validate_timeout_param(timeout: float) -> None:
75 """Validate timeout parameter."""
76 if (
77 not isinstance(timeout, (int, float))
78 or not math.isfinite(timeout)
79 or timeout < 0
80 ):
81 raise ValueError("timeout must be a finite non-negative number")
83 def with_bulkhead(
84 self,
85 max_concurrent: int = 10,
86 max_queue: int = 50,
87 timeout: float = 30.0,
88 ) -> ResilienceOrchestrator[T]:
89 """Add a bulkhead concurrency limiter.
91 Args:
92 max_concurrent: Max concurrent executions.
93 max_queue: Max queued callers.
94 timeout: Permit acquisition timeout.
96 Returns:
97 self for chaining.
99 """
100 self._validate_timeout_param(timeout)
101 self._bulkhead = Bulkhead(
102 f"{self.name}-bulkhead",
103 max_concurrent=max_concurrent,
104 max_queue=max_queue,
105 timeout=timeout,
106 )
107 return self
109 def with_circuit_breaker(
110 self,
111 breaker: CircuitBreaker | AdaptiveCircuitBreaker,
112 ) -> ResilienceOrchestrator[T]:
113 """Add a circuit breaker.
115 Args:
116 breaker: Standard or adaptive circuit breaker.
118 Returns:
119 self for chaining.
121 """
122 if isinstance(breaker, AdaptiveCircuitBreaker):
123 self._adaptive_breaker = breaker
124 self._breaker = None
125 else:
126 self._breaker = breaker
127 return self
129 def with_retry(
130 self,
131 config: RetryConfig | AdaptiveRetry,
132 ) -> ResilienceOrchestrator[T]:
133 """Add retry logic.
135 Args:
136 config: Standard retry config or adaptive retry.
138 Returns:
139 self for chaining.
141 """
142 if isinstance(config, AdaptiveRetry):
143 self._adaptive_retry = config
144 self._retry_config = config.to_retry_config()
145 else:
146 self._retry_config = config
147 return self
149 def with_timeout(self, seconds: float) -> ResilienceOrchestrator[T]:
150 """Add a timeout.
152 Args:
153 seconds: Maximum execution time.
155 Returns:
156 self for chaining.
158 """
159 self._validate_timeout_param(seconds)
160 self._timeout = seconds
161 return self
163 def with_fallback(self, value: T) -> ResilienceOrchestrator[T]:
164 """Add a fallback value for failures.
166 Args:
167 value: Value to return on failure.
169 Returns:
170 self for chaining.
172 """
173 self._fallback_value = value
174 return self
176 async def _acquire_bulkhead(self, bh: Bulkhead) -> Result[None, Exception]:
177 """Attempt to acquire a bulkhead permit, handling timeouts and errors."""
178 return await bh._acquire_permit()
180 async def _attempt_bulkhead_acquire(
181 self,
182 bh: Bulkhead,
183 ) -> Result[None, Exception]:
184 """Attempt to queue and acquire a bulkhead permit."""
185 bh._queued += 1
186 try:
187 acquire_result = await self._acquire_bulkhead(bh)
188 if isinstance(acquire_result, Err):
189 return Err(acquire_result.unwrap_err())
190 return Ok(None)
191 finally:
192 bh._queued -= 1
194 async def _execute_bulkhead_inner(
195 self,
196 bh: Bulkhead,
197 fn: Callable[P, Awaitable[T]],
198 *args: P.args,
199 **kwargs: P.kwargs,
200 ) -> Result[T, Exception]:
201 """Execute the inner function while holding the bulkhead permit."""
202 bh._active += 1
203 try:
204 return await self._execute_inner(fn, *args, **kwargs)
205 except Exception as exc:
206 return self._apply_fallback(Err(exc))
207 finally:
208 bh._active -= 1
209 bh._semaphore.release()
211 async def _execute_with_bulkhead(
212 self,
213 bh: Bulkhead,
214 fn: Callable[P, Awaitable[T]],
215 *args: P.args,
216 **kwargs: P.kwargs,
217 ) -> Result[T, Exception]:
218 """Execute through the bulkhead layer."""
219 if bh.queued >= bh._max_queue:
220 result: Result[T, Exception] = Err(
221 BulkheadFullError(bh.name, bh._max_concurrent, bh._max_queue),
222 )
223 return self._apply_fallback(result)
225 acquire_result = await self._attempt_bulkhead_acquire(bh)
226 if isinstance(acquire_result, Err):
227 return self._apply_fallback(cast(Result[T, Exception], acquire_result))
229 return await self._execute_bulkhead_inner(bh, fn, *args, **kwargs)
231 async def execute(
232 self,
233 fn: Callable[P, Awaitable[T]],
234 *args: P.args,
235 **kwargs: P.kwargs,
236 ) -> Result[T, Exception]:
237 """Execute the function through the resilience pipeline.
239 Order: bulkhead → circuit breaker → retry → timeout → fn → fallback.
241 Args:
242 fn: Async callable to execute.
243 *args: Positional arguments.
244 **kwargs: Keyword arguments.
246 Returns:
247 ``Ok(result)`` on success, ``Err`` on failure.
249 """
250 # Layer 1: Bulkhead — use semaphore directly to avoid double-wrapping
251 if self._bulkhead is not None:
252 return await self._execute_with_bulkhead(
253 self._bulkhead,
254 fn,
255 *args,
256 **kwargs,
257 )
259 try:
260 return await self._execute_inner(fn, *args, **kwargs)
261 except Exception as exc:
262 return self._apply_fallback(Err(exc))
264 def _evaluate_adaptive_breaker(self) -> Err[Exception] | None:
265 if (
266 self._adaptive_breaker is not None
267 and not self._adaptive_breaker.should_allow()
268 ):
269 return Err(
270 CircuitBreakerError(
271 f"Circuit '{self._adaptive_breaker.name}' is open",
272 state=self._adaptive_breaker.state,
273 ),
274 )
275 return None
277 def _evaluate_standard_breaker(self) -> Err[Exception] | None:
278 if self._breaker is not None and not self._breaker._should_attempt():
279 return Err(
280 CircuitBreakerError(
281 f"Circuit '{self._breaker.name}' is open",
282 state=self._breaker.state,
283 ),
284 )
285 return None
287 def _evaluate_circuit_breaker(self) -> Err[Exception] | None:
288 """Check if execution is allowed by the circuit breaker."""
289 if self._adaptive_breaker is not None:
290 return self._evaluate_adaptive_breaker()
291 return self._evaluate_standard_breaker()
293 def _record_success_outcome(self, attempt: int) -> None:
294 """Record a successful execution outcome."""
295 if self._adaptive_breaker is not None:
296 self._adaptive_breaker.record_success()
297 elif self._breaker is not None:
298 self._breaker._record_success()
300 if self._adaptive_retry is not None:
301 self._adaptive_retry.record_outcome(attempt, True, 0.0)
303 def _record_failure_outcome(self, error: Exception, attempt: int) -> None:
304 """Record a failed execution outcome."""
305 if self._adaptive_breaker is not None:
306 self._adaptive_breaker.record_failure(error)
307 elif self._breaker is not None:
308 self._breaker._record_failure(error)
310 if self._adaptive_retry is not None:
311 self._adaptive_retry.record_outcome(attempt, False, 0.0)
313 def _calculate_retry_delay(self, attempt: int) -> float:
314 """Calculate the retry delay for the given attempt."""
315 if self._adaptive_retry is not None:
316 return self._adaptive_retry.get_delay(attempt)
317 if self._retry_config is not None:
318 return calculate_delay(attempt, self._retry_config)
319 return 0.0
321 async def _execute_inner(
322 self,
323 fn: Callable[P, Awaitable[T]],
324 *args: P.args,
325 **kwargs: P.kwargs,
326 ) -> Result[T, Exception]:
327 """Execute through breaker → retry → timeout → fn layers."""
328 max_attempts = (
329 self._retry_config.max_attempts if self._retry_config is not None else 1
330 )
331 return await self._execute_with_retries(max_attempts, fn, *args, **kwargs)
333 async def _handle_retry_failure(
334 self,
335 error: Exception,
336 attempt: int,
337 max_attempts: int,
338 ) -> bool:
339 self._record_failure_outcome(error, attempt)
340 if self._retry_config is not None and attempt < max_attempts:
341 delay = self._calculate_retry_delay(attempt)
342 await asyncio.sleep(min(delay, 3600.0))
343 return True
344 return False
346 def _handle_circuit_breaker_open(
347 self,
348 cb_err: Err[Exception],
349 attempt: int,
350 ) -> Result[T, Exception]:
351 """Handle circuit breaker evaluation logic."""
352 if attempt == 1:
353 return self._apply_fallback(cb_err)
354 return cb_err
356 async def _execute_single_attempt(
357 self,
358 attempt: int,
359 fn: Callable[P, Awaitable[T]],
360 *args: P.args,
361 **kwargs: P.kwargs,
362 ) -> Result[T, Exception]:
363 """Execute a single attempt including timeout."""
364 result = await self._execute_with_timeout(fn, *args, **kwargs)
365 if isinstance(result, Ok):
366 self._record_success_outcome(attempt)
367 return result
369 def _check_circuit_breaker_for_attempt(
370 self,
371 attempt: int,
372 ) -> Result[T, Exception] | None:
373 """Evaluate circuit breaker for the current attempt."""
374 cb_err = self._evaluate_circuit_breaker()
375 if cb_err is not None:
376 return self._handle_circuit_breaker_open(cb_err, attempt)
377 return None
379 async def _process_retry_attempt(
380 self,
381 attempt: int,
382 max_attempts: int,
383 fn: Callable[P, Awaitable[T]],
384 *args: P.args,
385 **kwargs: P.kwargs,
386 ) -> Result[T, Exception] | tuple[bool, Exception]:
387 """Process a single retry attempt including circuit breaker check."""
388 cb_res = self._check_circuit_breaker_for_attempt(attempt)
389 if cb_res is not None:
390 if isinstance(cb_res, Err):
391 return False, cb_res.err_value
392 return cb_res
394 result = await self._execute_single_attempt(attempt, fn, *args, **kwargs)
395 if isinstance(result, Ok):
396 return result
398 last_error = result.err_value
399 should_continue = await self._handle_retry_failure(
400 last_error, attempt, max_attempts
401 )
402 return should_continue, last_error
404 async def _execute_with_retries(
405 self,
406 max_attempts: int,
407 fn: Callable[P, Awaitable[T]],
408 *args: P.args,
409 **kwargs: P.kwargs,
410 ) -> Result[T, Exception]:
411 last_error: Exception = RuntimeError("Execution failed")
412 for attempt in range(1, max_attempts + 1):
413 outcome = await self._process_retry_attempt(
414 attempt, max_attempts, fn, *args, **kwargs
415 )
416 if not isinstance(outcome, tuple):
417 return outcome
419 should_continue, last_error = outcome
420 if not should_continue:
421 break
423 return self._apply_fallback(Err(last_error))
425 async def _run_fn_with_timeout(
426 self,
427 fn: Callable[P, Awaitable[T]],
428 *args: P.args,
429 **kwargs: P.kwargs,
430 ) -> T | Result[T, Exception]:
431 if self._timeout is not None:
432 return await asyncio.wait_for(
433 fn(*args, **kwargs),
434 timeout=self._timeout,
435 )
436 return await fn(*args, **kwargs)
438 def _process_timeout_success_result(
439 self,
440 result: T | Result[T, Exception],
441 ) -> Result[T, Exception]:
442 """Process successful results from timeout execution."""
443 if isinstance(result, (Ok, Err)):
444 return cast(Result[T, Exception], result)
445 return Ok(result)
447 async def _execute_with_timeout(
448 self,
449 fn: Callable[P, Awaitable[T]],
450 *args: P.args,
451 **kwargs: P.kwargs,
452 ) -> Result[T, Exception]:
453 """Execute fn with optional timeout.
455 Args:
456 fn: Async callable.
457 *args: Positional arguments.
458 **kwargs: Keyword arguments.
460 Returns:
461 ``Ok(result)`` or ``Err``.
463 """
464 try:
465 result = await self._run_fn_with_timeout(fn, *args, **kwargs)
466 return self._process_timeout_success_result(result)
467 except TimeoutError:
468 return Err(
469 TimeoutError(
470 f"Pipeline '{self.name}' timed out after {self._timeout}s"
471 ),
472 )
473 except Exception as exc:
474 return Err(exc)
476 def _apply_fallback(
477 self,
478 result: Result[T, Exception],
479 ) -> Result[T, Exception]:
480 """Apply fallback if configured and result is Err.
482 Args:
483 result: The result to potentially replace.
485 Returns:
486 Original result or ``Ok(fallback_value)``.
488 """
489 if isinstance(result, Err) and self._fallback_value is not _SENTINEL:
490 return Ok(cast(T, self._fallback_value))
491 return result
494# Sentinel for distinguishing "no fallback" from "fallback=None"
495_SENTINEL = object()