provide-foundation 0.0.0.dev1__py3-none-any.whl → 0.0.0.dev3__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 +36 -10
- provide/foundation/archive/__init__.py +1 -1
- provide/foundation/archive/base.py +15 -14
- provide/foundation/archive/bzip2.py +40 -40
- provide/foundation/archive/gzip.py +42 -42
- provide/foundation/archive/operations.py +93 -96
- provide/foundation/archive/tar.py +33 -31
- provide/foundation/archive/zip.py +52 -50
- provide/foundation/asynctools/__init__.py +20 -0
- provide/foundation/asynctools/core.py +126 -0
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +15 -9
- provide/foundation/cli/commands/logs/__init__.py +3 -3
- provide/foundation/cli/commands/logs/generate.py +2 -2
- provide/foundation/cli/commands/logs/query.py +4 -4
- provide/foundation/cli/commands/logs/send.py +3 -3
- provide/foundation/cli/commands/logs/tail.py +3 -3
- provide/foundation/cli/decorators.py +11 -11
- provide/foundation/cli/main.py +1 -1
- provide/foundation/cli/testing.py +2 -40
- provide/foundation/cli/utils.py +21 -18
- provide/foundation/config/__init__.py +35 -2
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/converters.py +477 -0
- provide/foundation/config/defaults.py +67 -0
- provide/foundation/config/env.py +6 -20
- provide/foundation/config/loader.py +10 -4
- provide/foundation/config/sync.py +8 -6
- provide/foundation/config/types.py +5 -5
- provide/foundation/config/validators.py +4 -4
- provide/foundation/console/input.py +5 -5
- provide/foundation/console/output.py +36 -14
- provide/foundation/context/__init__.py +8 -4
- provide/foundation/context/core.py +88 -110
- provide/foundation/crypto/certificates/__init__.py +9 -5
- provide/foundation/crypto/certificates/base.py +2 -2
- provide/foundation/crypto/certificates/certificate.py +48 -19
- provide/foundation/crypto/certificates/factory.py +26 -18
- provide/foundation/crypto/certificates/generator.py +24 -23
- provide/foundation/crypto/certificates/loader.py +24 -16
- provide/foundation/crypto/certificates/operations.py +17 -10
- provide/foundation/crypto/certificates/trust.py +21 -21
- provide/foundation/env/__init__.py +28 -0
- provide/foundation/env/core.py +218 -0
- provide/foundation/errors/__init__.py +3 -3
- provide/foundation/errors/decorators.py +0 -234
- provide/foundation/errors/types.py +0 -98
- provide/foundation/eventsets/display.py +13 -14
- provide/foundation/eventsets/registry.py +61 -31
- provide/foundation/eventsets/resolver.py +50 -46
- provide/foundation/eventsets/sets/das.py +8 -8
- provide/foundation/eventsets/sets/database.py +14 -14
- provide/foundation/eventsets/sets/http.py +21 -21
- provide/foundation/eventsets/sets/llm.py +16 -16
- provide/foundation/eventsets/sets/task_queue.py +13 -13
- provide/foundation/eventsets/types.py +7 -7
- provide/foundation/file/directory.py +14 -23
- provide/foundation/file/lock.py +4 -3
- provide/foundation/hub/components.py +75 -389
- provide/foundation/hub/config.py +157 -0
- provide/foundation/hub/discovery.py +63 -0
- provide/foundation/hub/handlers.py +89 -0
- provide/foundation/hub/lifecycle.py +195 -0
- provide/foundation/hub/manager.py +7 -4
- provide/foundation/hub/processors.py +49 -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 +14 -14
- provide/foundation/{observability → integrations}/openobserve/commands.py +12 -12
- provide/foundation/integrations/openobserve/config.py +37 -0
- provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/otlp.py +2 -2
- provide/foundation/{observability → integrations}/openobserve/search.py +2 -3
- provide/foundation/{observability → integrations}/openobserve/streaming.py +5 -5
- provide/foundation/logger/__init__.py +0 -1
- provide/foundation/logger/config/base.py +1 -1
- provide/foundation/logger/config/logging.py +69 -299
- provide/foundation/logger/config/telemetry.py +39 -121
- provide/foundation/logger/factories.py +2 -2
- provide/foundation/logger/processors/main.py +12 -10
- provide/foundation/logger/ratelimit/limiters.py +4 -4
- provide/foundation/logger/ratelimit/processor.py +1 -1
- provide/foundation/logger/setup/coordinator.py +39 -25
- provide/foundation/logger/setup/processors.py +3 -3
- provide/foundation/logger/setup/testing.py +14 -0
- provide/foundation/logger/trace.py +5 -5
- provide/foundation/metrics/__init__.py +1 -1
- provide/foundation/metrics/otel.py +3 -1
- provide/foundation/observability/__init__.py +3 -3
- provide/foundation/process/__init__.py +9 -0
- provide/foundation/process/exit.py +48 -0
- provide/foundation/process/lifecycle.py +69 -46
- provide/foundation/resilience/__init__.py +36 -0
- provide/foundation/resilience/circuit.py +166 -0
- provide/foundation/resilience/decorators.py +236 -0
- provide/foundation/resilience/fallback.py +208 -0
- provide/foundation/resilience/retry.py +327 -0
- provide/foundation/serialization/__init__.py +16 -0
- provide/foundation/serialization/core.py +70 -0
- provide/foundation/streams/config.py +78 -0
- provide/foundation/streams/console.py +4 -5
- provide/foundation/streams/core.py +5 -2
- provide/foundation/streams/file.py +12 -2
- provide/foundation/testing/__init__.py +29 -9
- provide/foundation/testing/archive/__init__.py +7 -7
- provide/foundation/testing/archive/fixtures.py +58 -54
- provide/foundation/testing/cli.py +30 -20
- provide/foundation/testing/common/__init__.py +13 -15
- provide/foundation/testing/common/fixtures.py +27 -57
- provide/foundation/testing/file/__init__.py +15 -15
- provide/foundation/testing/file/content_fixtures.py +289 -0
- provide/foundation/testing/file/directory_fixtures.py +107 -0
- provide/foundation/testing/file/fixtures.py +42 -516
- provide/foundation/testing/file/special_fixtures.py +145 -0
- provide/foundation/testing/logger.py +89 -8
- provide/foundation/testing/mocking/__init__.py +21 -21
- provide/foundation/testing/mocking/fixtures.py +80 -67
- provide/foundation/testing/process/__init__.py +23 -23
- provide/foundation/testing/process/async_fixtures.py +414 -0
- provide/foundation/testing/process/fixtures.py +48 -571
- provide/foundation/testing/process/subprocess_fixtures.py +210 -0
- provide/foundation/testing/threading/__init__.py +17 -17
- provide/foundation/testing/threading/basic_fixtures.py +105 -0
- provide/foundation/testing/threading/data_fixtures.py +101 -0
- provide/foundation/testing/threading/execution_fixtures.py +278 -0
- provide/foundation/testing/threading/fixtures.py +32 -502
- provide/foundation/testing/threading/sync_fixtures.py +100 -0
- provide/foundation/testing/time/__init__.py +11 -11
- provide/foundation/testing/time/fixtures.py +95 -83
- provide/foundation/testing/transport/__init__.py +9 -9
- provide/foundation/testing/transport/fixtures.py +54 -54
- provide/foundation/time/__init__.py +18 -0
- provide/foundation/time/core.py +63 -0
- provide/foundation/tools/__init__.py +2 -2
- provide/foundation/tools/base.py +68 -67
- provide/foundation/tools/cache.py +69 -74
- provide/foundation/tools/downloader.py +68 -62
- provide/foundation/tools/installer.py +51 -57
- provide/foundation/tools/registry.py +38 -45
- provide/foundation/tools/resolver.py +70 -68
- provide/foundation/tools/verifier.py +39 -50
- provide/foundation/tracer/spans.py +2 -14
- provide/foundation/transport/__init__.py +26 -33
- provide/foundation/transport/base.py +32 -30
- provide/foundation/transport/client.py +44 -49
- provide/foundation/transport/config.py +36 -107
- provide/foundation/transport/errors.py +13 -27
- provide/foundation/transport/http.py +69 -55
- provide/foundation/transport/middleware.py +113 -114
- provide/foundation/transport/registry.py +29 -27
- provide/foundation/transport/types.py +6 -6
- provide/foundation/utils/deps.py +17 -14
- provide/foundation/utils/parsing.py +49 -4
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/METADATA +2 -2
- provide_foundation-0.0.0.dev3.dist-info/RECORD +233 -0
- provide_foundation-0.0.0.dev1.dist-info/RECORD +0 -200
- /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.dev3.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
- {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/top_level.txt +0 -0
@@ -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_READCHAR_TIMEOUT,
|
21
|
+
DEFAULT_PROCESS_READLINE_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
|
|
@@ -69,13 +76,13 @@ class ManagedProcess:
|
|
69
76
|
|
70
77
|
# Build environment - always start with current environment
|
71
78
|
self._env = os.environ.copy()
|
72
|
-
|
79
|
+
|
73
80
|
# Clean coverage-related environment variables from subprocess
|
74
81
|
# to prevent interference with output capture during testing
|
75
82
|
for key in list(self._env.keys()):
|
76
|
-
if key.startswith((
|
83
|
+
if key.startswith(("COVERAGE", "COV_CORE")):
|
77
84
|
self._env.pop(key, None)
|
78
|
-
|
85
|
+
|
79
86
|
# Merge in any provided environment variables
|
80
87
|
if env:
|
81
88
|
self._env.update(env)
|
@@ -112,6 +119,11 @@ 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}")
|
124
|
+
if not isinstance(e, (ProcessError, RuntimeError))
|
125
|
+
else e
|
126
|
+
)
|
115
127
|
def launch(self) -> None:
|
116
128
|
"""
|
117
129
|
Launch the managed process.
|
@@ -125,37 +137,27 @@ class ManagedProcess:
|
|
125
137
|
|
126
138
|
plog.debug("🚀 Launching managed process", command=" ".join(self.command))
|
127
139
|
|
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
|
-
)
|
140
|
+
self._process = subprocess.Popen(
|
141
|
+
self.command,
|
142
|
+
cwd=self.cwd,
|
143
|
+
env=self._env,
|
144
|
+
stdout=subprocess.PIPE if self.capture_output else None,
|
145
|
+
stderr=subprocess.PIPE if self.capture_output else None,
|
146
|
+
text=self.text_mode,
|
147
|
+
bufsize=self.bufsize,
|
148
|
+
**self.kwargs,
|
149
|
+
)
|
150
|
+
self._started = True
|
146
151
|
|
147
|
-
|
148
|
-
|
149
|
-
|
152
|
+
plog.info(
|
153
|
+
"🚀 Managed process started successfully",
|
154
|
+
pid=self._process.pid,
|
155
|
+
command=" ".join(self.command),
|
156
|
+
)
|
150
157
|
|
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
|
158
|
+
# Start stderr relay if enabled
|
159
|
+
if self.stderr_relay and self._process.stderr:
|
160
|
+
self._start_stderr_relay()
|
159
161
|
|
160
162
|
def _start_stderr_relay(self) -> None:
|
161
163
|
"""Start a background thread to relay stderr output."""
|
@@ -186,7 +188,9 @@ class ManagedProcess:
|
|
186
188
|
self._stderr_thread.start()
|
187
189
|
plog.debug("🚀 Started stderr relay thread")
|
188
190
|
|
189
|
-
async def read_line_async(
|
191
|
+
async def read_line_async(
|
192
|
+
self, timeout: float = DEFAULT_PROCESS_READLINE_TIMEOUT
|
193
|
+
) -> str:
|
190
194
|
"""
|
191
195
|
Read a line from stdout asynchronously with timeout.
|
192
196
|
|
@@ -221,7 +225,9 @@ class ManagedProcess:
|
|
221
225
|
plog.debug("Read timeout on managed process stdout")
|
222
226
|
raise TimeoutError(f"Read timeout after {timeout}s") from e
|
223
227
|
|
224
|
-
async def read_char_async(
|
228
|
+
async def read_char_async(
|
229
|
+
self, timeout: float = DEFAULT_PROCESS_READCHAR_TIMEOUT
|
230
|
+
) -> str:
|
225
231
|
"""
|
226
232
|
Read a single character from stdout asynchronously.
|
227
233
|
|
@@ -258,7 +264,9 @@ class ManagedProcess:
|
|
258
264
|
plog.debug("Character read timeout on managed process stdout")
|
259
265
|
raise TimeoutError(f"Character read timeout after {timeout}s") from e
|
260
266
|
|
261
|
-
def terminate_gracefully(
|
267
|
+
def terminate_gracefully(
|
268
|
+
self, timeout: float = DEFAULT_PROCESS_TERMINATE_TIMEOUT
|
269
|
+
) -> bool:
|
262
270
|
"""
|
263
271
|
Terminate the process gracefully with a timeout.
|
264
272
|
|
@@ -340,7 +348,7 @@ class ManagedProcess:
|
|
340
348
|
async def wait_for_process_output(
|
341
349
|
process: ManagedProcess,
|
342
350
|
expected_parts: list[str],
|
343
|
-
timeout: float =
|
351
|
+
timeout: float = DEFAULT_PROCESS_WAIT_TIMEOUT,
|
344
352
|
buffer_size: int = 1024,
|
345
353
|
) -> str:
|
346
354
|
"""
|
@@ -378,7 +386,7 @@ async def wait_for_process_output(
|
|
378
386
|
if not process.is_running():
|
379
387
|
last_exit_code = process.returncode
|
380
388
|
plog.debug("Process exited", returncode=last_exit_code)
|
381
|
-
|
389
|
+
|
382
390
|
# Try to drain any remaining output from the pipes
|
383
391
|
if process._process and process._process.stdout:
|
384
392
|
try:
|
@@ -389,19 +397,26 @@ async def wait_for_process_output(
|
|
389
397
|
buffer += remaining.decode("utf-8", errors="replace")
|
390
398
|
else:
|
391
399
|
buffer += str(remaining)
|
392
|
-
plog.debug(
|
400
|
+
plog.debug(
|
401
|
+
"Read remaining output from exited process",
|
402
|
+
size=len(remaining),
|
403
|
+
)
|
393
404
|
except Exception:
|
394
405
|
pass
|
395
|
-
|
406
|
+
|
396
407
|
# Check buffer after draining
|
397
408
|
if all(part in buffer for part in expected_parts):
|
398
409
|
plog.debug("Found expected pattern after process exit")
|
399
410
|
return buffer
|
400
|
-
|
411
|
+
|
401
412
|
# If process exited and we don't have the pattern, fail
|
402
413
|
if last_exit_code is not None:
|
403
414
|
if last_exit_code != 0:
|
404
|
-
plog.error(
|
415
|
+
plog.error(
|
416
|
+
"Process exited with error",
|
417
|
+
returncode=last_exit_code,
|
418
|
+
buffer=buffer[:200],
|
419
|
+
)
|
405
420
|
raise ProcessError(f"Process exited with code {last_exit_code}")
|
406
421
|
else:
|
407
422
|
# For exit code 0, give it a small window to collect buffered output
|
@@ -412,7 +427,9 @@ async def wait_for_process_output(
|
|
412
427
|
remaining = process._process.stdout.read()
|
413
428
|
if remaining:
|
414
429
|
if isinstance(remaining, bytes):
|
415
|
-
buffer += remaining.decode(
|
430
|
+
buffer += remaining.decode(
|
431
|
+
"utf-8", errors="replace"
|
432
|
+
)
|
416
433
|
else:
|
417
434
|
buffer += str(remaining)
|
418
435
|
except Exception:
|
@@ -422,9 +439,15 @@ async def wait_for_process_output(
|
|
422
439
|
plog.debug("Found expected pattern after final drain")
|
423
440
|
return buffer
|
424
441
|
# Process exited cleanly but pattern not found
|
425
|
-
plog.error(
|
426
|
-
|
427
|
-
|
442
|
+
plog.error(
|
443
|
+
"Process exited without expected output",
|
444
|
+
returncode=0,
|
445
|
+
buffer=buffer[:200],
|
446
|
+
)
|
447
|
+
raise ProcessError(
|
448
|
+
f"Process exited with code {last_exit_code} before expected output found"
|
449
|
+
)
|
450
|
+
|
428
451
|
try:
|
429
452
|
# Try to read a line with short timeout
|
430
453
|
line = await process.read_line_async(timeout=0.1)
|
@@ -449,7 +472,7 @@ async def wait_for_process_output(
|
|
449
472
|
# Final check of buffer before timeout error
|
450
473
|
if all(part in buffer for part in expected_parts):
|
451
474
|
return buffer
|
452
|
-
|
475
|
+
|
453
476
|
# If process exited with 0 but we didn't get output, that's still a timeout
|
454
477
|
plog.error(
|
455
478
|
"Timeout waiting for pattern",
|
@@ -0,0 +1,36 @@
|
|
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 (
|
17
|
+
BackoffStrategy,
|
18
|
+
RetryExecutor,
|
19
|
+
RetryPolicy,
|
20
|
+
)
|
21
|
+
|
22
|
+
__all__ = [
|
23
|
+
# Core retry functionality
|
24
|
+
"RetryPolicy",
|
25
|
+
"RetryExecutor",
|
26
|
+
"BackoffStrategy",
|
27
|
+
# Circuit breaker
|
28
|
+
"CircuitBreaker",
|
29
|
+
"CircuitState",
|
30
|
+
# Fallback
|
31
|
+
"FallbackChain",
|
32
|
+
# Decorators
|
33
|
+
"retry",
|
34
|
+
"circuit_breaker",
|
35
|
+
"fallback",
|
36
|
+
]
|
@@ -0,0 +1,166 @@
|
|
1
|
+
"""
|
2
|
+
Circuit breaker implementation for preventing cascading failures.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from enum import Enum
|
6
|
+
import time
|
7
|
+
from typing import Callable, TypeVar
|
8
|
+
|
9
|
+
from attrs import define, field
|
10
|
+
|
11
|
+
from provide.foundation.logger import get_logger
|
12
|
+
|
13
|
+
logger = get_logger(__name__)
|
14
|
+
|
15
|
+
T = TypeVar("T")
|
16
|
+
|
17
|
+
|
18
|
+
class CircuitState(Enum):
|
19
|
+
"""Circuit breaker states."""
|
20
|
+
|
21
|
+
CLOSED = "closed" # Normal operation
|
22
|
+
OPEN = "open" # Circuit is open, failing fast
|
23
|
+
HALF_OPEN = "half_open" # Testing if service has recovered
|
24
|
+
|
25
|
+
|
26
|
+
@define(kw_only=True, slots=True)
|
27
|
+
class CircuitBreaker:
|
28
|
+
"""Circuit breaker pattern for preventing cascading failures.
|
29
|
+
|
30
|
+
Tracks failures and opens the circuit when threshold is exceeded.
|
31
|
+
Periodically allows test requests to check if service has recovered.
|
32
|
+
"""
|
33
|
+
|
34
|
+
failure_threshold: int = field(default=5)
|
35
|
+
recovery_timeout: float = field(default=60.0) # seconds
|
36
|
+
expected_exception: tuple[type[Exception], ...] = field(
|
37
|
+
factory=lambda: (Exception,)
|
38
|
+
)
|
39
|
+
|
40
|
+
# Internal state
|
41
|
+
_state: CircuitState = field(default=CircuitState.CLOSED, init=False)
|
42
|
+
_failure_count: int = field(default=0, init=False)
|
43
|
+
_last_failure_time: float | None = field(default=None, init=False)
|
44
|
+
_next_attempt_time: float = field(default=0.0, init=False)
|
45
|
+
|
46
|
+
@property
|
47
|
+
def state(self) -> CircuitState:
|
48
|
+
"""Current circuit breaker state."""
|
49
|
+
return self._state
|
50
|
+
|
51
|
+
@property
|
52
|
+
def failure_count(self) -> int:
|
53
|
+
"""Current failure count."""
|
54
|
+
return self._failure_count
|
55
|
+
|
56
|
+
def _should_attempt_reset(self) -> bool:
|
57
|
+
"""Check if we should attempt to reset the circuit."""
|
58
|
+
if self._state != CircuitState.OPEN:
|
59
|
+
return False
|
60
|
+
|
61
|
+
current_time = time.time()
|
62
|
+
return current_time >= self._next_attempt_time
|
63
|
+
|
64
|
+
def _record_success(self) -> None:
|
65
|
+
"""Record successful execution."""
|
66
|
+
if self._state == CircuitState.HALF_OPEN:
|
67
|
+
logger.info(
|
68
|
+
"Circuit breaker recovered - closing circuit",
|
69
|
+
state="half_open->closed",
|
70
|
+
failure_count=self._failure_count,
|
71
|
+
)
|
72
|
+
|
73
|
+
self._failure_count = 0
|
74
|
+
self._last_failure_time = None
|
75
|
+
self._state = CircuitState.CLOSED
|
76
|
+
|
77
|
+
def _record_failure(self, exception: Exception) -> None:
|
78
|
+
"""Record failed execution."""
|
79
|
+
self._failure_count += 1
|
80
|
+
self._last_failure_time = time.time()
|
81
|
+
|
82
|
+
if self._state == CircuitState.HALF_OPEN:
|
83
|
+
# Failed during recovery attempt
|
84
|
+
self._state = CircuitState.OPEN
|
85
|
+
self._next_attempt_time = self._last_failure_time + self.recovery_timeout
|
86
|
+
logger.warning(
|
87
|
+
"Circuit breaker recovery failed - opening circuit",
|
88
|
+
state="half_open->open",
|
89
|
+
failure_count=self._failure_count,
|
90
|
+
next_attempt_in=self.recovery_timeout,
|
91
|
+
)
|
92
|
+
elif self._failure_count >= self.failure_threshold:
|
93
|
+
# Threshold exceeded, open circuit
|
94
|
+
self._state = CircuitState.OPEN
|
95
|
+
self._next_attempt_time = self._last_failure_time + self.recovery_timeout
|
96
|
+
logger.error(
|
97
|
+
"Circuit breaker opened due to failures",
|
98
|
+
state="closed->open",
|
99
|
+
failure_count=self._failure_count,
|
100
|
+
failure_threshold=self.failure_threshold,
|
101
|
+
next_attempt_in=self.recovery_timeout,
|
102
|
+
)
|
103
|
+
|
104
|
+
def call(self, func: Callable[..., T], *args, **kwargs) -> T:
|
105
|
+
"""Execute function with circuit breaker protection (sync)."""
|
106
|
+
# Check if circuit is open
|
107
|
+
if self._state == CircuitState.OPEN:
|
108
|
+
if self._should_attempt_reset():
|
109
|
+
self._state = CircuitState.HALF_OPEN
|
110
|
+
logger.info(
|
111
|
+
"Circuit breaker attempting recovery",
|
112
|
+
state="open->half_open",
|
113
|
+
failure_count=self._failure_count,
|
114
|
+
)
|
115
|
+
else:
|
116
|
+
raise RuntimeError(
|
117
|
+
f"Circuit breaker is open. Next attempt in "
|
118
|
+
f"{self._next_attempt_time - time.time():.1f} seconds"
|
119
|
+
)
|
120
|
+
|
121
|
+
try:
|
122
|
+
result = func(*args, **kwargs)
|
123
|
+
self._record_success()
|
124
|
+
return result
|
125
|
+
except Exception as e:
|
126
|
+
if isinstance(e, self.expected_exception):
|
127
|
+
self._record_failure(e)
|
128
|
+
raise
|
129
|
+
|
130
|
+
async def call_async(self, func: Callable[..., T], *args, **kwargs) -> T:
|
131
|
+
"""Execute async function with circuit breaker protection."""
|
132
|
+
# Check if circuit is open
|
133
|
+
if self._state == CircuitState.OPEN:
|
134
|
+
if self._should_attempt_reset():
|
135
|
+
self._state = CircuitState.HALF_OPEN
|
136
|
+
logger.info(
|
137
|
+
"Circuit breaker attempting recovery",
|
138
|
+
state="open->half_open",
|
139
|
+
failure_count=self._failure_count,
|
140
|
+
)
|
141
|
+
else:
|
142
|
+
raise RuntimeError(
|
143
|
+
f"Circuit breaker is open. Next attempt in "
|
144
|
+
f"{self._next_attempt_time - time.time():.1f} seconds"
|
145
|
+
)
|
146
|
+
|
147
|
+
try:
|
148
|
+
result = await func(*args, **kwargs)
|
149
|
+
self._record_success()
|
150
|
+
return result
|
151
|
+
except Exception as e:
|
152
|
+
if isinstance(e, self.expected_exception):
|
153
|
+
self._record_failure(e)
|
154
|
+
raise
|
155
|
+
|
156
|
+
def reset(self) -> None:
|
157
|
+
"""Manually reset the circuit breaker."""
|
158
|
+
logger.info(
|
159
|
+
"Circuit breaker manually reset",
|
160
|
+
previous_state=self._state.value,
|
161
|
+
failure_count=self._failure_count,
|
162
|
+
)
|
163
|
+
self._state = CircuitState.CLOSED
|
164
|
+
self._failure_count = 0
|
165
|
+
self._last_failure_time = None
|
166
|
+
self._next_attempt_time = 0.0
|
@@ -0,0 +1,236 @@
|
|
1
|
+
"""
|
2
|
+
Resilience decorators for retry, circuit breaker, and fallback patterns.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import functools
|
7
|
+
from typing import Any, Callable, TypeVar
|
8
|
+
|
9
|
+
from provide.foundation.config.defaults import DEFAULT_CIRCUIT_BREAKER_RECOVERY_TIMEOUT
|
10
|
+
from provide.foundation.resilience.retry import (
|
11
|
+
BackoffStrategy,
|
12
|
+
RetryExecutor,
|
13
|
+
RetryPolicy,
|
14
|
+
)
|
15
|
+
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
17
|
+
|
18
|
+
|
19
|
+
def _get_logger():
|
20
|
+
"""Get logger instance lazily to avoid circular imports."""
|
21
|
+
from provide.foundation.logger import logger
|
22
|
+
|
23
|
+
return logger
|
24
|
+
|
25
|
+
|
26
|
+
def retry(
|
27
|
+
*exceptions: type[Exception],
|
28
|
+
policy: RetryPolicy | None = None,
|
29
|
+
max_attempts: int | None = None,
|
30
|
+
base_delay: float | None = None,
|
31
|
+
backoff: BackoffStrategy | None = None,
|
32
|
+
max_delay: float | None = None,
|
33
|
+
jitter: bool | None = None,
|
34
|
+
on_retry: Callable[[int, Exception], None] | None = None,
|
35
|
+
) -> Callable[[F], F]:
|
36
|
+
"""
|
37
|
+
Decorator for retrying operations on errors.
|
38
|
+
|
39
|
+
Can be used in multiple ways:
|
40
|
+
|
41
|
+
1. With a policy object:
|
42
|
+
@retry(policy=RetryPolicy(max_attempts=5))
|
43
|
+
|
44
|
+
2. With individual parameters:
|
45
|
+
@retry(max_attempts=3, base_delay=1.0)
|
46
|
+
|
47
|
+
3. With specific exceptions:
|
48
|
+
@retry(ConnectionError, TimeoutError, max_attempts=3)
|
49
|
+
|
50
|
+
4. Without parentheses (uses defaults):
|
51
|
+
@retry
|
52
|
+
def my_func(): ...
|
53
|
+
|
54
|
+
Args:
|
55
|
+
*exceptions: Exception types to retry (all if empty)
|
56
|
+
policy: Complete retry policy (overrides other params)
|
57
|
+
max_attempts: Maximum retry attempts
|
58
|
+
base_delay: Base delay between retries
|
59
|
+
backoff: Backoff strategy
|
60
|
+
max_delay: Maximum delay cap
|
61
|
+
jitter: Whether to add jitter
|
62
|
+
on_retry: Callback for retry events
|
63
|
+
|
64
|
+
Returns:
|
65
|
+
Decorated function with retry logic
|
66
|
+
|
67
|
+
Examples:
|
68
|
+
>>> @retry(max_attempts=3)
|
69
|
+
... def flaky_operation():
|
70
|
+
... # May fail occasionally
|
71
|
+
... pass
|
72
|
+
|
73
|
+
>>> @retry(ConnectionError, max_attempts=5, base_delay=2.0)
|
74
|
+
... async def connect_to_service():
|
75
|
+
... # Async function with specific error handling
|
76
|
+
... pass
|
77
|
+
"""
|
78
|
+
# Handle decorator without parentheses
|
79
|
+
if (
|
80
|
+
len(exceptions) == 1
|
81
|
+
and callable(exceptions[0])
|
82
|
+
and not isinstance(exceptions[0], type)
|
83
|
+
):
|
84
|
+
# Called as @retry without parentheses
|
85
|
+
func = exceptions[0]
|
86
|
+
executor = RetryExecutor(RetryPolicy())
|
87
|
+
|
88
|
+
if asyncio.iscoroutinefunction(func):
|
89
|
+
|
90
|
+
@functools.wraps(func)
|
91
|
+
async def async_wrapper(*args, **kwargs):
|
92
|
+
return await executor.execute_async(func, *args, **kwargs)
|
93
|
+
|
94
|
+
return async_wrapper
|
95
|
+
else:
|
96
|
+
|
97
|
+
@functools.wraps(func)
|
98
|
+
def sync_wrapper(*args, **kwargs):
|
99
|
+
return executor.execute_sync(func, *args, **kwargs)
|
100
|
+
|
101
|
+
return sync_wrapper
|
102
|
+
|
103
|
+
# Build policy if not provided
|
104
|
+
if policy is not None and any(
|
105
|
+
p is not None for p in [max_attempts, base_delay, backoff, max_delay, jitter]
|
106
|
+
):
|
107
|
+
raise ValueError("Cannot specify both policy and individual retry parameters")
|
108
|
+
|
109
|
+
if policy is None:
|
110
|
+
# Build policy from parameters
|
111
|
+
policy_kwargs = {}
|
112
|
+
|
113
|
+
if max_attempts is not None:
|
114
|
+
policy_kwargs["max_attempts"] = max_attempts
|
115
|
+
if base_delay is not None:
|
116
|
+
policy_kwargs["base_delay"] = base_delay
|
117
|
+
if backoff is not None:
|
118
|
+
policy_kwargs["backoff"] = backoff
|
119
|
+
if max_delay is not None:
|
120
|
+
policy_kwargs["max_delay"] = max_delay
|
121
|
+
if jitter is not None:
|
122
|
+
policy_kwargs["jitter"] = jitter
|
123
|
+
if exceptions:
|
124
|
+
policy_kwargs["retryable_errors"] = exceptions
|
125
|
+
|
126
|
+
policy = RetryPolicy(**policy_kwargs)
|
127
|
+
|
128
|
+
def decorator(func: F) -> F:
|
129
|
+
executor = RetryExecutor(policy, on_retry=on_retry)
|
130
|
+
|
131
|
+
if asyncio.iscoroutinefunction(func):
|
132
|
+
|
133
|
+
@functools.wraps(func)
|
134
|
+
async def async_wrapper(*args, **kwargs):
|
135
|
+
return await executor.execute_async(func, *args, **kwargs)
|
136
|
+
|
137
|
+
return async_wrapper
|
138
|
+
else:
|
139
|
+
|
140
|
+
@functools.wraps(func)
|
141
|
+
def sync_wrapper(*args, **kwargs):
|
142
|
+
return executor.execute_sync(func, *args, **kwargs)
|
143
|
+
|
144
|
+
return sync_wrapper
|
145
|
+
|
146
|
+
return decorator
|
147
|
+
|
148
|
+
|
149
|
+
# Import CircuitBreaker from circuit module
|
150
|
+
from provide.foundation.resilience.circuit import CircuitBreaker
|
151
|
+
|
152
|
+
|
153
|
+
def circuit_breaker(
|
154
|
+
failure_threshold: int = 5,
|
155
|
+
recovery_timeout: float = DEFAULT_CIRCUIT_BREAKER_RECOVERY_TIMEOUT,
|
156
|
+
expected_exception: tuple[type[Exception], ...] = (Exception,),
|
157
|
+
) -> Callable[[F], F]:
|
158
|
+
"""Create a circuit breaker decorator.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
failure_threshold: Number of failures before opening circuit.
|
162
|
+
recovery_timeout: Seconds to wait before attempting recovery.
|
163
|
+
expected_exception: Exception types that trigger the breaker.
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
Circuit breaker decorator.
|
167
|
+
|
168
|
+
Examples:
|
169
|
+
>>> @circuit_breaker(failure_threshold=3, recovery_timeout=30)
|
170
|
+
... def unreliable_service():
|
171
|
+
... return external_api_call()
|
172
|
+
"""
|
173
|
+
breaker = CircuitBreaker(
|
174
|
+
failure_threshold=failure_threshold,
|
175
|
+
recovery_timeout=recovery_timeout,
|
176
|
+
expected_exception=expected_exception,
|
177
|
+
)
|
178
|
+
|
179
|
+
def decorator(func: F) -> F:
|
180
|
+
@functools.wraps(func)
|
181
|
+
def sync_wrapper(*args, **kwargs):
|
182
|
+
return breaker.call(func, *args, **kwargs)
|
183
|
+
|
184
|
+
@functools.wraps(func)
|
185
|
+
async def async_wrapper(*args, **kwargs):
|
186
|
+
return await breaker.call_async(func, *args, **kwargs)
|
187
|
+
|
188
|
+
if asyncio.iscoroutinefunction(func):
|
189
|
+
return async_wrapper # type: ignore
|
190
|
+
else:
|
191
|
+
return sync_wrapper # type: ignore
|
192
|
+
|
193
|
+
return decorator
|
194
|
+
|
195
|
+
|
196
|
+
def fallback(*fallback_funcs: Callable[..., Any]) -> Callable[[F], F]:
|
197
|
+
"""
|
198
|
+
Fallback decorator using FallbackChain.
|
199
|
+
|
200
|
+
Args:
|
201
|
+
*fallback_funcs: Functions to use as fallbacks, in order of preference
|
202
|
+
|
203
|
+
Returns:
|
204
|
+
Decorated function with fallback chain
|
205
|
+
|
206
|
+
Examples:
|
207
|
+
>>> def backup_api():
|
208
|
+
... return "backup result"
|
209
|
+
...
|
210
|
+
>>> @fallback(backup_api)
|
211
|
+
... def primary_api():
|
212
|
+
... return external_api_call()
|
213
|
+
"""
|
214
|
+
from provide.foundation.resilience.fallback import FallbackChain
|
215
|
+
|
216
|
+
def decorator(func: F) -> F:
|
217
|
+
chain = FallbackChain()
|
218
|
+
for fallback_func in fallback_funcs:
|
219
|
+
chain.add_fallback(fallback_func)
|
220
|
+
|
221
|
+
if asyncio.iscoroutinefunction(func):
|
222
|
+
|
223
|
+
@functools.wraps(func)
|
224
|
+
async def async_wrapper(*args, **kwargs):
|
225
|
+
return await chain.execute_async(func, *args, **kwargs)
|
226
|
+
|
227
|
+
return async_wrapper # type: ignore
|
228
|
+
else:
|
229
|
+
|
230
|
+
@functools.wraps(func)
|
231
|
+
def sync_wrapper(*args, **kwargs):
|
232
|
+
return chain.execute(func, *args, **kwargs)
|
233
|
+
|
234
|
+
return sync_wrapper # type: ignore
|
235
|
+
|
236
|
+
return decorator
|