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.
Files changed (163) hide show
  1. provide/foundation/__init__.py +36 -10
  2. provide/foundation/archive/__init__.py +1 -1
  3. provide/foundation/archive/base.py +15 -14
  4. provide/foundation/archive/bzip2.py +40 -40
  5. provide/foundation/archive/gzip.py +42 -42
  6. provide/foundation/archive/operations.py +93 -96
  7. provide/foundation/archive/tar.py +33 -31
  8. provide/foundation/archive/zip.py +52 -50
  9. provide/foundation/asynctools/__init__.py +20 -0
  10. provide/foundation/asynctools/core.py +126 -0
  11. provide/foundation/cli/__init__.py +2 -2
  12. provide/foundation/cli/commands/deps.py +15 -9
  13. provide/foundation/cli/commands/logs/__init__.py +3 -3
  14. provide/foundation/cli/commands/logs/generate.py +2 -2
  15. provide/foundation/cli/commands/logs/query.py +4 -4
  16. provide/foundation/cli/commands/logs/send.py +3 -3
  17. provide/foundation/cli/commands/logs/tail.py +3 -3
  18. provide/foundation/cli/decorators.py +11 -11
  19. provide/foundation/cli/main.py +1 -1
  20. provide/foundation/cli/testing.py +2 -40
  21. provide/foundation/cli/utils.py +21 -18
  22. provide/foundation/config/__init__.py +35 -2
  23. provide/foundation/config/base.py +2 -2
  24. provide/foundation/config/converters.py +477 -0
  25. provide/foundation/config/defaults.py +67 -0
  26. provide/foundation/config/env.py +6 -20
  27. provide/foundation/config/loader.py +10 -4
  28. provide/foundation/config/sync.py +8 -6
  29. provide/foundation/config/types.py +5 -5
  30. provide/foundation/config/validators.py +4 -4
  31. provide/foundation/console/input.py +5 -5
  32. provide/foundation/console/output.py +36 -14
  33. provide/foundation/context/__init__.py +8 -4
  34. provide/foundation/context/core.py +88 -110
  35. provide/foundation/crypto/certificates/__init__.py +9 -5
  36. provide/foundation/crypto/certificates/base.py +2 -2
  37. provide/foundation/crypto/certificates/certificate.py +48 -19
  38. provide/foundation/crypto/certificates/factory.py +26 -18
  39. provide/foundation/crypto/certificates/generator.py +24 -23
  40. provide/foundation/crypto/certificates/loader.py +24 -16
  41. provide/foundation/crypto/certificates/operations.py +17 -10
  42. provide/foundation/crypto/certificates/trust.py +21 -21
  43. provide/foundation/env/__init__.py +28 -0
  44. provide/foundation/env/core.py +218 -0
  45. provide/foundation/errors/__init__.py +3 -3
  46. provide/foundation/errors/decorators.py +0 -234
  47. provide/foundation/errors/types.py +0 -98
  48. provide/foundation/eventsets/display.py +13 -14
  49. provide/foundation/eventsets/registry.py +61 -31
  50. provide/foundation/eventsets/resolver.py +50 -46
  51. provide/foundation/eventsets/sets/das.py +8 -8
  52. provide/foundation/eventsets/sets/database.py +14 -14
  53. provide/foundation/eventsets/sets/http.py +21 -21
  54. provide/foundation/eventsets/sets/llm.py +16 -16
  55. provide/foundation/eventsets/sets/task_queue.py +13 -13
  56. provide/foundation/eventsets/types.py +7 -7
  57. provide/foundation/file/directory.py +14 -23
  58. provide/foundation/file/lock.py +4 -3
  59. provide/foundation/hub/components.py +75 -389
  60. provide/foundation/hub/config.py +157 -0
  61. provide/foundation/hub/discovery.py +63 -0
  62. provide/foundation/hub/handlers.py +89 -0
  63. provide/foundation/hub/lifecycle.py +195 -0
  64. provide/foundation/hub/manager.py +7 -4
  65. provide/foundation/hub/processors.py +49 -0
  66. provide/foundation/integrations/__init__.py +11 -0
  67. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  68. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  69. provide/foundation/{observability → integrations}/openobserve/client.py +14 -14
  70. provide/foundation/{observability → integrations}/openobserve/commands.py +12 -12
  71. provide/foundation/integrations/openobserve/config.py +37 -0
  72. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  73. provide/foundation/{observability → integrations}/openobserve/otlp.py +2 -2
  74. provide/foundation/{observability → integrations}/openobserve/search.py +2 -3
  75. provide/foundation/{observability → integrations}/openobserve/streaming.py +5 -5
  76. provide/foundation/logger/__init__.py +0 -1
  77. provide/foundation/logger/config/base.py +1 -1
  78. provide/foundation/logger/config/logging.py +69 -299
  79. provide/foundation/logger/config/telemetry.py +39 -121
  80. provide/foundation/logger/factories.py +2 -2
  81. provide/foundation/logger/processors/main.py +12 -10
  82. provide/foundation/logger/ratelimit/limiters.py +4 -4
  83. provide/foundation/logger/ratelimit/processor.py +1 -1
  84. provide/foundation/logger/setup/coordinator.py +39 -25
  85. provide/foundation/logger/setup/processors.py +3 -3
  86. provide/foundation/logger/setup/testing.py +14 -0
  87. provide/foundation/logger/trace.py +5 -5
  88. provide/foundation/metrics/__init__.py +1 -1
  89. provide/foundation/metrics/otel.py +3 -1
  90. provide/foundation/observability/__init__.py +3 -3
  91. provide/foundation/process/__init__.py +9 -0
  92. provide/foundation/process/exit.py +48 -0
  93. provide/foundation/process/lifecycle.py +69 -46
  94. provide/foundation/resilience/__init__.py +36 -0
  95. provide/foundation/resilience/circuit.py +166 -0
  96. provide/foundation/resilience/decorators.py +236 -0
  97. provide/foundation/resilience/fallback.py +208 -0
  98. provide/foundation/resilience/retry.py +327 -0
  99. provide/foundation/serialization/__init__.py +16 -0
  100. provide/foundation/serialization/core.py +70 -0
  101. provide/foundation/streams/config.py +78 -0
  102. provide/foundation/streams/console.py +4 -5
  103. provide/foundation/streams/core.py +5 -2
  104. provide/foundation/streams/file.py +12 -2
  105. provide/foundation/testing/__init__.py +29 -9
  106. provide/foundation/testing/archive/__init__.py +7 -7
  107. provide/foundation/testing/archive/fixtures.py +58 -54
  108. provide/foundation/testing/cli.py +30 -20
  109. provide/foundation/testing/common/__init__.py +13 -15
  110. provide/foundation/testing/common/fixtures.py +27 -57
  111. provide/foundation/testing/file/__init__.py +15 -15
  112. provide/foundation/testing/file/content_fixtures.py +289 -0
  113. provide/foundation/testing/file/directory_fixtures.py +107 -0
  114. provide/foundation/testing/file/fixtures.py +42 -516
  115. provide/foundation/testing/file/special_fixtures.py +145 -0
  116. provide/foundation/testing/logger.py +89 -8
  117. provide/foundation/testing/mocking/__init__.py +21 -21
  118. provide/foundation/testing/mocking/fixtures.py +80 -67
  119. provide/foundation/testing/process/__init__.py +23 -23
  120. provide/foundation/testing/process/async_fixtures.py +414 -0
  121. provide/foundation/testing/process/fixtures.py +48 -571
  122. provide/foundation/testing/process/subprocess_fixtures.py +210 -0
  123. provide/foundation/testing/threading/__init__.py +17 -17
  124. provide/foundation/testing/threading/basic_fixtures.py +105 -0
  125. provide/foundation/testing/threading/data_fixtures.py +101 -0
  126. provide/foundation/testing/threading/execution_fixtures.py +278 -0
  127. provide/foundation/testing/threading/fixtures.py +32 -502
  128. provide/foundation/testing/threading/sync_fixtures.py +100 -0
  129. provide/foundation/testing/time/__init__.py +11 -11
  130. provide/foundation/testing/time/fixtures.py +95 -83
  131. provide/foundation/testing/transport/__init__.py +9 -9
  132. provide/foundation/testing/transport/fixtures.py +54 -54
  133. provide/foundation/time/__init__.py +18 -0
  134. provide/foundation/time/core.py +63 -0
  135. provide/foundation/tools/__init__.py +2 -2
  136. provide/foundation/tools/base.py +68 -67
  137. provide/foundation/tools/cache.py +69 -74
  138. provide/foundation/tools/downloader.py +68 -62
  139. provide/foundation/tools/installer.py +51 -57
  140. provide/foundation/tools/registry.py +38 -45
  141. provide/foundation/tools/resolver.py +70 -68
  142. provide/foundation/tools/verifier.py +39 -50
  143. provide/foundation/tracer/spans.py +2 -14
  144. provide/foundation/transport/__init__.py +26 -33
  145. provide/foundation/transport/base.py +32 -30
  146. provide/foundation/transport/client.py +44 -49
  147. provide/foundation/transport/config.py +36 -107
  148. provide/foundation/transport/errors.py +13 -27
  149. provide/foundation/transport/http.py +69 -55
  150. provide/foundation/transport/middleware.py +113 -114
  151. provide/foundation/transport/registry.py +29 -27
  152. provide/foundation/transport/types.py +6 -6
  153. provide/foundation/utils/deps.py +17 -14
  154. provide/foundation/utils/parsing.py +49 -4
  155. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/METADATA +2 -2
  156. provide_foundation-0.0.0.dev3.dist-info/RECORD +233 -0
  157. provide_foundation-0.0.0.dev1.dist-info/RECORD +0 -200
  158. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  159. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  160. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/WHEEL +0 -0
  161. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/entry_points.txt +0 -0
  162. {provide_foundation-0.0.0.dev1.dist-info → provide_foundation-0.0.0.dev3.dist-info}/licenses/LICENSE +0 -0
  163. {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(('COVERAGE', 'COV_CORE')):
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
- 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
- )
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
- # Start stderr relay if enabled
148
- if self.stderr_relay and self._process.stderr:
149
- self._start_stderr_relay()
152
+ plog.info(
153
+ "🚀 Managed process started successfully",
154
+ pid=self._process.pid,
155
+ command=" ".join(self.command),
156
+ )
150
157
 
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
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(self, timeout: float = 2.0) -> str:
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(self, timeout: float = 1.0) -> str:
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(self, timeout: float = 7.0) -> bool:
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 = 10.0,
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("Read remaining output from exited process", size=len(remaining))
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("Process exited with error", returncode=last_exit_code, buffer=buffer[:200])
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("utf-8", errors="replace")
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("Process exited without expected output", returncode=0, buffer=buffer[:200])
426
- raise ProcessError(f"Process exited with code {last_exit_code} before expected output found")
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