runtime-memory 3.0.0__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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Where Runtime Memory keeps its files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
STORE_DIR_NAME = ".runtime-memory"
|
|
8
|
+
LEGACY_STORE_DIR_NAME = ".memory-layer"
|
|
9
|
+
"""Store directory used before the 3.0 rename."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def store_dir() -> Path:
|
|
13
|
+
"""The directory holding the database, cache and extracted context.
|
|
14
|
+
|
|
15
|
+
Falls back to the pre-3.0 directory when it exists and the current one does
|
|
16
|
+
not, so an install that predates the rename keeps reading the memories it
|
|
17
|
+
already has instead of quietly starting an empty store.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Directory path. It is not created here.
|
|
21
|
+
"""
|
|
22
|
+
current = Path.home() / STORE_DIR_NAME
|
|
23
|
+
if not current.exists() and (Path.home() / LEGACY_STORE_DIR_NAME).exists():
|
|
24
|
+
return Path.home() / LEGACY_STORE_DIR_NAME
|
|
25
|
+
return current
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_db_path() -> Path:
|
|
29
|
+
"""The database used when nothing names another one."""
|
|
30
|
+
return store_dir() / "memories.db"
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resilience utilities for Runtime Memory.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Retry with exponential backoff
|
|
6
|
+
- Circuit breaker pattern
|
|
7
|
+
- Graceful degradation helpers
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, ParamSpec, TypeVar
|
|
19
|
+
|
|
20
|
+
from .exceptions import CircuitOpenError, is_recoverable
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
P = ParamSpec("P")
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =============================================================================
|
|
29
|
+
# Retry with Exponential Backoff
|
|
30
|
+
# =============================================================================
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RetryConfig:
|
|
35
|
+
"""Configuration for retry behavior."""
|
|
36
|
+
|
|
37
|
+
max_attempts: int = 3
|
|
38
|
+
base_delay: float = 1.0 # seconds
|
|
39
|
+
max_delay: float = 60.0 # seconds
|
|
40
|
+
exponential_base: float = 2.0
|
|
41
|
+
jitter: bool = True # Add randomness to prevent thundering herd
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calculate_backoff(
|
|
45
|
+
attempt: int,
|
|
46
|
+
config: RetryConfig,
|
|
47
|
+
) -> float:
|
|
48
|
+
"""Calculate delay for a retry attempt.
|
|
49
|
+
|
|
50
|
+
Uses exponential backoff with optional jitter.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
attempt: The current attempt number (0-indexed)
|
|
54
|
+
config: Retry configuration
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Delay in seconds before the next retry
|
|
58
|
+
"""
|
|
59
|
+
delay = config.base_delay * (config.exponential_base**attempt)
|
|
60
|
+
delay = min(delay, config.max_delay)
|
|
61
|
+
|
|
62
|
+
if config.jitter:
|
|
63
|
+
# Add up to 25% jitter
|
|
64
|
+
jitter_range = delay * 0.25
|
|
65
|
+
delay += random.uniform(-jitter_range, jitter_range)
|
|
66
|
+
|
|
67
|
+
return max(0, delay)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def retry(
|
|
71
|
+
max_attempts: int = 3,
|
|
72
|
+
base_delay: float = 1.0,
|
|
73
|
+
max_delay: float = 60.0,
|
|
74
|
+
exceptions: tuple[type[Exception], ...] | None = None,
|
|
75
|
+
on_retry: Callable[[Exception, int], None] | None = None,
|
|
76
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
77
|
+
"""Decorator for retrying async functions with exponential backoff.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
max_attempts: Maximum number of attempts
|
|
81
|
+
base_delay: Initial delay between retries (seconds)
|
|
82
|
+
max_delay: Maximum delay between retries (seconds)
|
|
83
|
+
exceptions: Tuple of exception types to retry on. If None, retries
|
|
84
|
+
on all recoverable exceptions.
|
|
85
|
+
on_retry: Optional callback called before each retry with
|
|
86
|
+
(exception, attempt_number)
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
@retry(max_attempts=3, base_delay=1.0)
|
|
90
|
+
async def fetch_data():
|
|
91
|
+
...
|
|
92
|
+
"""
|
|
93
|
+
config = RetryConfig(
|
|
94
|
+
max_attempts=max_attempts,
|
|
95
|
+
base_delay=base_delay,
|
|
96
|
+
max_delay=max_delay,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
100
|
+
@functools.wraps(func)
|
|
101
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
102
|
+
last_exception: Exception | None = None
|
|
103
|
+
|
|
104
|
+
for attempt in range(config.max_attempts):
|
|
105
|
+
try:
|
|
106
|
+
return await func(*args, **kwargs)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
last_exception = e
|
|
109
|
+
|
|
110
|
+
# Check if we should retry this exception
|
|
111
|
+
should_retry = False
|
|
112
|
+
if exceptions is not None:
|
|
113
|
+
should_retry = isinstance(e, exceptions)
|
|
114
|
+
else:
|
|
115
|
+
should_retry = is_recoverable(e)
|
|
116
|
+
|
|
117
|
+
if not should_retry or attempt >= config.max_attempts - 1:
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
# Calculate backoff delay
|
|
121
|
+
delay = calculate_backoff(attempt, config)
|
|
122
|
+
|
|
123
|
+
logger.warning(
|
|
124
|
+
f"Attempt {attempt + 1}/{config.max_attempts} failed "
|
|
125
|
+
f"for {func.__name__}: {e}. Retrying in {delay:.2f}s..."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if on_retry:
|
|
129
|
+
on_retry(e, attempt + 1)
|
|
130
|
+
|
|
131
|
+
await asyncio.sleep(delay)
|
|
132
|
+
|
|
133
|
+
# Should never reach here, but just in case
|
|
134
|
+
if last_exception:
|
|
135
|
+
raise last_exception
|
|
136
|
+
raise RuntimeError("Retry logic error")
|
|
137
|
+
|
|
138
|
+
return wrapper
|
|
139
|
+
|
|
140
|
+
return decorator
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def retry_sync(
|
|
144
|
+
max_attempts: int = 3,
|
|
145
|
+
base_delay: float = 1.0,
|
|
146
|
+
max_delay: float = 60.0,
|
|
147
|
+
exceptions: tuple[type[Exception], ...] | None = None,
|
|
148
|
+
on_retry: Callable[[Exception, int], None] | None = None,
|
|
149
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
150
|
+
"""Decorator for retrying sync functions with exponential backoff.
|
|
151
|
+
|
|
152
|
+
Same as retry() but for synchronous functions.
|
|
153
|
+
"""
|
|
154
|
+
config = RetryConfig(
|
|
155
|
+
max_attempts=max_attempts,
|
|
156
|
+
base_delay=base_delay,
|
|
157
|
+
max_delay=max_delay,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
161
|
+
@functools.wraps(func)
|
|
162
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
163
|
+
last_exception: Exception | None = None
|
|
164
|
+
|
|
165
|
+
for attempt in range(config.max_attempts):
|
|
166
|
+
try:
|
|
167
|
+
return func(*args, **kwargs)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
last_exception = e
|
|
170
|
+
|
|
171
|
+
should_retry = False
|
|
172
|
+
if exceptions is not None:
|
|
173
|
+
should_retry = isinstance(e, exceptions)
|
|
174
|
+
else:
|
|
175
|
+
should_retry = is_recoverable(e)
|
|
176
|
+
|
|
177
|
+
if not should_retry or attempt >= config.max_attempts - 1:
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
delay = calculate_backoff(attempt, config)
|
|
181
|
+
|
|
182
|
+
logger.warning(
|
|
183
|
+
f"Attempt {attempt + 1}/{config.max_attempts} failed "
|
|
184
|
+
f"for {func.__name__}: {e}. Retrying in {delay:.2f}s..."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if on_retry:
|
|
188
|
+
on_retry(e, attempt + 1)
|
|
189
|
+
|
|
190
|
+
time.sleep(delay)
|
|
191
|
+
|
|
192
|
+
if last_exception:
|
|
193
|
+
raise last_exception
|
|
194
|
+
raise RuntimeError("Retry logic error")
|
|
195
|
+
|
|
196
|
+
return wrapper
|
|
197
|
+
|
|
198
|
+
return decorator
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# =============================================================================
|
|
202
|
+
# Circuit Breaker
|
|
203
|
+
# =============================================================================
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CircuitState(Enum):
|
|
207
|
+
"""Circuit breaker states."""
|
|
208
|
+
|
|
209
|
+
CLOSED = "closed" # Normal operation
|
|
210
|
+
OPEN = "open" # Failing, rejecting requests
|
|
211
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class CircuitBreakerConfig:
|
|
216
|
+
"""Configuration for circuit breaker."""
|
|
217
|
+
|
|
218
|
+
failure_threshold: int = 5 # Failures before opening
|
|
219
|
+
success_threshold: int = 2 # Successes to close from half-open
|
|
220
|
+
timeout: float = 60.0 # Seconds before trying half-open
|
|
221
|
+
excluded_exceptions: tuple[type[Exception], ...] = () # Don't count these
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class CircuitBreaker:
|
|
226
|
+
"""Circuit breaker for protecting external service calls.
|
|
227
|
+
|
|
228
|
+
The circuit breaker prevents cascading failures by:
|
|
229
|
+
1. Tracking failures and successes
|
|
230
|
+
2. Opening the circuit (rejecting requests) after too many failures
|
|
231
|
+
3. Periodically testing if the service has recovered
|
|
232
|
+
4. Closing the circuit when service is healthy again
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
breaker = CircuitBreaker(name="embedding_api")
|
|
236
|
+
|
|
237
|
+
@breaker
|
|
238
|
+
async def call_embedding_api():
|
|
239
|
+
...
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
name: str
|
|
243
|
+
config: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig)
|
|
244
|
+
_state: CircuitState = field(default=CircuitState.CLOSED, init=False)
|
|
245
|
+
_failure_count: int = field(default=0, init=False)
|
|
246
|
+
_success_count: int = field(default=0, init=False)
|
|
247
|
+
_last_failure_time: float = field(default=0.0, init=False)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def state(self) -> CircuitState:
|
|
251
|
+
"""Get current circuit state, checking for timeout transition."""
|
|
252
|
+
if self._state == CircuitState.OPEN:
|
|
253
|
+
if time.time() - self._last_failure_time >= self.config.timeout:
|
|
254
|
+
self._state = CircuitState.HALF_OPEN
|
|
255
|
+
self._success_count = 0
|
|
256
|
+
logger.info(f"Circuit breaker '{self.name}' entering half-open state")
|
|
257
|
+
return self._state
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def is_closed(self) -> bool:
|
|
261
|
+
"""Check if circuit is closed (allowing requests)."""
|
|
262
|
+
return self.state == CircuitState.CLOSED
|
|
263
|
+
|
|
264
|
+
def record_success(self) -> None:
|
|
265
|
+
"""Record a successful call."""
|
|
266
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
267
|
+
self._success_count += 1
|
|
268
|
+
if self._success_count >= self.config.success_threshold:
|
|
269
|
+
self._state = CircuitState.CLOSED
|
|
270
|
+
self._failure_count = 0
|
|
271
|
+
logger.info(f"Circuit breaker '{self.name}' closed (service recovered)")
|
|
272
|
+
elif self._state == CircuitState.CLOSED:
|
|
273
|
+
# Reset failure count on success
|
|
274
|
+
self._failure_count = 0
|
|
275
|
+
|
|
276
|
+
def record_failure(self, exception: Exception) -> None:
|
|
277
|
+
"""Record a failed call."""
|
|
278
|
+
# Don't count excluded exceptions
|
|
279
|
+
if isinstance(exception, self.config.excluded_exceptions):
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
self._failure_count += 1
|
|
283
|
+
self._last_failure_time = time.time()
|
|
284
|
+
|
|
285
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
286
|
+
# Any failure in half-open immediately opens
|
|
287
|
+
self._state = CircuitState.OPEN
|
|
288
|
+
logger.warning(
|
|
289
|
+
f"Circuit breaker '{self.name}' opened (failure in half-open state)"
|
|
290
|
+
)
|
|
291
|
+
elif self._state == CircuitState.CLOSED:
|
|
292
|
+
if self._failure_count >= self.config.failure_threshold:
|
|
293
|
+
self._state = CircuitState.OPEN
|
|
294
|
+
logger.warning(
|
|
295
|
+
f"Circuit breaker '{self.name}' opened "
|
|
296
|
+
f"(reached {self._failure_count} failures)"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def __call__(
|
|
300
|
+
self, func: Callable[P, Awaitable[T]]
|
|
301
|
+
) -> Callable[P, Awaitable[T]]:
|
|
302
|
+
"""Decorator to wrap an async function with circuit breaker."""
|
|
303
|
+
|
|
304
|
+
@functools.wraps(func)
|
|
305
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
306
|
+
if self.state == CircuitState.OPEN:
|
|
307
|
+
raise CircuitOpenError(self.name)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
result = await func(*args, **kwargs)
|
|
311
|
+
self.record_success()
|
|
312
|
+
return result
|
|
313
|
+
except Exception as e:
|
|
314
|
+
self.record_failure(e)
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
return wrapper
|
|
318
|
+
|
|
319
|
+
def call_sync(self, func: Callable[P, T]) -> Callable[P, T]:
|
|
320
|
+
"""Decorator to wrap a sync function with circuit breaker."""
|
|
321
|
+
|
|
322
|
+
@functools.wraps(func)
|
|
323
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
324
|
+
if self.state == CircuitState.OPEN:
|
|
325
|
+
raise CircuitOpenError(self.name)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
result = func(*args, **kwargs)
|
|
329
|
+
self.record_success()
|
|
330
|
+
return result
|
|
331
|
+
except Exception as e:
|
|
332
|
+
self.record_failure(e)
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
return wrapper
|
|
336
|
+
|
|
337
|
+
def reset(self) -> None:
|
|
338
|
+
"""Manually reset the circuit breaker to closed state."""
|
|
339
|
+
self._state = CircuitState.CLOSED
|
|
340
|
+
self._failure_count = 0
|
|
341
|
+
self._success_count = 0
|
|
342
|
+
logger.info(f"Circuit breaker '{self.name}' manually reset")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# =============================================================================
|
|
346
|
+
# Graceful Degradation
|
|
347
|
+
# =============================================================================
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def with_fallback(
|
|
351
|
+
fallback_value: T,
|
|
352
|
+
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
353
|
+
log_level: int = logging.WARNING,
|
|
354
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
355
|
+
"""Decorator that returns a fallback value on failure.
|
|
356
|
+
|
|
357
|
+
Useful for graceful degradation where partial functionality
|
|
358
|
+
is better than total failure.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
fallback_value: Value to return on failure
|
|
362
|
+
exceptions: Exception types to catch
|
|
363
|
+
log_level: Logging level for failures
|
|
364
|
+
|
|
365
|
+
Example:
|
|
366
|
+
@with_fallback(fallback_value=[])
|
|
367
|
+
async def get_optional_data():
|
|
368
|
+
...
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
372
|
+
@functools.wraps(func)
|
|
373
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
374
|
+
try:
|
|
375
|
+
return await func(*args, **kwargs)
|
|
376
|
+
except exceptions as e:
|
|
377
|
+
logger.log(
|
|
378
|
+
log_level,
|
|
379
|
+
f"Function {func.__name__} failed, using fallback: {e}",
|
|
380
|
+
)
|
|
381
|
+
return fallback_value
|
|
382
|
+
|
|
383
|
+
return wrapper
|
|
384
|
+
|
|
385
|
+
return decorator
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def with_fallback_sync(
|
|
389
|
+
fallback_value: T,
|
|
390
|
+
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
391
|
+
log_level: int = logging.WARNING,
|
|
392
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
393
|
+
"""Synchronous version of with_fallback."""
|
|
394
|
+
|
|
395
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
396
|
+
@functools.wraps(func)
|
|
397
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
398
|
+
try:
|
|
399
|
+
return func(*args, **kwargs)
|
|
400
|
+
except exceptions as e:
|
|
401
|
+
logger.log(
|
|
402
|
+
log_level,
|
|
403
|
+
f"Function {func.__name__} failed, using fallback: {e}",
|
|
404
|
+
)
|
|
405
|
+
return fallback_value
|
|
406
|
+
|
|
407
|
+
return wrapper
|
|
408
|
+
|
|
409
|
+
return decorator
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def try_multiple(
|
|
413
|
+
*operations: tuple[Callable[[], Awaitable[T]], str],
|
|
414
|
+
default: T | None = None,
|
|
415
|
+
) -> T | None:
|
|
416
|
+
"""Try multiple operations in order, returning first success.
|
|
417
|
+
|
|
418
|
+
Useful for fallback chains where you want to try multiple
|
|
419
|
+
approaches to get data.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
operations: Tuples of (async_callable, description)
|
|
423
|
+
default: Value to return if all operations fail
|
|
424
|
+
|
|
425
|
+
Example:
|
|
426
|
+
result = await try_multiple(
|
|
427
|
+
(fetch_from_cache, "cache"),
|
|
428
|
+
(fetch_from_api, "api"),
|
|
429
|
+
(fetch_from_backup, "backup"),
|
|
430
|
+
default=None,
|
|
431
|
+
)
|
|
432
|
+
"""
|
|
433
|
+
for operation, description in operations:
|
|
434
|
+
try:
|
|
435
|
+
result = await operation()
|
|
436
|
+
logger.debug(f"Operation '{description}' succeeded")
|
|
437
|
+
return result
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.debug(f"Operation '{description}' failed: {e}")
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
logger.warning("All operations failed, returning default")
|
|
443
|
+
return default
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# =============================================================================
|
|
447
|
+
# Timeout Utilities
|
|
448
|
+
# =============================================================================
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def with_timeout(
|
|
452
|
+
coro: Awaitable[T],
|
|
453
|
+
timeout: float,
|
|
454
|
+
timeout_message: str | None = None,
|
|
455
|
+
) -> T:
|
|
456
|
+
"""Execute a coroutine with a timeout.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
coro: The coroutine to execute
|
|
460
|
+
timeout: Timeout in seconds
|
|
461
|
+
timeout_message: Custom message for timeout error
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
The result of the coroutine
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
asyncio.TimeoutError: If the operation times out
|
|
468
|
+
"""
|
|
469
|
+
try:
|
|
470
|
+
return await asyncio.wait_for(coro, timeout=timeout)
|
|
471
|
+
except asyncio.TimeoutError:
|
|
472
|
+
msg = timeout_message or f"Operation timed out after {timeout}s"
|
|
473
|
+
raise asyncio.TimeoutError(msg) from None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# =============================================================================
|
|
477
|
+
# Shared Circuit Breakers (Singletons)
|
|
478
|
+
# =============================================================================
|
|
479
|
+
|
|
480
|
+
# Pre-configured circuit breakers for common external services
|
|
481
|
+
_circuit_breakers: dict[str, CircuitBreaker] = {}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def get_circuit_breaker(
|
|
485
|
+
name: str,
|
|
486
|
+
config: CircuitBreakerConfig | None = None,
|
|
487
|
+
) -> CircuitBreaker:
|
|
488
|
+
"""Get or create a named circuit breaker.
|
|
489
|
+
|
|
490
|
+
Circuit breakers are cached by name, so the same breaker
|
|
491
|
+
is returned for the same name across the application.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
name: Unique name for the circuit breaker
|
|
495
|
+
config: Configuration (only used on first creation)
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
The circuit breaker instance
|
|
499
|
+
"""
|
|
500
|
+
if name not in _circuit_breakers:
|
|
501
|
+
_circuit_breakers[name] = CircuitBreaker(
|
|
502
|
+
name=name,
|
|
503
|
+
config=config or CircuitBreakerConfig(),
|
|
504
|
+
)
|
|
505
|
+
return _circuit_breakers[name]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def reset_all_circuit_breakers() -> None:
|
|
509
|
+
"""Reset all circuit breakers to closed state."""
|
|
510
|
+
for breaker in _circuit_breakers.values():
|
|
511
|
+
breaker.reset()
|