krons 0.1.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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Resilience patterns: circuit breaker and retry with exponential backoff.
|
|
5
|
+
|
|
6
|
+
Provides fail-fast circuit breaking and configurable retry strategies
|
|
7
|
+
for transient failure handling in async operations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import random
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, TypedDict, TypeVar
|
|
18
|
+
|
|
19
|
+
from kronos.errors import KronConnectionError
|
|
20
|
+
from kronos.utils.concurrency import Lock, current_time, sleep
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _MetricsDict(TypedDict):
|
|
24
|
+
"""Internal type for circuit breaker metrics tracking."""
|
|
25
|
+
|
|
26
|
+
success_count: int
|
|
27
|
+
failure_count: int
|
|
28
|
+
rejected_count: int
|
|
29
|
+
state_changes: list[dict[str, Any]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = (
|
|
33
|
+
"CircuitBreaker",
|
|
34
|
+
"CircuitBreakerOpenError",
|
|
35
|
+
"CircuitState",
|
|
36
|
+
"RetryConfig",
|
|
37
|
+
"retry_with_backoff",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CircuitBreakerOpenError(KronConnectionError):
|
|
45
|
+
"""Circuit breaker is open."""
|
|
46
|
+
|
|
47
|
+
default_message = "Circuit breaker is open"
|
|
48
|
+
default_retryable = True # Circuit breaker errors are inherently retryable
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str | None = None,
|
|
53
|
+
*,
|
|
54
|
+
retry_after: float | None = None,
|
|
55
|
+
details: dict[str, Any] | None = None,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize with message and optional retry_after.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
message: Error message (uses default_message if None)
|
|
61
|
+
retry_after: Seconds until retry should be attempted
|
|
62
|
+
details: Additional context dict
|
|
63
|
+
"""
|
|
64
|
+
# Add retry_after to details if provided
|
|
65
|
+
if retry_after is not None:
|
|
66
|
+
details = details or {}
|
|
67
|
+
details["retry_after"] = retry_after
|
|
68
|
+
|
|
69
|
+
super().__init__(message=message, details=details, retryable=True)
|
|
70
|
+
self.retry_after = retry_after
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CircuitState(Enum):
|
|
74
|
+
"""Circuit breaker states."""
|
|
75
|
+
|
|
76
|
+
CLOSED = "closed" # Normal operation
|
|
77
|
+
OPEN = "open" # Failing, rejecting requests
|
|
78
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CircuitBreaker:
|
|
82
|
+
"""Fail-fast circuit breaker for protecting against cascading failures.
|
|
83
|
+
|
|
84
|
+
State machine: CLOSED -> OPEN -> HALF_OPEN -> CLOSED (on success) or OPEN (on failure).
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> cb = CircuitBreaker(failure_threshold=3, recovery_time=10.0)
|
|
88
|
+
>>> result = await cb.execute(api_call, arg1, kwarg=val)
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
failure_threshold: Failures before opening circuit.
|
|
92
|
+
recovery_time: Seconds to wait before transitioning to HALF_OPEN.
|
|
93
|
+
half_open_max_calls: Test calls allowed in HALF_OPEN state.
|
|
94
|
+
excluded_exceptions: Exception types that don't count as failures.
|
|
95
|
+
name: Identifier for logging/metrics.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
failure_threshold: int = 5,
|
|
101
|
+
recovery_time: float = 30.0,
|
|
102
|
+
half_open_max_calls: int = 1,
|
|
103
|
+
excluded_exceptions: set[type[Exception]] | None = None,
|
|
104
|
+
name: str = "default",
|
|
105
|
+
):
|
|
106
|
+
"""Initialize circuit breaker.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If thresholds or times are non-positive.
|
|
110
|
+
"""
|
|
111
|
+
if failure_threshold <= 0:
|
|
112
|
+
raise ValueError("failure_threshold must be > 0")
|
|
113
|
+
if recovery_time <= 0:
|
|
114
|
+
raise ValueError("recovery_time must be > 0")
|
|
115
|
+
if half_open_max_calls <= 0:
|
|
116
|
+
raise ValueError("half_open_max_calls must be > 0")
|
|
117
|
+
|
|
118
|
+
self.failure_threshold = failure_threshold
|
|
119
|
+
self.recovery_time = recovery_time
|
|
120
|
+
self.half_open_max_calls = half_open_max_calls
|
|
121
|
+
self.excluded_exceptions = excluded_exceptions or set()
|
|
122
|
+
self.name = name
|
|
123
|
+
|
|
124
|
+
if Exception in self.excluded_exceptions:
|
|
125
|
+
logger.warning(
|
|
126
|
+
f"CircuitBreaker '{name}': excluding base Exception means circuit will never open"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self.failure_count = 0
|
|
130
|
+
self.state = CircuitState.CLOSED
|
|
131
|
+
self.last_failure_time = 0.0
|
|
132
|
+
self._half_open_calls = 0
|
|
133
|
+
self._lock = Lock()
|
|
134
|
+
|
|
135
|
+
self._metrics: _MetricsDict = {
|
|
136
|
+
"success_count": 0,
|
|
137
|
+
"failure_count": 0,
|
|
138
|
+
"rejected_count": 0,
|
|
139
|
+
"state_changes": [],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
logger.debug(
|
|
143
|
+
f"Initialized CircuitBreaker '{self.name}' with failure_threshold={failure_threshold}, "
|
|
144
|
+
f"recovery_time={recovery_time}, half_open_max_calls={half_open_max_calls}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def metrics(self) -> dict[str, Any]:
|
|
149
|
+
"""Get circuit breaker metrics snapshot (deep copy for thread-safety)."""
|
|
150
|
+
return {
|
|
151
|
+
"success_count": self._metrics["success_count"],
|
|
152
|
+
"failure_count": self._metrics["failure_count"],
|
|
153
|
+
"rejected_count": self._metrics["rejected_count"],
|
|
154
|
+
"state_changes": list(self._metrics["state_changes"]), # Deep copy list
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, Any]:
|
|
158
|
+
"""Serialize circuit breaker configuration."""
|
|
159
|
+
return {
|
|
160
|
+
"failure_threshold": self.failure_threshold,
|
|
161
|
+
"recovery_time": self.recovery_time,
|
|
162
|
+
"half_open_max_calls": self.half_open_max_calls,
|
|
163
|
+
"name": self.name,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async def _change_state(self, new_state: CircuitState) -> None:
|
|
167
|
+
"""Transition to new state, reset counters, and log change."""
|
|
168
|
+
old_state = self.state
|
|
169
|
+
if new_state != old_state:
|
|
170
|
+
self.state = new_state
|
|
171
|
+
self._metrics["state_changes"].append(
|
|
172
|
+
{
|
|
173
|
+
"time": current_time(),
|
|
174
|
+
"from": old_state.value,
|
|
175
|
+
"to": new_state.value,
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
logger.info(
|
|
180
|
+
f"Circuit '{self.name}' state changed from {old_state.value} to {new_state.value}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if new_state == CircuitState.HALF_OPEN:
|
|
184
|
+
self._half_open_calls = 0
|
|
185
|
+
elif new_state == CircuitState.CLOSED:
|
|
186
|
+
self.failure_count = 0
|
|
187
|
+
|
|
188
|
+
async def _check_state(self) -> tuple[bool, float]:
|
|
189
|
+
"""Check if request can proceed based on current state.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
(can_proceed, retry_after_seconds) - retry_after is 0.0 if allowed.
|
|
193
|
+
"""
|
|
194
|
+
async with self._lock:
|
|
195
|
+
now = current_time()
|
|
196
|
+
|
|
197
|
+
if self.state == CircuitState.OPEN:
|
|
198
|
+
# Check if recovery time has elapsed
|
|
199
|
+
if now - self.last_failure_time >= self.recovery_time:
|
|
200
|
+
await self._change_state(CircuitState.HALF_OPEN)
|
|
201
|
+
else:
|
|
202
|
+
recovery_remaining = self.recovery_time - (now - self.last_failure_time)
|
|
203
|
+
self._metrics["rejected_count"] += 1
|
|
204
|
+
|
|
205
|
+
logger.warning(
|
|
206
|
+
f"Circuit '{self.name}' is OPEN, rejecting request. "
|
|
207
|
+
f"Try again in {recovery_remaining:.2f}s"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return False, recovery_remaining
|
|
211
|
+
|
|
212
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
213
|
+
if self._half_open_calls >= self.half_open_max_calls:
|
|
214
|
+
self._metrics["rejected_count"] += 1
|
|
215
|
+
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Circuit '{self.name}' is HALF_OPEN and at capacity. Try again later."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return False, self.recovery_time
|
|
221
|
+
|
|
222
|
+
self._half_open_calls += 1
|
|
223
|
+
|
|
224
|
+
return True, 0.0
|
|
225
|
+
|
|
226
|
+
async def execute(self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any) -> T:
|
|
227
|
+
"""Execute async function with circuit breaker protection.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
func: Async callable to execute.
|
|
231
|
+
*args: Positional arguments for func.
|
|
232
|
+
**kwargs: Keyword arguments for func.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Result from func if successful.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
CircuitBreakerOpenError: If circuit is open or half-open at capacity.
|
|
239
|
+
Exception: Any exception from func (after recording failure).
|
|
240
|
+
"""
|
|
241
|
+
can_proceed, retry_after = await self._check_state()
|
|
242
|
+
if not can_proceed:
|
|
243
|
+
raise CircuitBreakerOpenError(
|
|
244
|
+
f"Circuit breaker '{self.name}' is open. Retry after {retry_after:.2f} seconds",
|
|
245
|
+
retry_after=retry_after,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
logger.debug(
|
|
250
|
+
f"Executing {func.__name__} with circuit '{self.name}' state: {self.state.value}"
|
|
251
|
+
)
|
|
252
|
+
result = await func(*args, **kwargs)
|
|
253
|
+
|
|
254
|
+
async with self._lock:
|
|
255
|
+
self._metrics["success_count"] += 1
|
|
256
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
257
|
+
await self._change_state(CircuitState.CLOSED)
|
|
258
|
+
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
is_excluded = any(isinstance(e, exc_type) for exc_type in self.excluded_exceptions)
|
|
263
|
+
|
|
264
|
+
if not is_excluded:
|
|
265
|
+
async with self._lock:
|
|
266
|
+
self.failure_count += 1
|
|
267
|
+
self.last_failure_time = current_time()
|
|
268
|
+
self._metrics["failure_count"] += 1
|
|
269
|
+
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"Circuit '{self.name}' failure: {e}. "
|
|
272
|
+
f"Count: {self.failure_count}/{self.failure_threshold}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
self.state == CircuitState.CLOSED
|
|
277
|
+
and self.failure_count >= self.failure_threshold
|
|
278
|
+
) or self.state == CircuitState.HALF_OPEN:
|
|
279
|
+
await self._change_state(CircuitState.OPEN)
|
|
280
|
+
|
|
281
|
+
logger.exception(f"Circuit breaker '{self.name}' caught exception")
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class RetryConfig:
|
|
287
|
+
"""Immutable retry configuration with exponential backoff and jitter.
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
>>> config = RetryConfig(max_retries=5, initial_delay=0.5)
|
|
291
|
+
>>> await retry_with_backoff(api_call, **config.as_kwargs())
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
max_retries: int = 3
|
|
295
|
+
initial_delay: float = 1.0
|
|
296
|
+
max_delay: float = 60.0
|
|
297
|
+
exponential_base: float = 2.0
|
|
298
|
+
jitter: bool = True
|
|
299
|
+
retry_on: tuple[type[Exception], ...] = field(
|
|
300
|
+
default=(KronConnectionError, CircuitBreakerOpenError)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def __post_init__(self):
|
|
304
|
+
"""Validate configuration parameters."""
|
|
305
|
+
if self.max_retries < 0:
|
|
306
|
+
raise ValueError("max_retries must be >= 0")
|
|
307
|
+
if self.initial_delay <= 0:
|
|
308
|
+
raise ValueError("initial_delay must be > 0")
|
|
309
|
+
if self.max_delay <= 0:
|
|
310
|
+
raise ValueError("max_delay must be > 0")
|
|
311
|
+
if self.max_delay < self.initial_delay:
|
|
312
|
+
raise ValueError("max_delay must be >= initial_delay")
|
|
313
|
+
if self.exponential_base <= 0:
|
|
314
|
+
raise ValueError("exponential_base must be > 0")
|
|
315
|
+
|
|
316
|
+
def calculate_delay(self, attempt: int) -> float:
|
|
317
|
+
"""Calculate delay with exponential backoff + optional jitter.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
attempt: Current retry attempt number (0-indexed)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Delay in seconds before next retry
|
|
324
|
+
"""
|
|
325
|
+
delay = min(self.initial_delay * (self.exponential_base**attempt), self.max_delay)
|
|
326
|
+
if self.jitter:
|
|
327
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
328
|
+
return delay
|
|
329
|
+
|
|
330
|
+
def to_dict(self) -> dict[str, Any]:
|
|
331
|
+
"""Serialize to dict (for persistence/logging)."""
|
|
332
|
+
return {
|
|
333
|
+
"max_retries": self.max_retries,
|
|
334
|
+
"initial_delay": self.initial_delay,
|
|
335
|
+
"max_delay": self.max_delay,
|
|
336
|
+
"exponential_base": self.exponential_base,
|
|
337
|
+
"jitter": self.jitter,
|
|
338
|
+
"retry_on": self.retry_on,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def as_kwargs(self) -> dict[str, Any]:
|
|
342
|
+
"""Convert to kwargs dict for passing to retry_with_backoff()."""
|
|
343
|
+
return self.to_dict()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def retry_with_backoff(
|
|
347
|
+
func: Callable[..., Awaitable[T]],
|
|
348
|
+
*args,
|
|
349
|
+
max_retries: int = 3,
|
|
350
|
+
initial_delay: float = 1.0,
|
|
351
|
+
max_delay: float = 60.0,
|
|
352
|
+
exponential_base: float = 2.0,
|
|
353
|
+
jitter: bool = True,
|
|
354
|
+
retry_on: tuple[type[Exception], ...] = (
|
|
355
|
+
KronConnectionError,
|
|
356
|
+
CircuitBreakerOpenError,
|
|
357
|
+
),
|
|
358
|
+
**kwargs,
|
|
359
|
+
) -> T:
|
|
360
|
+
"""Retry async function with exponential backoff and optional jitter.
|
|
361
|
+
|
|
362
|
+
Only retries on specified exception types (transient errors by default).
|
|
363
|
+
Does NOT retry programming errors, file system errors, or timeouts unless
|
|
364
|
+
explicitly added to retry_on.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
func: Async callable to execute.
|
|
368
|
+
*args: Positional arguments for func.
|
|
369
|
+
max_retries: Maximum retry attempts (0 = no retries).
|
|
370
|
+
initial_delay: Base delay in seconds before first retry.
|
|
371
|
+
max_delay: Maximum delay cap in seconds.
|
|
372
|
+
exponential_base: Multiplier for delay growth (delay * base^attempt).
|
|
373
|
+
jitter: If True, randomize delay by 50-100% to prevent thundering herd.
|
|
374
|
+
retry_on: Exception types to retry on.
|
|
375
|
+
**kwargs: Keyword arguments for func.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Result from func on success.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
Exception: Last caught exception after all retries exhausted.
|
|
382
|
+
|
|
383
|
+
Example:
|
|
384
|
+
>>> result = await retry_with_backoff(
|
|
385
|
+
... api_call, endpoint, max_retries=5, initial_delay=0.5
|
|
386
|
+
... )
|
|
387
|
+
"""
|
|
388
|
+
last_exception = None
|
|
389
|
+
|
|
390
|
+
for attempt in range(max_retries + 1):
|
|
391
|
+
try:
|
|
392
|
+
return await func(*args, **kwargs)
|
|
393
|
+
except retry_on as e:
|
|
394
|
+
last_exception = e
|
|
395
|
+
|
|
396
|
+
if attempt >= max_retries:
|
|
397
|
+
logger.error(f"All {max_retries} retry attempts exhausted for {func.__name__}: {e}")
|
|
398
|
+
raise
|
|
399
|
+
|
|
400
|
+
delay = min(initial_delay * (exponential_base**attempt), max_delay)
|
|
401
|
+
|
|
402
|
+
if jitter:
|
|
403
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
404
|
+
|
|
405
|
+
logger.debug(
|
|
406
|
+
f"Retry attempt {attempt + 1}/{max_retries} for {func.__name__} "
|
|
407
|
+
f"after {delay:.2f}s: {e}"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
await sleep(delay)
|
|
411
|
+
|
|
412
|
+
if last_exception: # pragma: no cover
|
|
413
|
+
raise last_exception # pragma: no cover
|
|
414
|
+
raise RuntimeError("Unexpected retry loop exit") # pragma: no cover
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Session: conversation orchestration with messages, branches, and services.
|
|
5
|
+
|
|
6
|
+
Core types:
|
|
7
|
+
Session: Central orchestrator owning branches, messages, services.
|
|
8
|
+
Branch: Message progression with access control (capabilities, resources).
|
|
9
|
+
Message: Inter-entity communication container.
|
|
10
|
+
Exchange: Async message router between entity mailboxes.
|
|
11
|
+
|
|
12
|
+
Validators (raise on failure):
|
|
13
|
+
resource_must_exist_in_session
|
|
14
|
+
resource_must_be_accessible_by_branch
|
|
15
|
+
capabilities_must_be_subset_of_branch
|
|
16
|
+
resolve_branch_exists_in_session
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .exchange import Exchange
|
|
20
|
+
from .message import Message
|
|
21
|
+
from .session import (
|
|
22
|
+
Branch,
|
|
23
|
+
Session,
|
|
24
|
+
SessionConfig,
|
|
25
|
+
capabilities_must_be_subset_of_branch,
|
|
26
|
+
resolve_branch_exists_in_session,
|
|
27
|
+
resource_must_be_accessible_by_branch,
|
|
28
|
+
resource_must_exist_in_session,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = (
|
|
32
|
+
"Branch",
|
|
33
|
+
"Exchange",
|
|
34
|
+
"Message",
|
|
35
|
+
"Session",
|
|
36
|
+
"SessionConfig",
|
|
37
|
+
"capabilities_must_be_subset_of_branch",
|
|
38
|
+
"resolve_branch_exists_in_session",
|
|
39
|
+
"resource_must_be_accessible_by_branch",
|
|
40
|
+
"resource_must_exist_in_session",
|
|
41
|
+
)
|