provide-foundation 0.0.0.dev1__py3-none-any.whl → 0.0.0.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. provide/foundation/__init__.py +29 -3
  2. provide/foundation/archive/operations.py +4 -6
  3. provide/foundation/cli/__init__.py +2 -2
  4. provide/foundation/cli/commands/deps.py +13 -7
  5. provide/foundation/cli/commands/logs/__init__.py +1 -1
  6. provide/foundation/cli/commands/logs/query.py +1 -1
  7. provide/foundation/cli/commands/logs/send.py +1 -1
  8. provide/foundation/cli/commands/logs/tail.py +1 -1
  9. provide/foundation/cli/decorators.py +11 -10
  10. provide/foundation/cli/main.py +1 -1
  11. provide/foundation/cli/testing.py +2 -35
  12. provide/foundation/cli/utils.py +21 -17
  13. provide/foundation/config/__init__.py +35 -2
  14. provide/foundation/config/converters.py +479 -0
  15. provide/foundation/config/defaults.py +67 -0
  16. provide/foundation/config/env.py +4 -19
  17. provide/foundation/config/loader.py +9 -3
  18. provide/foundation/console/input.py +5 -5
  19. provide/foundation/console/output.py +35 -13
  20. provide/foundation/context/__init__.py +8 -4
  21. provide/foundation/context/core.py +85 -109
  22. provide/foundation/crypto/certificates/operations.py +1 -1
  23. provide/foundation/errors/__init__.py +2 -3
  24. provide/foundation/errors/decorators.py +0 -231
  25. provide/foundation/errors/types.py +0 -97
  26. provide/foundation/file/directory.py +13 -22
  27. provide/foundation/file/lock.py +3 -1
  28. provide/foundation/hub/components.py +72 -384
  29. provide/foundation/hub/config.py +151 -0
  30. provide/foundation/hub/discovery.py +62 -0
  31. provide/foundation/hub/handlers.py +81 -0
  32. provide/foundation/hub/lifecycle.py +194 -0
  33. provide/foundation/hub/manager.py +4 -4
  34. provide/foundation/hub/processors.py +44 -0
  35. provide/foundation/integrations/__init__.py +11 -0
  36. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  37. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  38. provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
  39. provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
  40. provide/foundation/integrations/openobserve/config.py +37 -0
  41. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  42. provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
  43. provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
  44. provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
  45. provide/foundation/logger/config/logging.py +68 -298
  46. provide/foundation/logger/config/telemetry.py +41 -121
  47. provide/foundation/logger/setup/coordinator.py +1 -1
  48. provide/foundation/observability/__init__.py +2 -2
  49. provide/foundation/process/__init__.py +9 -0
  50. provide/foundation/process/exit.py +47 -0
  51. provide/foundation/process/lifecycle.py +33 -33
  52. provide/foundation/resilience/__init__.py +35 -0
  53. provide/foundation/resilience/circuit.py +164 -0
  54. provide/foundation/resilience/decorators.py +220 -0
  55. provide/foundation/resilience/fallback.py +193 -0
  56. provide/foundation/resilience/retry.py +325 -0
  57. provide/foundation/streams/config.py +79 -0
  58. provide/foundation/streams/console.py +7 -8
  59. provide/foundation/streams/core.py +6 -3
  60. provide/foundation/streams/file.py +12 -2
  61. provide/foundation/testing/__init__.py +7 -2
  62. provide/foundation/testing/cli.py +30 -17
  63. provide/foundation/testing/common/__init__.py +0 -2
  64. provide/foundation/testing/common/fixtures.py +0 -27
  65. provide/foundation/testing/file/content_fixtures.py +316 -0
  66. provide/foundation/testing/file/directory_fixtures.py +107 -0
  67. provide/foundation/testing/file/fixtures.py +45 -516
  68. provide/foundation/testing/file/special_fixtures.py +153 -0
  69. provide/foundation/testing/logger.py +76 -0
  70. provide/foundation/testing/process/async_fixtures.py +405 -0
  71. provide/foundation/testing/process/fixtures.py +50 -571
  72. provide/foundation/testing/process/subprocess_fixtures.py +209 -0
  73. provide/foundation/testing/threading/basic_fixtures.py +101 -0
  74. provide/foundation/testing/threading/data_fixtures.py +99 -0
  75. provide/foundation/testing/threading/execution_fixtures.py +263 -0
  76. provide/foundation/testing/threading/fixtures.py +34 -500
  77. provide/foundation/testing/threading/sync_fixtures.py +97 -0
  78. provide/foundation/testing/time/fixtures.py +4 -4
  79. provide/foundation/tools/cache.py +8 -6
  80. provide/foundation/tools/downloader.py +23 -12
  81. provide/foundation/tracer/spans.py +2 -2
  82. provide/foundation/transport/config.py +26 -95
  83. provide/foundation/transport/middleware.py +30 -36
  84. provide/foundation/utils/deps.py +14 -12
  85. provide/foundation/utils/parsing.py +49 -4
  86. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +1 -1
  87. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/RECORD +93 -68
  88. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  89. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  90. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
  91. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
  92. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
  93. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/top_level.txt +0 -0
