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.
- provide/foundation/__init__.py +29 -3
- provide/foundation/archive/operations.py +4 -6
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +13 -7
- provide/foundation/cli/commands/logs/__init__.py +1 -1
- provide/foundation/cli/commands/logs/query.py +1 -1
- provide/foundation/cli/commands/logs/send.py +1 -1
- provide/foundation/cli/commands/logs/tail.py +1 -1
- provide/foundation/cli/decorators.py +11 -10
- provide/foundation/cli/main.py +1 -1
- provide/foundation/cli/testing.py +2 -35
- provide/foundation/cli/utils.py +21 -17
- provide/foundation/config/__init__.py +35 -2
- provide/foundation/config/converters.py +479 -0
- provide/foundation/config/defaults.py +67 -0
- provide/foundation/config/env.py +4 -19
- provide/foundation/config/loader.py +9 -3
- provide/foundation/console/input.py +5 -5
- provide/foundation/console/output.py +35 -13
- provide/foundation/context/__init__.py +8 -4
- provide/foundation/context/core.py +85 -109
- provide/foundation/crypto/certificates/operations.py +1 -1
- provide/foundation/errors/__init__.py +2 -3
- provide/foundation/errors/decorators.py +0 -231
- provide/foundation/errors/types.py +0 -97
- provide/foundation/file/directory.py +13 -22
- provide/foundation/file/lock.py +3 -1
- provide/foundation/hub/components.py +72 -384
- provide/foundation/hub/config.py +151 -0
- provide/foundation/hub/discovery.py +62 -0
- provide/foundation/hub/handlers.py +81 -0
- provide/foundation/hub/lifecycle.py +194 -0
- provide/foundation/hub/manager.py +4 -4
- provide/foundation/hub/processors.py +44 -0
- provide/foundation/integrations/__init__.py +11 -0
- provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
- provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
- provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
- provide/foundation/integrations/openobserve/config.py +37 -0
- provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
- provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
- provide/foundation/logger/config/logging.py +68 -298
- provide/foundation/logger/config/telemetry.py +41 -121
- provide/foundation/logger/setup/coordinator.py +1 -1
- provide/foundation/observability/__init__.py +2 -2
- provide/foundation/process/__init__.py +9 -0
- provide/foundation/process/exit.py +47 -0
- provide/foundation/process/lifecycle.py +33 -33
- provide/foundation/resilience/__init__.py +35 -0
- provide/foundation/resilience/circuit.py +164 -0
- provide/foundation/resilience/decorators.py +220 -0
- provide/foundation/resilience/fallback.py +193 -0
- provide/foundation/resilience/retry.py +325 -0
- provide/foundation/streams/config.py +79 -0
- provide/foundation/streams/console.py +7 -8
- provide/foundation/streams/core.py +6 -3
- provide/foundation/streams/file.py +12 -2
- provide/foundation/testing/__init__.py +7 -2
- provide/foundation/testing/cli.py +30 -17
- provide/foundation/testing/common/__init__.py +0 -2
- provide/foundation/testing/common/fixtures.py +0 -27
- provide/foundation/testing/file/content_fixtures.py +316 -0
- provide/foundation/testing/file/directory_fixtures.py +107 -0
- provide/foundation/testing/file/fixtures.py +45 -516
- provide/foundation/testing/file/special_fixtures.py +153 -0
- provide/foundation/testing/logger.py +76 -0
- provide/foundation/testing/process/async_fixtures.py +405 -0
- provide/foundation/testing/process/fixtures.py +50 -571
- provide/foundation/testing/process/subprocess_fixtures.py +209 -0
- provide/foundation/testing/threading/basic_fixtures.py +101 -0
- provide/foundation/testing/threading/data_fixtures.py +99 -0
- provide/foundation/testing/threading/execution_fixtures.py +263 -0
- provide/foundation/testing/threading/fixtures.py +34 -500
- provide/foundation/testing/threading/sync_fixtures.py +97 -0
- provide/foundation/testing/time/fixtures.py +4 -4
- provide/foundation/tools/cache.py +8 -6
- provide/foundation/tools/downloader.py +23 -12
- provide/foundation/tracer/spans.py +2 -2
- provide/foundation/transport/config.py +26 -95
- provide/foundation/transport/middleware.py +30 -36
- provide/foundation/utils/deps.py +14 -12
- provide/foundation/utils/parsing.py +49 -4
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +1 -1
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/RECORD +93 -68
- /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
- /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {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.
|
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.
|
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
|
-
|
129
|
-
self.
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
150
|
+
plog.info(
|
151
|
+
"🚀 Managed process started successfully",
|
152
|
+
pid=self._process.pid,
|
153
|
+
command=" ".join(self.command),
|
154
|
+
)
|
150
155
|
|
151
|
-
|
152
|
-
|
153
|
-
|
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 =
|
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 =
|
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 =
|
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 =
|
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
|