provide-foundation 0.0.0.dev0__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/__init__.py +15 -0
- provide/foundation/__init__.py +155 -0
- provide/foundation/_version.py +58 -0
- provide/foundation/cli/__init__.py +67 -0
- provide/foundation/cli/commands/__init__.py +3 -0
- provide/foundation/cli/commands/deps.py +71 -0
- provide/foundation/cli/commands/logs/__init__.py +63 -0
- provide/foundation/cli/commands/logs/generate.py +357 -0
- provide/foundation/cli/commands/logs/generate_old.py +569 -0
- provide/foundation/cli/commands/logs/query.py +174 -0
- provide/foundation/cli/commands/logs/send.py +166 -0
- provide/foundation/cli/commands/logs/tail.py +112 -0
- provide/foundation/cli/decorators.py +262 -0
- provide/foundation/cli/main.py +65 -0
- provide/foundation/cli/testing.py +220 -0
- provide/foundation/cli/utils.py +210 -0
- provide/foundation/config/__init__.py +106 -0
- provide/foundation/config/base.py +295 -0
- provide/foundation/config/env.py +369 -0
- provide/foundation/config/loader.py +311 -0
- provide/foundation/config/manager.py +387 -0
- provide/foundation/config/schema.py +284 -0
- provide/foundation/config/sync.py +281 -0
- provide/foundation/config/types.py +78 -0
- provide/foundation/config/validators.py +80 -0
- provide/foundation/console/__init__.py +29 -0
- provide/foundation/console/input.py +364 -0
- provide/foundation/console/output.py +178 -0
- provide/foundation/context/__init__.py +12 -0
- provide/foundation/context/core.py +356 -0
- provide/foundation/core.py +20 -0
- provide/foundation/crypto/__init__.py +182 -0
- provide/foundation/crypto/algorithms.py +111 -0
- provide/foundation/crypto/certificates.py +896 -0
- provide/foundation/crypto/checksums.py +301 -0
- provide/foundation/crypto/constants.py +57 -0
- provide/foundation/crypto/hashing.py +265 -0
- provide/foundation/crypto/keys.py +188 -0
- provide/foundation/crypto/signatures.py +144 -0
- provide/foundation/crypto/utils.py +164 -0
- provide/foundation/errors/__init__.py +96 -0
- provide/foundation/errors/auth.py +73 -0
- provide/foundation/errors/base.py +81 -0
- provide/foundation/errors/config.py +103 -0
- provide/foundation/errors/context.py +299 -0
- provide/foundation/errors/decorators.py +484 -0
- provide/foundation/errors/handlers.py +360 -0
- provide/foundation/errors/integration.py +105 -0
- provide/foundation/errors/platform.py +37 -0
- provide/foundation/errors/process.py +140 -0
- provide/foundation/errors/resources.py +133 -0
- provide/foundation/errors/runtime.py +160 -0
- provide/foundation/errors/safe_decorators.py +133 -0
- provide/foundation/errors/types.py +276 -0
- provide/foundation/file/__init__.py +79 -0
- provide/foundation/file/atomic.py +157 -0
- provide/foundation/file/directory.py +134 -0
- provide/foundation/file/formats.py +236 -0
- provide/foundation/file/lock.py +175 -0
- provide/foundation/file/safe.py +179 -0
- provide/foundation/file/utils.py +170 -0
- provide/foundation/hub/__init__.py +88 -0
- provide/foundation/hub/click_builder.py +310 -0
- provide/foundation/hub/commands.py +42 -0
- provide/foundation/hub/components.py +640 -0
- provide/foundation/hub/decorators.py +244 -0
- provide/foundation/hub/info.py +32 -0
- provide/foundation/hub/manager.py +446 -0
- provide/foundation/hub/registry.py +279 -0
- provide/foundation/hub/type_mapping.py +54 -0
- provide/foundation/hub/types.py +28 -0
- provide/foundation/logger/__init__.py +41 -0
- provide/foundation/logger/base.py +22 -0
- provide/foundation/logger/config/__init__.py +16 -0
- provide/foundation/logger/config/base.py +40 -0
- provide/foundation/logger/config/logging.py +394 -0
- provide/foundation/logger/config/telemetry.py +188 -0
- provide/foundation/logger/core.py +239 -0
- provide/foundation/logger/custom_processors.py +172 -0
- provide/foundation/logger/emoji/__init__.py +44 -0
- provide/foundation/logger/emoji/matrix.py +209 -0
- provide/foundation/logger/emoji/sets.py +458 -0
- provide/foundation/logger/emoji/types.py +56 -0
- provide/foundation/logger/factories.py +56 -0
- provide/foundation/logger/processors/__init__.py +13 -0
- provide/foundation/logger/processors/main.py +254 -0
- provide/foundation/logger/processors/trace.py +113 -0
- provide/foundation/logger/ratelimit/__init__.py +31 -0
- provide/foundation/logger/ratelimit/limiters.py +294 -0
- provide/foundation/logger/ratelimit/processor.py +203 -0
- provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
- provide/foundation/logger/setup/__init__.py +29 -0
- provide/foundation/logger/setup/coordinator.py +138 -0
- provide/foundation/logger/setup/emoji_resolver.py +64 -0
- provide/foundation/logger/setup/processors.py +85 -0
- provide/foundation/logger/setup/testing.py +39 -0
- provide/foundation/logger/trace.py +38 -0
- provide/foundation/metrics/__init__.py +119 -0
- provide/foundation/metrics/otel.py +122 -0
- provide/foundation/metrics/simple.py +165 -0
- provide/foundation/observability/__init__.py +53 -0
- provide/foundation/observability/openobserve/__init__.py +79 -0
- provide/foundation/observability/openobserve/auth.py +72 -0
- provide/foundation/observability/openobserve/client.py +307 -0
- provide/foundation/observability/openobserve/commands.py +357 -0
- provide/foundation/observability/openobserve/exceptions.py +41 -0
- provide/foundation/observability/openobserve/formatters.py +298 -0
- provide/foundation/observability/openobserve/models.py +134 -0
- provide/foundation/observability/openobserve/otlp.py +320 -0
- provide/foundation/observability/openobserve/search.py +222 -0
- provide/foundation/observability/openobserve/streaming.py +235 -0
- provide/foundation/platform/__init__.py +44 -0
- provide/foundation/platform/detection.py +193 -0
- provide/foundation/platform/info.py +157 -0
- provide/foundation/process/__init__.py +39 -0
- provide/foundation/process/async_runner.py +373 -0
- provide/foundation/process/lifecycle.py +406 -0
- provide/foundation/process/runner.py +390 -0
- provide/foundation/setup/__init__.py +101 -0
- provide/foundation/streams/__init__.py +44 -0
- provide/foundation/streams/console.py +57 -0
- provide/foundation/streams/core.py +65 -0
- provide/foundation/streams/file.py +104 -0
- provide/foundation/testing/__init__.py +166 -0
- provide/foundation/testing/cli.py +227 -0
- provide/foundation/testing/crypto.py +163 -0
- provide/foundation/testing/fixtures.py +49 -0
- provide/foundation/testing/hub.py +23 -0
- provide/foundation/testing/logger.py +106 -0
- provide/foundation/testing/streams.py +54 -0
- provide/foundation/tracer/__init__.py +49 -0
- provide/foundation/tracer/context.py +115 -0
- provide/foundation/tracer/otel.py +135 -0
- provide/foundation/tracer/spans.py +174 -0
- provide/foundation/types.py +32 -0
- provide/foundation/utils/__init__.py +97 -0
- provide/foundation/utils/deps.py +195 -0
- provide/foundation/utils/env.py +491 -0
- provide/foundation/utils/formatting.py +483 -0
- provide/foundation/utils/parsing.py +235 -0
- provide/foundation/utils/rate_limiting.py +112 -0
- provide/foundation/utils/streams.py +67 -0
- provide/foundation/utils/timing.py +93 -0
- provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
- provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
- provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
- provide_foundation-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,484 @@
|
|
1
|
+
"""Decorators for error handling and resilience patterns.
|
2
|
+
|
3
|
+
Provides decorators for common error handling patterns like retry,
|
4
|
+
fallback, and error suppression.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from collections.abc import Callable
|
8
|
+
import functools
|
9
|
+
import inspect
|
10
|
+
import time
|
11
|
+
from typing import Any, TypeVar
|
12
|
+
|
13
|
+
from attrs import define, field
|
14
|
+
|
15
|
+
from provide.foundation.errors.base import FoundationError
|
16
|
+
from provide.foundation.errors.types import RetryPolicy
|
17
|
+
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
19
|
+
|
20
|
+
|
21
|
+
def _get_logger():
|
22
|
+
"""Get logger instance lazily to avoid circular imports."""
|
23
|
+
from provide.foundation.logger import logger
|
24
|
+
|
25
|
+
return logger
|
26
|
+
|
27
|
+
|
28
|
+
def with_error_handling(
|
29
|
+
func: F | None = None,
|
30
|
+
*,
|
31
|
+
fallback: Any = None,
|
32
|
+
log_errors: bool = True,
|
33
|
+
context_provider: Callable[[], dict[str, Any]] | None = None,
|
34
|
+
error_mapper: Callable[[Exception], Exception] | None = None,
|
35
|
+
suppress: tuple[type[Exception], ...] | None = None,
|
36
|
+
) -> Callable[[F], F] | F:
|
37
|
+
"""Decorator for automatic error handling with logging.
|
38
|
+
|
39
|
+
Args:
|
40
|
+
fallback: Value to return when an error occurs.
|
41
|
+
log_errors: Whether to log errors.
|
42
|
+
context_provider: Function that provides additional logging context.
|
43
|
+
error_mapper: Function to transform exceptions before re-raising.
|
44
|
+
suppress: Tuple of exception types to suppress (return fallback instead).
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
Decorated function.
|
48
|
+
|
49
|
+
Examples:
|
50
|
+
>>> @with_error_handling(fallback=None, suppress=(KeyError,))
|
51
|
+
... def get_value(data, key):
|
52
|
+
... return data[key]
|
53
|
+
|
54
|
+
>>> @with_error_handling(
|
55
|
+
... context_provider=lambda: {"request_id": get_request_id()}
|
56
|
+
... )
|
57
|
+
... def process_request():
|
58
|
+
... # errors will be logged with request_id
|
59
|
+
... pass
|
60
|
+
"""
|
61
|
+
|
62
|
+
def decorator(func: F) -> F:
|
63
|
+
if inspect.iscoroutinefunction(func):
|
64
|
+
|
65
|
+
@functools.wraps(func)
|
66
|
+
async def async_wrapper(*args, **kwargs):
|
67
|
+
try:
|
68
|
+
return await func(*args, **kwargs)
|
69
|
+
except Exception as e:
|
70
|
+
# Check if we should suppress this error
|
71
|
+
if suppress and isinstance(e, suppress):
|
72
|
+
if log_errors:
|
73
|
+
context = context_provider() if context_provider else {}
|
74
|
+
_get_logger().info(
|
75
|
+
f"Suppressed {type(e).__name__} in {func.__name__}",
|
76
|
+
function=func.__name__,
|
77
|
+
error=str(e),
|
78
|
+
**context,
|
79
|
+
)
|
80
|
+
return fallback
|
81
|
+
|
82
|
+
# Log the error if configured
|
83
|
+
if log_errors:
|
84
|
+
context = context_provider() if context_provider else {}
|
85
|
+
_get_logger().error(
|
86
|
+
f"Error in {func.__name__}: {e}",
|
87
|
+
exc_info=True,
|
88
|
+
function=func.__name__,
|
89
|
+
**context,
|
90
|
+
)
|
91
|
+
|
92
|
+
# Map the error if mapper provided
|
93
|
+
if error_mapper and not isinstance(e, FoundationError):
|
94
|
+
mapped = error_mapper(e)
|
95
|
+
if mapped is not e:
|
96
|
+
raise mapped from e
|
97
|
+
|
98
|
+
# Re-raise the original error
|
99
|
+
raise
|
100
|
+
|
101
|
+
return async_wrapper # type: ignore
|
102
|
+
else:
|
103
|
+
|
104
|
+
@functools.wraps(func)
|
105
|
+
def wrapper(*args, **kwargs):
|
106
|
+
try:
|
107
|
+
return func(*args, **kwargs)
|
108
|
+
except Exception as e:
|
109
|
+
# Check if we should suppress this error
|
110
|
+
if suppress and isinstance(e, suppress):
|
111
|
+
if log_errors:
|
112
|
+
context = context_provider() if context_provider else {}
|
113
|
+
_get_logger().info(
|
114
|
+
f"Suppressed {type(e).__name__} in {func.__name__}",
|
115
|
+
function=func.__name__,
|
116
|
+
error=str(e),
|
117
|
+
**context,
|
118
|
+
)
|
119
|
+
return fallback
|
120
|
+
|
121
|
+
# Log the error if configured
|
122
|
+
if log_errors:
|
123
|
+
context = context_provider() if context_provider else {}
|
124
|
+
_get_logger().error(
|
125
|
+
f"Error in {func.__name__}: {e}",
|
126
|
+
exc_info=True,
|
127
|
+
function=func.__name__,
|
128
|
+
**context,
|
129
|
+
)
|
130
|
+
|
131
|
+
# Map the error if mapper provided
|
132
|
+
if error_mapper and not isinstance(e, FoundationError):
|
133
|
+
mapped = error_mapper(e)
|
134
|
+
if mapped is not e:
|
135
|
+
raise mapped from e
|
136
|
+
|
137
|
+
# Re-raise the original error
|
138
|
+
raise
|
139
|
+
|
140
|
+
return wrapper # type: ignore
|
141
|
+
|
142
|
+
# Support both @with_error_handling and @with_error_handling(...) forms
|
143
|
+
if func is None:
|
144
|
+
# Called as @with_error_handling(...) with arguments
|
145
|
+
return decorator
|
146
|
+
else:
|
147
|
+
# Called as @with_error_handling (no parentheses)
|
148
|
+
return decorator(func)
|
149
|
+
|
150
|
+
|
151
|
+
def retry_on_error(
|
152
|
+
*exceptions: type[Exception],
|
153
|
+
policy: RetryPolicy | None = None,
|
154
|
+
max_attempts: int | None = None,
|
155
|
+
delay: float | None = None,
|
156
|
+
backoff: float | None = None,
|
157
|
+
on_retry: Callable[[int, Exception], None] | None = None,
|
158
|
+
) -> Callable[[F], F]:
|
159
|
+
"""Decorator for retrying operations on specific errors.
|
160
|
+
|
161
|
+
Args:
|
162
|
+
*exceptions: Exception types to retry on (all if empty).
|
163
|
+
policy: Complete retry policy (overrides other retry params).
|
164
|
+
max_attempts: Maximum retry attempts (ignored if policy provided).
|
165
|
+
delay: Base delay between retries in seconds.
|
166
|
+
backoff: Backoff multiplier for delays.
|
167
|
+
on_retry: Callback function called before each retry.
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
Decorated function.
|
171
|
+
|
172
|
+
Examples:
|
173
|
+
>>> @retry_on_error(ConnectionError, TimeoutError, max_attempts=3)
|
174
|
+
... def fetch_data():
|
175
|
+
... return api_call()
|
176
|
+
|
177
|
+
>>> @retry_on_error(
|
178
|
+
... policy=RetryPolicy(max_attempts=5, backoff="exponential")
|
179
|
+
... )
|
180
|
+
... def unreliable_operation():
|
181
|
+
... pass
|
182
|
+
"""
|
183
|
+
# Use provided policy or create one from parameters
|
184
|
+
if policy is None:
|
185
|
+
from provide.foundation.errors.types import BackoffStrategy
|
186
|
+
|
187
|
+
# Determine backoff strategy
|
188
|
+
if backoff is not None and backoff > 1:
|
189
|
+
backoff_strategy = BackoffStrategy.EXPONENTIAL
|
190
|
+
elif backoff == 1:
|
191
|
+
backoff_strategy = BackoffStrategy.FIXED
|
192
|
+
else:
|
193
|
+
backoff_strategy = BackoffStrategy.EXPONENTIAL
|
194
|
+
|
195
|
+
policy = RetryPolicy(
|
196
|
+
max_attempts=max_attempts or 3,
|
197
|
+
base_delay=delay or 1.0,
|
198
|
+
backoff=backoff_strategy,
|
199
|
+
retryable_errors=exceptions if exceptions else None,
|
200
|
+
)
|
201
|
+
|
202
|
+
def decorator(func: F) -> F:
|
203
|
+
@functools.wraps(func)
|
204
|
+
def wrapper(*args, **kwargs):
|
205
|
+
last_exception = None
|
206
|
+
|
207
|
+
for attempt in range(1, policy.max_attempts + 1):
|
208
|
+
try:
|
209
|
+
return func(*args, **kwargs)
|
210
|
+
except Exception as e:
|
211
|
+
last_exception = e
|
212
|
+
|
213
|
+
# Check if we should retry this error
|
214
|
+
if not policy.should_retry(e, attempt):
|
215
|
+
if attempt > 1: # Only log if we've actually retried
|
216
|
+
_get_logger().error(
|
217
|
+
f"All {attempt} retry attempts failed for {func.__name__}",
|
218
|
+
attempts=attempt,
|
219
|
+
error=str(e),
|
220
|
+
error_type=type(e).__name__,
|
221
|
+
)
|
222
|
+
raise
|
223
|
+
|
224
|
+
# Don't retry on last attempt
|
225
|
+
if attempt >= policy.max_attempts:
|
226
|
+
_get_logger().error(
|
227
|
+
f"All {policy.max_attempts} retry attempts failed for {func.__name__}",
|
228
|
+
attempts=policy.max_attempts,
|
229
|
+
error=str(e),
|
230
|
+
error_type=type(e).__name__,
|
231
|
+
)
|
232
|
+
raise
|
233
|
+
|
234
|
+
# Calculate delay
|
235
|
+
retry_delay = policy.calculate_delay(attempt)
|
236
|
+
|
237
|
+
# Log retry attempt
|
238
|
+
_get_logger().warning(
|
239
|
+
f"Retry {attempt}/{policy.max_attempts} for {func.__name__} after {retry_delay:.2f}s",
|
240
|
+
function=func.__name__,
|
241
|
+
attempt=attempt,
|
242
|
+
max_attempts=policy.max_attempts,
|
243
|
+
delay=retry_delay,
|
244
|
+
error=str(e),
|
245
|
+
error_type=type(e).__name__,
|
246
|
+
)
|
247
|
+
|
248
|
+
# Call retry callback if provided
|
249
|
+
if on_retry:
|
250
|
+
try:
|
251
|
+
on_retry(attempt, e)
|
252
|
+
except Exception as callback_error:
|
253
|
+
_get_logger().warning(
|
254
|
+
f"Retry callback failed: {callback_error}",
|
255
|
+
function=func.__name__,
|
256
|
+
attempt=attempt,
|
257
|
+
)
|
258
|
+
|
259
|
+
# Wait before retry
|
260
|
+
time.sleep(retry_delay)
|
261
|
+
|
262
|
+
# Should never reach here, but just in case
|
263
|
+
if last_exception:
|
264
|
+
raise last_exception
|
265
|
+
|
266
|
+
return wrapper # type: ignore
|
267
|
+
|
268
|
+
return decorator
|
269
|
+
|
270
|
+
|
271
|
+
def suppress_and_log(
|
272
|
+
*exceptions: type[Exception],
|
273
|
+
fallback: Any = None,
|
274
|
+
log_level: str = "warning",
|
275
|
+
) -> Callable[[F], F]:
|
276
|
+
"""Decorator to suppress specific exceptions and log them.
|
277
|
+
|
278
|
+
Args:
|
279
|
+
*exceptions: Exception types to suppress.
|
280
|
+
fallback: Value to return when exception is suppressed.
|
281
|
+
log_level: Log level to use ('debug', 'info', 'warning', 'error').
|
282
|
+
|
283
|
+
Returns:
|
284
|
+
Decorated function.
|
285
|
+
|
286
|
+
Examples:
|
287
|
+
>>> @suppress_and_log(KeyError, AttributeError, fallback={})
|
288
|
+
... def get_nested_value(data):
|
289
|
+
... return data["key"].attribute
|
290
|
+
"""
|
291
|
+
|
292
|
+
def decorator(func: F) -> F:
|
293
|
+
@functools.wraps(func)
|
294
|
+
def wrapper(*args, **kwargs):
|
295
|
+
try:
|
296
|
+
return func(*args, **kwargs)
|
297
|
+
except exceptions as e:
|
298
|
+
# Get appropriate log method
|
299
|
+
if log_level in ("debug", "info", "warning", "error", "critical"):
|
300
|
+
log_method = getattr(_get_logger(), log_level)
|
301
|
+
else:
|
302
|
+
log_method = _get_logger().warning
|
303
|
+
|
304
|
+
log_method(
|
305
|
+
f"Suppressed {type(e).__name__} in {func.__name__}: {e}",
|
306
|
+
function=func.__name__,
|
307
|
+
error_type=type(e).__name__,
|
308
|
+
error=str(e),
|
309
|
+
fallback=fallback,
|
310
|
+
)
|
311
|
+
|
312
|
+
return fallback
|
313
|
+
|
314
|
+
return wrapper # type: ignore
|
315
|
+
|
316
|
+
return decorator
|
317
|
+
|
318
|
+
|
319
|
+
def fallback_on_error(
|
320
|
+
fallback_func: Callable[..., Any],
|
321
|
+
*exceptions: type[Exception],
|
322
|
+
log_errors: bool = True,
|
323
|
+
) -> Callable[[F], F]:
|
324
|
+
"""Decorator to call a fallback function when errors occur.
|
325
|
+
|
326
|
+
Args:
|
327
|
+
fallback_func: Function to call when an error occurs.
|
328
|
+
*exceptions: Specific exception types to handle (all if empty).
|
329
|
+
log_errors: Whether to log errors before calling fallback.
|
330
|
+
|
331
|
+
Returns:
|
332
|
+
Decorated function.
|
333
|
+
|
334
|
+
Examples:
|
335
|
+
>>> def use_cache():
|
336
|
+
... return cached_value
|
337
|
+
...
|
338
|
+
>>> @fallback_on_error(use_cache, NetworkError)
|
339
|
+
... def fetch_from_api():
|
340
|
+
... return api_call()
|
341
|
+
"""
|
342
|
+
catch_types = exceptions if exceptions else (Exception,)
|
343
|
+
|
344
|
+
def decorator(func: F) -> F:
|
345
|
+
@functools.wraps(func)
|
346
|
+
def wrapper(*args, **kwargs):
|
347
|
+
try:
|
348
|
+
return func(*args, **kwargs)
|
349
|
+
except catch_types as e:
|
350
|
+
if log_errors:
|
351
|
+
_get_logger().warning(
|
352
|
+
f"Using fallback for {func.__name__} due to {type(e).__name__}",
|
353
|
+
function=func.__name__,
|
354
|
+
error_type=type(e).__name__,
|
355
|
+
error=str(e),
|
356
|
+
fallback=fallback_func.__name__,
|
357
|
+
)
|
358
|
+
|
359
|
+
# Call fallback with same arguments
|
360
|
+
try:
|
361
|
+
return fallback_func(*args, **kwargs)
|
362
|
+
except Exception as fallback_error:
|
363
|
+
_get_logger().error(
|
364
|
+
f"Fallback function {fallback_func.__name__} also failed",
|
365
|
+
exc_info=True,
|
366
|
+
original_error=str(e),
|
367
|
+
fallback_error=str(fallback_error),
|
368
|
+
)
|
369
|
+
# Re-raise the fallback error
|
370
|
+
raise fallback_error from e
|
371
|
+
|
372
|
+
return wrapper # type: ignore
|
373
|
+
|
374
|
+
return decorator
|
375
|
+
|
376
|
+
|
377
|
+
@define(kw_only=True, slots=True)
|
378
|
+
class CircuitBreaker:
|
379
|
+
"""Circuit breaker pattern for preventing cascading failures.
|
380
|
+
|
381
|
+
Attributes:
|
382
|
+
failure_threshold: Number of failures before opening circuit.
|
383
|
+
recovery_timeout: Seconds to wait before attempting recovery.
|
384
|
+
expected_exception: Exception types that trigger the breaker.
|
385
|
+
"""
|
386
|
+
|
387
|
+
failure_threshold: int = 5
|
388
|
+
recovery_timeout: float = 60.0
|
389
|
+
expected_exception: tuple[type[Exception], ...] = field(default=(Exception,))
|
390
|
+
|
391
|
+
# Internal state
|
392
|
+
_failure_count: int = field(init=False, default=0)
|
393
|
+
_last_failure_time: float | None = field(init=False, default=None)
|
394
|
+
_state: str = field(init=False, default="closed") # closed, open, half_open
|
395
|
+
|
396
|
+
def __call__(self, func: F) -> F:
|
397
|
+
"""Decorator to apply circuit breaker to a function."""
|
398
|
+
|
399
|
+
@functools.wraps(func)
|
400
|
+
def wrapper(*args, **kwargs):
|
401
|
+
# Check circuit state
|
402
|
+
if self._state == "open":
|
403
|
+
# Check if we should try half-open
|
404
|
+
if (
|
405
|
+
self._last_failure_time
|
406
|
+
and (time.time() - self._last_failure_time) > self.recovery_timeout
|
407
|
+
):
|
408
|
+
self._state = "half_open"
|
409
|
+
_get_logger().info(
|
410
|
+
f"Circuit breaker for {func.__name__} entering half-open state",
|
411
|
+
function=func.__name__,
|
412
|
+
)
|
413
|
+
else:
|
414
|
+
raise RuntimeError(f"Circuit breaker is open for {func.__name__}")
|
415
|
+
|
416
|
+
try:
|
417
|
+
result = func(*args, **kwargs)
|
418
|
+
|
419
|
+
# Success - reset on half-open or reduce failure count
|
420
|
+
if self._state == "half_open":
|
421
|
+
self._state = "closed"
|
422
|
+
self._failure_count = 0
|
423
|
+
_get_logger().info(
|
424
|
+
f"Circuit breaker for {func.__name__} closed after successful recovery",
|
425
|
+
function=func.__name__,
|
426
|
+
)
|
427
|
+
elif self._failure_count > 0:
|
428
|
+
self._failure_count = max(0, self._failure_count - 1)
|
429
|
+
|
430
|
+
return result
|
431
|
+
|
432
|
+
except self.expected_exception as e:
|
433
|
+
self._failure_count += 1
|
434
|
+
self._last_failure_time = time.time()
|
435
|
+
|
436
|
+
# Check if we should open the circuit
|
437
|
+
if self._failure_count >= self.failure_threshold:
|
438
|
+
self._state = "open"
|
439
|
+
_get_logger().error(
|
440
|
+
f"Circuit breaker for {func.__name__} opened after {self._failure_count} failures",
|
441
|
+
function=func.__name__,
|
442
|
+
failures=self._failure_count,
|
443
|
+
error=str(e),
|
444
|
+
)
|
445
|
+
else:
|
446
|
+
_get_logger().warning(
|
447
|
+
f"Circuit breaker for {func.__name__} failure {self._failure_count}/{self.failure_threshold}",
|
448
|
+
function=func.__name__,
|
449
|
+
failures=self._failure_count,
|
450
|
+
threshold=self.failure_threshold,
|
451
|
+
error=str(e),
|
452
|
+
)
|
453
|
+
|
454
|
+
raise
|
455
|
+
|
456
|
+
return wrapper # type: ignore
|
457
|
+
|
458
|
+
|
459
|
+
def circuit_breaker(
|
460
|
+
failure_threshold: int = 5,
|
461
|
+
recovery_timeout: float = 60.0,
|
462
|
+
expected_exception: tuple[type[Exception], ...] = (Exception,),
|
463
|
+
) -> Callable[[F], F]:
|
464
|
+
"""Create a circuit breaker decorator.
|
465
|
+
|
466
|
+
Args:
|
467
|
+
failure_threshold: Number of failures before opening circuit.
|
468
|
+
recovery_timeout: Seconds to wait before attempting recovery.
|
469
|
+
expected_exception: Exception types that trigger the breaker.
|
470
|
+
|
471
|
+
Returns:
|
472
|
+
Circuit breaker decorator.
|
473
|
+
|
474
|
+
Examples:
|
475
|
+
>>> @circuit_breaker(failure_threshold=3, recovery_timeout=30)
|
476
|
+
... def unreliable_service():
|
477
|
+
... return external_api_call()
|
478
|
+
"""
|
479
|
+
breaker = CircuitBreaker(
|
480
|
+
failure_threshold=failure_threshold,
|
481
|
+
recovery_timeout=recovery_timeout,
|
482
|
+
expected_exception=expected_exception,
|
483
|
+
)
|
484
|
+
return breaker
|