@@ -17,7 +17,7 @@ except ImportError:
17
17
  # Only import OpenObserve if OpenTelemetry is available
18
18
  if _HAS_OTEL:
19
19
  try:
20
- from provide.foundation.observability.openobserve import (
20
+ from provide.foundation.integrations.openobserve import (
21
21
  OpenObserveClient,
22
22
  search_logs,
23
23
  stream_logs,
@@ -25,7 +25,7 @@ if _HAS_OTEL:
25
25
 
26
26
  # Commands will auto-register if click is available
27
27
  try:
28
- from provide.foundation.observability.openobserve.commands import (
28
+ from provide.foundation.integrations.openobserve.commands import (
29
29
  openobserve_group,
30
30
  )
31
31
  except ImportError:
@@ -10,6 +10,11 @@ from provide.foundation.process.async_runner import (
10
10
  async_run_shell,
11
11
  async_stream_command,
12
12
  )
13
+ from provide.foundation.process.exit import (
14
+ exit_success,
15
+ exit_error,
16
+ exit_interrupted,
17
+ )
13
18
  from provide.foundation.process.lifecycle import (
14
19
  ManagedProcess,
15
20
  wait_for_process_output,
@@ -36,4 +41,8 @@ __all__ = [
36
41
  # Process lifecycle management
37
42
  "ManagedProcess",
38
43
  "wait_for_process_output",
44
+ # Exit utilities
45
+ "exit_success",
46
+ "exit_error",
47
+ "exit_interrupted",
39
48
  ]
@@ -0,0 +1,47 @@
1
+ """Process exit utilities for standardized exit handling."""
2
+
3
+ import sys
4
+
5
+ from provide.foundation.config.defaults import EXIT_SUCCESS, EXIT_ERROR, EXIT_SIGINT
6
+
7
+
8
+ def _get_logger():
9
+ """Get logger instance lazily to avoid circular imports."""
10
+ from provide.foundation.logger import logger
11
+ return logger
12
+
13
+
14
+ def exit_success(message: str | None = None) -> None:
15
+ """Exit with success status.
16
+
17
+ Args:
18
+ message: Optional message to log before exiting
19
+ """
20
+ if message:
21
+ logger = _get_logger()
22
+ logger.info(f"Exiting successfully: {message}")
23
+ sys.exit(EXIT_SUCCESS)
24
+
25
+
26
+ def exit_error(message: str | None = None, code: int = EXIT_ERROR) -> None:
27
+ """Exit with error status.
28
+
29
+ Args:
30
+ message: Optional error message to log before exiting
31
+ code: Exit code to use (defaults to EXIT_ERROR)
32
+ """
33
+ if message:
34
+ logger = _get_logger()
35
+ logger.error(f"Exiting with error: {message}", exit_code=code)
36
+ sys.exit(code)
37
+
38
+
39
+ def exit_interrupted(message: str = "Process interrupted") -> None:
40
+ """Exit due to interrupt signal (SIGINT).
41
+
42
+ Args:
43
+ message: Message to log before exiting
44
+ """
45
+ logger = _get_logger()
46
+ logger.warning(f"Exiting due to interrupt: {message}")
47
+ sys.exit(EXIT_SIGINT)
@@ -16,6 +16,13 @@ import threading
16
16
  import traceback
17
17
  from typing import Any
18
18
 
19
+ from provide.foundation.config.defaults import (
20
+ DEFAULT_PROCESS_READLINE_TIMEOUT,
21
+ DEFAULT_PROCESS_READCHAR_TIMEOUT,
22
+ DEFAULT_PROCESS_TERMINATE_TIMEOUT,
23
+ DEFAULT_PROCESS_WAIT_TIMEOUT,
24
+ )
25
+ from provide.foundation.errors.decorators import with_error_handling
19
26
  from provide.foundation.logger import get_logger
20
27
  from provide.foundation.process.runner import ProcessError
21
28
 
@@ -112,6 +119,9 @@ class ManagedProcess:
112
119
  return False
113
120
  return self._process.poll() is None
114
121
 
122
+ @with_error_handling(
123
+ error_mapper=lambda e: ProcessError(f"Failed to launch process: {e}") if not isinstance(e, (ProcessError, RuntimeError)) else e
124
+ )
115
125
  def launch(self) -> None:
116
126
  """
117
127
  Launch the managed process.
@@ -125,37 +135,27 @@ class ManagedProcess:
125
135
 
126
136
  plog.debug("🚀 Launching managed process", command=" ".join(self.command))
127
137
 
128
- try:
129
- self._process = subprocess.Popen(
130
- self.command,
131
- cwd=self.cwd,
132
- env=self._env,
133
- stdout=subprocess.PIPE if self.capture_output else None,
134
- stderr=subprocess.PIPE if self.capture_output else None,
135
- text=self.text_mode,
136
- bufsize=self.bufsize,
137
- **self.kwargs,
138
- )
139
- self._started = True
140
-
141
- plog.info(
142
- "🚀 Managed process started successfully",
143
- pid=self._process.pid,
144
- command=" ".join(self.command),
145
- )
138
+ self._process = subprocess.Popen(
139
+ self.command,
140
+ cwd=self.cwd,
141
+ env=self._env,
142
+ stdout=subprocess.PIPE if self.capture_output else None,
143
+ stderr=subprocess.PIPE if self.capture_output else None,
144
+ text=self.text_mode,
145
+ bufsize=self.bufsize,
146
+ **self.kwargs,
147
+ )
148
+ self._started = True
146
149
 
147
- # Start stderr relay if enabled
148
- if self.stderr_relay and self._process.stderr:
149
- self._start_stderr_relay()
150
+ plog.info(
151
+ "🚀 Managed process started successfully",
152
+ pid=self._process.pid,
153
+ command=" ".join(self.command),
154
+ )
150
155
 
151
- except Exception as e:
152
- plog.error(
153
- "🚀❌ Failed to launch managed process",
154
- command=" ".join(self.command),
155
- error=str(e),
156
- trace=traceback.format_exc(),
157
- )
158
- raise ProcessError(f"Failed to launch process: {e}") from e
156
+ # Start stderr relay if enabled
157
+ if self.stderr_relay and self._process.stderr:
158
+ self._start_stderr_relay()
159
159
 
160
160
  def _start_stderr_relay(self) -> None:
161
161
  """Start a background thread to relay stderr output."""
@@ -186,7 +186,7 @@ class ManagedProcess:
186
186
  self._stderr_thread.start()
187
187
  plog.debug("🚀 Started stderr relay thread")
188
188
 
189
- async def read_line_async(self, timeout: float = 2.0) -> str:
189
+ async def read_line_async(self, timeout: float = DEFAULT_PROCESS_READLINE_TIMEOUT) -> str:
190
190
  """
191
191
  Read a line from stdout asynchronously with timeout.
192
192
 
@@ -221,7 +221,7 @@ class ManagedProcess:
221
221
  plog.debug("Read timeout on managed process stdout")
222
222
  raise TimeoutError(f"Read timeout after {timeout}s") from e
223
223
 
224
- async def read_char_async(self, timeout: float = 1.0) -> str:
224
+ async def read_char_async(self, timeout: float = DEFAULT_PROCESS_READCHAR_TIMEOUT) -> str:
225
225
  """
226
226
  Read a single character from stdout asynchronously.
227
227
 
@@ -258,7 +258,7 @@ class ManagedProcess:
258
258
  plog.debug("Character read timeout on managed process stdout")
259
259
  raise TimeoutError(f"Character read timeout after {timeout}s") from e
260
260
 
261
- def terminate_gracefully(self, timeout: float = 7.0) -> bool:
261
+ def terminate_gracefully(self, timeout: float = DEFAULT_PROCESS_TERMINATE_TIMEOUT) -> bool:
262
262
  """
263
263
  Terminate the process gracefully with a timeout.
264
264
 
@@ -340,7 +340,7 @@ class ManagedProcess:
340
340
  async def wait_for_process_output(
341
341
  process: ManagedProcess,
342
342
  expected_parts: list[str],
343
- timeout: float = 10.0,
343
+ timeout: float = DEFAULT_PROCESS_WAIT_TIMEOUT,
344
344
  buffer_size: int = 1024,
345
345
  ) -> str:
346
346
  """
@@ -0,0 +1,35 @@
1
+ """
2
+ Resilience patterns for handling failures and improving reliability.
3
+
4
+ This module provides unified implementations of common resilience patterns:
5
+ - Retry with configurable backoff strategies
6
+ - Circuit breaker for failing fast
7
+ - Fallback for graceful degradation
8
+
9
+ These patterns are used throughout foundation to eliminate code duplication
10
+ and provide consistent failure handling.
11
+ """
12
+
13
+ from provide.foundation.resilience.circuit import CircuitBreaker, CircuitState
14
+ from provide.foundation.resilience.decorators import circuit_breaker, fallback, retry
15
+ from provide.foundation.resilience.fallback import FallbackChain
16
+ from provide.foundation.resilience.retry import BackoffStrategy, RetryExecutor, RetryPolicy
17
+
18
+ __all__ = [
19
+ # Core retry functionality
20
+ "RetryPolicy",
21
+ "RetryExecutor",
22
+ "BackoffStrategy",
23
+
24
+ # Circuit breaker
25
+ "CircuitBreaker",
26
+ "CircuitState",
27
+
28
+ # Fallback
29
+ "FallbackChain",
30
+
31
+ # Decorators
32
+ "retry",
33
+ "circuit_breaker",
34
+ "fallback",
35
+ ]
@@ -0,0 +1,164 @@
1
+ """
2
+ Circuit breaker implementation for preventing cascading failures.
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from enum import Enum
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from attrs import define, field
11
+
12
+ from provide.foundation.logger import logger
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class CircuitState(Enum):
18
+ """Circuit breaker states."""
19
+ CLOSED = "closed" # Normal operation
20
+ OPEN = "open" # Circuit is open, failing fast
21
+ HALF_OPEN = "half_open" # Testing if service has recovered
22
+
23
+
24
+ @define(kw_only=True, slots=True)
25
+ class CircuitBreaker:
26
+ """Circuit breaker pattern for preventing cascading failures.
27
+
28
+ Tracks failures and opens the circuit when threshold is exceeded.
29
+ Periodically allows test requests to check if service has recovered.
30
+ """
31
+
32
+ failure_threshold: int = field(default=5)
33
+ recovery_timeout: float = field(default=60.0) # seconds
34
+ expected_exception: tuple[type[Exception], ...] = field(
35
+ factory=lambda: (Exception,)
36
+ )
37
+
38
+ # Internal state
39
+ _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
40
+ _failure_count: int = field(default=0, init=False)
41
+ _last_failure_time: float | None = field(default=None, init=False)
42
+ _next_attempt_time: float = field(default=0.0, init=False)
43
+
44
+ @property
45
+ def state(self) -> CircuitState:
46
+ """Current circuit breaker state."""
47
+ return self._state
48
+
49
+ @property
50
+ def failure_count(self) -> int:
51
+ """Current failure count."""
52
+ return self._failure_count
53
+
54
+ def _should_attempt_reset(self) -> bool:
55
+ """Check if we should attempt to reset the circuit."""
56
+ if self._state != CircuitState.OPEN:
57
+ return False
58
+
59
+ current_time = time.time()
60
+ return current_time >= self._next_attempt_time
61
+
62
+ def _record_success(self) -> None:
63
+ """Record successful execution."""
64
+ if self._state == CircuitState.HALF_OPEN:
65
+ logger.info(
66
+ "Circuit breaker recovered - closing circuit",
67
+ state="half_open->closed",
68
+ failure_count=self._failure_count
69
+ )
70
+
71
+ self._failure_count = 0
72
+ self._last_failure_time = None
73
+ self._state = CircuitState.CLOSED
74
+
75
+ def _record_failure(self, exception: Exception) -> None:
76
+ """Record failed execution."""
77
+ self._failure_count += 1
78
+ self._last_failure_time = time.time()
79
+
80
+ if self._state == CircuitState.HALF_OPEN:
81
+ # Failed during recovery attempt
82
+ self._state = CircuitState.OPEN
83
+ self._next_attempt_time = self._last_failure_time + self.recovery_timeout
84
+ logger.warning(
85
+ "Circuit breaker recovery failed - opening circuit",
86
+ state="half_open->open",
87
+ failure_count=self._failure_count,
88
+ next_attempt_in=self.recovery_timeout
89
+ )
90
+ elif self._failure_count >= self.failure_threshold:
91
+ # Threshold exceeded, open circuit
92
+ self._state = CircuitState.OPEN
93
+ self._next_attempt_time = self._last_failure_time + self.recovery_timeout
94
+ logger.error(
95
+ "Circuit breaker opened due to failures",
96
+ state="closed->open",
97
+ failure_count=self._failure_count,
98
+ failure_threshold=self.failure_threshold,
99
+ next_attempt_in=self.recovery_timeout
100
+ )
101
+
102
+ def call(self, func: Callable[..., T], *args, **kwargs) -> T:
103
+ """Execute function with circuit breaker protection (sync)."""
104
+ # Check if circuit is open
105
+ if self._state == CircuitState.OPEN:
106
+ if self._should_attempt_reset():
107
+ self._state = CircuitState.HALF_OPEN
108
+ logger.info(
109
+ "Circuit breaker attempting recovery",
110
+ state="open->half_open",
111
+ failure_count=self._failure_count
112
+ )
113
+ else:
114
+ raise RuntimeError(
115
+ f"Circuit breaker is open. Next attempt in "
116
+ f"{self._next_attempt_time - time.time():.1f} seconds"
117
+ )
118
+
119
+ try:
120
+ result = func(*args, **kwargs)
121
+ self._record_success()
122
+ return result
123
+ except Exception as e:
124
+ if isinstance(e, self.expected_exception):
125
+ self._record_failure(e)
126
+ raise
127
+
128
+ async def call_async(self, func: Callable[..., T], *args, **kwargs) -> T:
129
+ """Execute async function with circuit breaker protection."""
130
+ # Check if circuit is open
131
+ if self._state == CircuitState.OPEN:
132
+ if self._should_attempt_reset():
133
+ self._state = CircuitState.HALF_OPEN
134
+ logger.info(
135
+ "Circuit breaker attempting recovery",
136
+ state="open->half_open",
137
+ failure_count=self._failure_count
138
+ )
139
+ else:
140
+ raise RuntimeError(
141
+ f"Circuit breaker is open. Next attempt in "
142
+ f"{self._next_attempt_time - time.time():.1f} seconds"
143
+ )
144
+
145
+ try:
146
+ result = await func(*args, **kwargs)
147
+ self._record_success()
148
+ return result
149
+ except Exception as e:
150
+ if isinstance(e, self.expected_exception):
151
+ self._record_failure(e)
152
+ raise
153
+
154
+ def reset(self) -> None:
155
+ """Manually reset the circuit breaker."""
156
+ logger.info(
157
+ "Circuit breaker manually reset",
158
+ previous_state=self._state.value,
159
+ failure_count=self._failure_count
160
+ )
161
+ self._state = CircuitState.CLOSED
162
+ self._failure_count = 0
163
+ self._last_failure_time = None
164
+ self._next_attempt_time = 0.0
@@ -0,0 +1,220 @@
1
+ """
2
+ Resilience decorators for retry, circuit breaker, and fallback patterns.
3
+ """
4
+
5
+ import asyncio
6
+ import functools
7
+ import time
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from attrs import define, field
11
+
12
+ from provide.foundation.config.defaults import DEFAULT_CIRCUIT_BREAKER_RECOVERY_TIMEOUT
13
+ from provide.foundation.resilience.retry import BackoffStrategy, RetryExecutor, RetryPolicy
14
+
15
+ F = TypeVar("F", bound=Callable[..., Any])
16
+
17
+
18
+ def _get_logger():
19
+ """Get logger instance lazily to avoid circular imports."""
20
+ from provide.foundation.logger import logger
21
+ return logger
22
+
23
+
24
+ def retry(
25
+ *exceptions: type[Exception],
26
+ policy: RetryPolicy | None = None,
27
+ max_attempts: int | None = None,
28
+ base_delay: float | None = None,
29
+ backoff: BackoffStrategy | None = None,
30
+ max_delay: float | None = None,
31
+ jitter: bool | None = None,
32
+ on_retry: Callable[[int, Exception], None] | None = None,
33
+ ) -> Callable[[F], F]:
34
+ """
35
+ Decorator for retrying operations on errors.
36
+
37
+ Can be used in multiple ways:
38
+
39
+ 1. With a policy object:
40
+ @retry(policy=RetryPolicy(max_attempts=5))
41
+
42
+ 2. With individual parameters:
43
+ @retry(max_attempts=3, base_delay=1.0)
44
+
45
+ 3. With specific exceptions:
46
+ @retry(ConnectionError, TimeoutError, max_attempts=3)
47
+
48
+ 4. Without parentheses (uses defaults):
49
+ @retry
50
+ def my_func(): ...
51
+
52
+ Args:
53
+ *exceptions: Exception types to retry (all if empty)
54
+ policy: Complete retry policy (overrides other params)
55
+ max_attempts: Maximum retry attempts
56
+ base_delay: Base delay between retries
57
+ backoff: Backoff strategy
58
+ max_delay: Maximum delay cap
59
+ jitter: Whether to add jitter
60
+ on_retry: Callback for retry events
61
+
62
+ Returns:
63
+ Decorated function with retry logic
64
+
65
+ Examples:
66
+ >>> @retry(max_attempts=3)
67
+ ... def flaky_operation():
68
+ ... # May fail occasionally
69
+ ... pass
70
+
71
+ >>> @retry(ConnectionError, max_attempts=5, base_delay=2.0)
72
+ ... async def connect_to_service():
73
+ ... # Async function with specific error handling
74
+ ... pass
75
+ """
76
+ # Handle decorator without parentheses
77
+ if len(exceptions) == 1 and callable(exceptions[0]) and not isinstance(exceptions[0], type):
78
+ # Called as @retry without parentheses
79
+ func = exceptions[0]
80
+ executor = RetryExecutor(RetryPolicy())
81
+
82
+ if asyncio.iscoroutinefunction(func):
83
+ @functools.wraps(func)
84
+ async def async_wrapper(*args, **kwargs):
85
+ return await executor.execute_async(func, *args, **kwargs)
86
+ return async_wrapper
87
+ else:
88
+ @functools.wraps(func)
89
+ def sync_wrapper(*args, **kwargs):
90
+ return executor.execute_sync(func, *args, **kwargs)
91
+ return sync_wrapper
92
+
93
+ # Build policy if not provided
94
+ if policy is not None and any(
95
+ p is not None for p in [max_attempts, base_delay, backoff, max_delay, jitter]
96
+ ):
97
+ raise ValueError(
98
+ "Cannot specify both policy and individual retry parameters"
99
+ )
100
+
101
+ if policy is None:
102
+ # Build policy from parameters
103
+ policy_kwargs = {}
104
+
105
+ if max_attempts is not None:
106
+ policy_kwargs['max_attempts'] = max_attempts
107
+ if base_delay is not None:
108
+ policy_kwargs['base_delay'] = base_delay
109
+ if backoff is not None:
110
+ policy_kwargs['backoff'] = backoff
111
+ if max_delay is not None:
112
+ policy_kwargs['max_delay'] = max_delay
113
+ if jitter is not None:
114
+ policy_kwargs['jitter'] = jitter
115
+ if exceptions:
116
+ policy_kwargs['retryable_errors'] = exceptions
117
+
118
+ policy = RetryPolicy(**policy_kwargs)
119
+
120
+ def decorator(func: F) -> F:
121
+ executor = RetryExecutor(policy, on_retry=on_retry)
122
+
123
+ if asyncio.iscoroutinefunction(func):
124
+ @functools.wraps(func)
125
+ async def async_wrapper(*args, **kwargs):
126
+ return await executor.execute_async(func, *args, **kwargs)
127
+ return async_wrapper
128
+ else:
129
+ @functools.wraps(func)
130
+ def sync_wrapper(*args, **kwargs):
131
+ return executor.execute_sync(func, *args, **kwargs)
132
+ return sync_wrapper
133
+
134
+ return decorator
135
+
136
+
137
+ # Import CircuitBreaker from circuit module
138
+ from provide.foundation.resilience.circuit import CircuitBreaker
139
+
140
+
141
+ def circuit_breaker(
142
+ failure_threshold: int = 5,
143
+ recovery_timeout: float = DEFAULT_CIRCUIT_BREAKER_RECOVERY_TIMEOUT,
144
+ expected_exception: tuple[type[Exception], ...] = (Exception,),
145
+ ) -> Callable[[F], F]:
146
+ """Create a circuit breaker decorator.
147
+
148
+ Args:
149
+ failure_threshold: Number of failures before opening circuit.
150
+ recovery_timeout: Seconds to wait before attempting recovery.
151
+ expected_exception: Exception types that trigger the breaker.
152
+
153
+ Returns:
154
+ Circuit breaker decorator.
155
+
156
+ Examples:
157
+ >>> @circuit_breaker(failure_threshold=3, recovery_timeout=30)
158
+ ... def unreliable_service():
159
+ ... return external_api_call()
160
+ """
161
+ breaker = CircuitBreaker(
162
+ failure_threshold=failure_threshold,
163
+ recovery_timeout=recovery_timeout,
164
+ expected_exception=expected_exception,
165
+ )
166
+
167
+ def decorator(func: F) -> F:
168
+ @functools.wraps(func)
169
+ def sync_wrapper(*args, **kwargs):
170
+ return breaker.call(func, *args, **kwargs)
171
+
172
+ @functools.wraps(func)
173
+ async def async_wrapper(*args, **kwargs):
174
+ return await breaker.call_async(func, *args, **kwargs)
175
+
176
+ if asyncio.iscoroutinefunction(func):
177
+ return async_wrapper # type: ignore
178
+ else:
179
+ return sync_wrapper # type: ignore
180
+
181
+ return decorator
182
+
183
+
184
+ def fallback(*fallback_funcs: Callable[..., Any]) -> Callable[[F], F]:
185
+ """
186
+ Fallback decorator using FallbackChain.
187
+
188
+ Args:
189
+ *fallback_funcs: Functions to use as fallbacks, in order of preference
190
+
191
+ Returns:
192
+ Decorated function with fallback chain
193
+
194
+ Examples:
195
+ >>> def backup_api():
196
+ ... return "backup result"
197
+ ...
198
+ >>> @fallback(backup_api)
199
+ ... def primary_api():
200
+ ... return external_api_call()
201
+ """
202
+ from provide.foundation.resilience.fallback import FallbackChain
203
+
204
+ def decorator(func: F) -> F:
205
+ chain = FallbackChain()
206
+ for fallback_func in fallback_funcs:
207
+ chain.add_fallback(fallback_func)
208
+
209
+ if asyncio.iscoroutinefunction(func):
210
+ @functools.wraps(func)
211
+ async def async_wrapper(*args, **kwargs):
212
+ return await chain.execute_async(func, *args, **kwargs)
213
+ return async_wrapper # type: ignore
214
+ else:
215
+ @functools.wraps(func)
216
+ def sync_wrapper(*args, **kwargs):
217
+ return chain.execute(func, *args, **kwargs)
218
+ return sync_wrapper # type: ignore
219
+
220
+ return decorator