ai-lib-python 0.5.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.
- ai_lib_python/__init__.py +43 -0
- ai_lib_python/batch/__init__.py +15 -0
- ai_lib_python/batch/collector.py +244 -0
- ai_lib_python/batch/executor.py +224 -0
- ai_lib_python/cache/__init__.py +26 -0
- ai_lib_python/cache/backends.py +380 -0
- ai_lib_python/cache/key.py +237 -0
- ai_lib_python/cache/manager.py +332 -0
- ai_lib_python/client/__init__.py +37 -0
- ai_lib_python/client/builder.py +528 -0
- ai_lib_python/client/cancel.py +368 -0
- ai_lib_python/client/core.py +433 -0
- ai_lib_python/client/response.py +134 -0
- ai_lib_python/embeddings/__init__.py +36 -0
- ai_lib_python/embeddings/client.py +339 -0
- ai_lib_python/embeddings/types.py +234 -0
- ai_lib_python/embeddings/vectors.py +246 -0
- ai_lib_python/errors/__init__.py +41 -0
- ai_lib_python/errors/base.py +316 -0
- ai_lib_python/errors/classification.py +210 -0
- ai_lib_python/guardrails/__init__.py +35 -0
- ai_lib_python/guardrails/base.py +336 -0
- ai_lib_python/guardrails/filters.py +583 -0
- ai_lib_python/guardrails/validators.py +475 -0
- ai_lib_python/pipeline/__init__.py +55 -0
- ai_lib_python/pipeline/accumulate.py +248 -0
- ai_lib_python/pipeline/base.py +240 -0
- ai_lib_python/pipeline/decode.py +281 -0
- ai_lib_python/pipeline/event_map.py +506 -0
- ai_lib_python/pipeline/fan_out.py +284 -0
- ai_lib_python/pipeline/select.py +297 -0
- ai_lib_python/plugins/__init__.py +32 -0
- ai_lib_python/plugins/base.py +294 -0
- ai_lib_python/plugins/hooks.py +296 -0
- ai_lib_python/plugins/middleware.py +285 -0
- ai_lib_python/plugins/registry.py +294 -0
- ai_lib_python/protocol/__init__.py +71 -0
- ai_lib_python/protocol/loader.py +317 -0
- ai_lib_python/protocol/manifest.py +385 -0
- ai_lib_python/protocol/validator.py +460 -0
- ai_lib_python/py.typed +1 -0
- ai_lib_python/resilience/__init__.py +102 -0
- ai_lib_python/resilience/backpressure.py +225 -0
- ai_lib_python/resilience/circuit_breaker.py +318 -0
- ai_lib_python/resilience/executor.py +343 -0
- ai_lib_python/resilience/fallback.py +341 -0
- ai_lib_python/resilience/preflight.py +413 -0
- ai_lib_python/resilience/rate_limiter.py +291 -0
- ai_lib_python/resilience/retry.py +299 -0
- ai_lib_python/resilience/signals.py +283 -0
- ai_lib_python/routing/__init__.py +118 -0
- ai_lib_python/routing/manager.py +593 -0
- ai_lib_python/routing/strategy.py +345 -0
- ai_lib_python/routing/types.py +397 -0
- ai_lib_python/structured/__init__.py +33 -0
- ai_lib_python/structured/json_mode.py +281 -0
- ai_lib_python/structured/schema.py +316 -0
- ai_lib_python/structured/validator.py +334 -0
- ai_lib_python/telemetry/__init__.py +127 -0
- ai_lib_python/telemetry/exporters/__init__.py +9 -0
- ai_lib_python/telemetry/exporters/prometheus.py +111 -0
- ai_lib_python/telemetry/feedback.py +446 -0
- ai_lib_python/telemetry/health.py +409 -0
- ai_lib_python/telemetry/logger.py +389 -0
- ai_lib_python/telemetry/metrics.py +496 -0
- ai_lib_python/telemetry/tracer.py +473 -0
- ai_lib_python/tokens/__init__.py +25 -0
- ai_lib_python/tokens/counter.py +282 -0
- ai_lib_python/tokens/estimator.py +286 -0
- ai_lib_python/transport/__init__.py +34 -0
- ai_lib_python/transport/auth.py +141 -0
- ai_lib_python/transport/http.py +364 -0
- ai_lib_python/transport/pool.py +425 -0
- ai_lib_python/types/__init__.py +41 -0
- ai_lib_python/types/events.py +343 -0
- ai_lib_python/types/message.py +332 -0
- ai_lib_python/types/tool.py +191 -0
- ai_lib_python/utils/__init__.py +21 -0
- ai_lib_python/utils/tool_call_assembler.py +317 -0
- ai_lib_python-0.5.0.dist-info/METADATA +837 -0
- ai_lib_python-0.5.0.dist-info/RECORD +84 -0
- ai_lib_python-0.5.0.dist-info/WHEEL +4 -0
- ai_lib_python-0.5.0.dist-info/licenses/LICENSE-APACHE +201 -0
- ai_lib_python-0.5.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backpressure control using semaphores.
|
|
3
|
+
|
|
4
|
+
Limits concurrent operations to prevent overload.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BackpressureConfig:
|
|
23
|
+
"""Configuration for backpressure control.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
max_concurrent: Maximum concurrent operations
|
|
27
|
+
queue_timeout: Timeout waiting for permit (None = wait forever)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
max_concurrent: int = 10
|
|
31
|
+
queue_timeout: float | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_env(cls) -> BackpressureConfig:
|
|
35
|
+
"""Create configuration from environment variables."""
|
|
36
|
+
max_concurrent = int(os.getenv("AI_LIB_MAX_INFLIGHT", "10"))
|
|
37
|
+
timeout_str = os.getenv("AI_LIB_QUEUE_TIMEOUT")
|
|
38
|
+
queue_timeout = float(timeout_str) if timeout_str else None
|
|
39
|
+
|
|
40
|
+
return cls(
|
|
41
|
+
max_concurrent=max_concurrent,
|
|
42
|
+
queue_timeout=queue_timeout,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def unlimited(cls) -> BackpressureConfig:
|
|
47
|
+
"""Create config with no limit."""
|
|
48
|
+
return cls(max_concurrent=0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BackpressureError(Exception):
|
|
52
|
+
"""Raised when backpressure limit is exceeded."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str = "Backpressure limit exceeded") -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Backpressure:
|
|
59
|
+
"""Backpressure control using semaphores.
|
|
60
|
+
|
|
61
|
+
Limits the number of concurrent operations to prevent
|
|
62
|
+
overwhelming downstream services.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> bp = Backpressure(BackpressureConfig(max_concurrent=5))
|
|
66
|
+
>>> async with bp.acquire():
|
|
67
|
+
... await make_request()
|
|
68
|
+
|
|
69
|
+
>>> # Or with execute
|
|
70
|
+
>>> result = await bp.execute(async_operation)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, config: BackpressureConfig | None = None) -> None:
|
|
74
|
+
"""Initialize backpressure control.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config: Backpressure configuration
|
|
78
|
+
"""
|
|
79
|
+
self._config = config or BackpressureConfig()
|
|
80
|
+
|
|
81
|
+
# Create semaphore if limiting is enabled
|
|
82
|
+
if self._config.max_concurrent > 0:
|
|
83
|
+
self._semaphore: asyncio.Semaphore | None = asyncio.Semaphore(
|
|
84
|
+
self._config.max_concurrent
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
self._semaphore = None
|
|
88
|
+
|
|
89
|
+
# Statistics
|
|
90
|
+
self._current_inflight = 0
|
|
91
|
+
self._peak_inflight = 0
|
|
92
|
+
self._total_acquired = 0
|
|
93
|
+
self._total_rejected = 0
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def current_inflight(self) -> int:
|
|
97
|
+
"""Get current number of in-flight operations."""
|
|
98
|
+
return self._current_inflight
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def available_permits(self) -> int:
|
|
102
|
+
"""Get number of available permits."""
|
|
103
|
+
if self._semaphore is None:
|
|
104
|
+
return float("inf") # type: ignore
|
|
105
|
+
return self._config.max_concurrent - self._current_inflight
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_limited(self) -> bool:
|
|
109
|
+
"""Check if backpressure limiting is enabled."""
|
|
110
|
+
return self._semaphore is not None
|
|
111
|
+
|
|
112
|
+
@asynccontextmanager
|
|
113
|
+
async def acquire(self) -> AsyncIterator[None]:
|
|
114
|
+
"""Acquire a permit for an operation.
|
|
115
|
+
|
|
116
|
+
Yields:
|
|
117
|
+
None when permit acquired
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
BackpressureError: If timeout exceeded
|
|
121
|
+
asyncio.TimeoutError: If timeout exceeded
|
|
122
|
+
"""
|
|
123
|
+
if self._semaphore is None:
|
|
124
|
+
# No limiting
|
|
125
|
+
self._current_inflight += 1
|
|
126
|
+
self._peak_inflight = max(self._peak_inflight, self._current_inflight)
|
|
127
|
+
self._total_acquired += 1
|
|
128
|
+
try:
|
|
129
|
+
yield
|
|
130
|
+
finally:
|
|
131
|
+
self._current_inflight -= 1
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
if self._config.queue_timeout is not None:
|
|
136
|
+
# Wait with timeout
|
|
137
|
+
await asyncio.wait_for(
|
|
138
|
+
self._semaphore.acquire(),
|
|
139
|
+
timeout=self._config.queue_timeout,
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
# Wait indefinitely
|
|
143
|
+
await self._semaphore.acquire()
|
|
144
|
+
|
|
145
|
+
self._current_inflight += 1
|
|
146
|
+
self._peak_inflight = max(self._peak_inflight, self._current_inflight)
|
|
147
|
+
self._total_acquired += 1
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
yield
|
|
151
|
+
finally:
|
|
152
|
+
self._current_inflight -= 1
|
|
153
|
+
self._semaphore.release()
|
|
154
|
+
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
self._total_rejected += 1
|
|
157
|
+
raise BackpressureError("Timeout waiting for permit") from None
|
|
158
|
+
|
|
159
|
+
async def try_acquire(self) -> bool:
|
|
160
|
+
"""Try to acquire a permit without waiting.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if permit acquired, False otherwise
|
|
164
|
+
"""
|
|
165
|
+
if self._semaphore is None:
|
|
166
|
+
self._current_inflight += 1
|
|
167
|
+
self._peak_inflight = max(self._peak_inflight, self._current_inflight)
|
|
168
|
+
self._total_acquired += 1
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
# Try to acquire without waiting
|
|
172
|
+
acquired = self._semaphore.locked() is False
|
|
173
|
+
if acquired:
|
|
174
|
+
await self._semaphore.acquire()
|
|
175
|
+
self._current_inflight += 1
|
|
176
|
+
self._peak_inflight = max(self._peak_inflight, self._current_inflight)
|
|
177
|
+
self._total_acquired += 1
|
|
178
|
+
|
|
179
|
+
return acquired
|
|
180
|
+
|
|
181
|
+
def release(self) -> None:
|
|
182
|
+
"""Release a permit (if using try_acquire)."""
|
|
183
|
+
self._current_inflight -= 1
|
|
184
|
+
if self._semaphore is not None:
|
|
185
|
+
self._semaphore.release()
|
|
186
|
+
|
|
187
|
+
async def execute(
|
|
188
|
+
self,
|
|
189
|
+
operation: Callable[[], Awaitable[T]],
|
|
190
|
+
) -> T:
|
|
191
|
+
"""Execute an operation with backpressure control.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
operation: Async operation to execute
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Operation result
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
BackpressureError: If timeout exceeded
|
|
201
|
+
"""
|
|
202
|
+
async with self.acquire():
|
|
203
|
+
return await operation()
|
|
204
|
+
|
|
205
|
+
def get_stats(self) -> dict[str, int]:
|
|
206
|
+
"""Get backpressure statistics.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Dict with statistics
|
|
210
|
+
"""
|
|
211
|
+
return {
|
|
212
|
+
"current_inflight": self._current_inflight,
|
|
213
|
+
"peak_inflight": self._peak_inflight,
|
|
214
|
+
"total_acquired": self._total_acquired,
|
|
215
|
+
"total_rejected": self._total_rejected,
|
|
216
|
+
"max_concurrent": self._config.max_concurrent,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def __repr__(self) -> str:
|
|
220
|
+
if self._semaphore is None:
|
|
221
|
+
return "Backpressure(unlimited)"
|
|
222
|
+
return (
|
|
223
|
+
f"Backpressure("
|
|
224
|
+
f"inflight={self._current_inflight}/{self._config.max_concurrent})"
|
|
225
|
+
)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Circuit breaker for fault isolation.
|
|
3
|
+
|
|
4
|
+
Implements the circuit breaker pattern with three states:
|
|
5
|
+
- Closed: Normal operation, requests pass through
|
|
6
|
+
- Open: Circuit tripped, requests fail fast
|
|
7
|
+
- Half-Open: Testing if service recovered
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CircuitState(str, Enum):
|
|
25
|
+
"""Circuit breaker states."""
|
|
26
|
+
|
|
27
|
+
CLOSED = "closed"
|
|
28
|
+
OPEN = "open"
|
|
29
|
+
HALF_OPEN = "half_open"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CircuitBreakerConfig:
|
|
34
|
+
"""Configuration for circuit breaker.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
failure_threshold: Number of failures to trip the circuit
|
|
38
|
+
success_threshold: Number of successes in half-open to close
|
|
39
|
+
cooldown_seconds: Time to wait before testing (half-open)
|
|
40
|
+
timeout_seconds: Optional timeout for operations
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
failure_threshold: int = 5
|
|
44
|
+
success_threshold: int = 2
|
|
45
|
+
cooldown_seconds: float = 30.0
|
|
46
|
+
timeout_seconds: float | None = None
|
|
47
|
+
half_open_max_concurrent: int = 1
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def default(cls) -> CircuitBreakerConfig:
|
|
51
|
+
"""Create default configuration."""
|
|
52
|
+
return cls()
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_env(cls) -> CircuitBreakerConfig:
|
|
56
|
+
"""Create configuration from environment variables."""
|
|
57
|
+
import os
|
|
58
|
+
|
|
59
|
+
failure_threshold = int(
|
|
60
|
+
os.getenv("AI_LIB_BREAKER_FAILURE_THRESHOLD", "5")
|
|
61
|
+
)
|
|
62
|
+
cooldown_seconds = float(
|
|
63
|
+
os.getenv("AI_LIB_BREAKER_COOLDOWN_SECS", "30")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
failure_threshold=failure_threshold,
|
|
68
|
+
cooldown_seconds=cooldown_seconds,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CircuitOpenError(Exception):
|
|
73
|
+
"""Raised when circuit is open and request is rejected."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
message: str = "Circuit breaker is open",
|
|
78
|
+
time_until_retry: float | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.time_until_retry = time_until_retry
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class CircuitStats:
|
|
86
|
+
"""Statistics for circuit breaker."""
|
|
87
|
+
|
|
88
|
+
total_requests: int = 0
|
|
89
|
+
successful_requests: int = 0
|
|
90
|
+
failed_requests: int = 0
|
|
91
|
+
rejected_requests: int = 0
|
|
92
|
+
state_changes: int = 0
|
|
93
|
+
last_failure_time: float | None = None
|
|
94
|
+
last_success_time: float | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CircuitBreaker:
|
|
98
|
+
"""Circuit breaker for fault isolation.
|
|
99
|
+
|
|
100
|
+
Prevents cascading failures by failing fast when a service is unhealthy.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
>>> breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=3))
|
|
104
|
+
>>> try:
|
|
105
|
+
... result = await breaker.execute(async_operation)
|
|
106
|
+
... except CircuitOpenError:
|
|
107
|
+
... print("Service unavailable")
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, config: CircuitBreakerConfig | None = None) -> None:
|
|
111
|
+
"""Initialize circuit breaker.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config: Circuit breaker configuration
|
|
115
|
+
"""
|
|
116
|
+
self._config = config or CircuitBreakerConfig()
|
|
117
|
+
self._state = CircuitState.CLOSED
|
|
118
|
+
self._lock = asyncio.Lock()
|
|
119
|
+
|
|
120
|
+
# Failure tracking
|
|
121
|
+
self._failure_count = 0
|
|
122
|
+
self._success_count = 0
|
|
123
|
+
self._last_failure_time: float | None = None
|
|
124
|
+
self._opened_at: float | None = None
|
|
125
|
+
|
|
126
|
+
# Half-open state management
|
|
127
|
+
self._half_open_semaphore = asyncio.Semaphore(
|
|
128
|
+
self._config.half_open_max_concurrent
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Statistics
|
|
132
|
+
self._stats = CircuitStats()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def state(self) -> CircuitState:
|
|
136
|
+
"""Get current circuit state."""
|
|
137
|
+
return self._state
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def is_closed(self) -> bool:
|
|
141
|
+
"""Check if circuit is closed (normal operation)."""
|
|
142
|
+
return self._state == CircuitState.CLOSED
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_open(self) -> bool:
|
|
146
|
+
"""Check if circuit is open (failing fast)."""
|
|
147
|
+
return self._state == CircuitState.OPEN
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_half_open(self) -> bool:
|
|
151
|
+
"""Check if circuit is half-open (testing)."""
|
|
152
|
+
return self._state == CircuitState.HALF_OPEN
|
|
153
|
+
|
|
154
|
+
def _check_state_transition(self) -> None:
|
|
155
|
+
"""Check if state should transition."""
|
|
156
|
+
now = time.monotonic()
|
|
157
|
+
|
|
158
|
+
if self._state == CircuitState.OPEN and self._opened_at is not None:
|
|
159
|
+
# Check if cooldown has passed
|
|
160
|
+
elapsed = now - self._opened_at
|
|
161
|
+
if elapsed >= self._config.cooldown_seconds:
|
|
162
|
+
self._transition_to(CircuitState.HALF_OPEN)
|
|
163
|
+
|
|
164
|
+
def _transition_to(self, new_state: CircuitState) -> None:
|
|
165
|
+
"""Transition to a new state.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
new_state: Target state
|
|
169
|
+
"""
|
|
170
|
+
if new_state == self._state:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
self._state = new_state
|
|
174
|
+
self._stats.state_changes += 1
|
|
175
|
+
|
|
176
|
+
if new_state == CircuitState.OPEN:
|
|
177
|
+
self._opened_at = time.monotonic()
|
|
178
|
+
elif new_state == CircuitState.HALF_OPEN:
|
|
179
|
+
self._success_count = 0
|
|
180
|
+
elif new_state == CircuitState.CLOSED:
|
|
181
|
+
self._failure_count = 0
|
|
182
|
+
self._opened_at = None
|
|
183
|
+
|
|
184
|
+
def _record_success(self) -> None:
|
|
185
|
+
"""Record a successful operation."""
|
|
186
|
+
self._stats.successful_requests += 1
|
|
187
|
+
self._stats.last_success_time = time.monotonic()
|
|
188
|
+
|
|
189
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
190
|
+
self._success_count += 1
|
|
191
|
+
if self._success_count >= self._config.success_threshold:
|
|
192
|
+
self._transition_to(CircuitState.CLOSED)
|
|
193
|
+
elif self._state == CircuitState.CLOSED:
|
|
194
|
+
# Reset failure count on success
|
|
195
|
+
self._failure_count = max(0, self._failure_count - 1)
|
|
196
|
+
|
|
197
|
+
def _record_failure(self) -> None:
|
|
198
|
+
"""Record a failed operation."""
|
|
199
|
+
now = time.monotonic()
|
|
200
|
+
self._stats.failed_requests += 1
|
|
201
|
+
self._stats.last_failure_time = now
|
|
202
|
+
self._last_failure_time = now
|
|
203
|
+
|
|
204
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
205
|
+
# Single failure in half-open trips back to open
|
|
206
|
+
self._transition_to(CircuitState.OPEN)
|
|
207
|
+
elif self._state == CircuitState.CLOSED:
|
|
208
|
+
self._failure_count += 1
|
|
209
|
+
if self._failure_count >= self._config.failure_threshold:
|
|
210
|
+
self._transition_to(CircuitState.OPEN)
|
|
211
|
+
|
|
212
|
+
def get_time_until_retry(self) -> float | None:
|
|
213
|
+
"""Get time until circuit will transition to half-open.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Seconds until retry, or None if not open
|
|
217
|
+
"""
|
|
218
|
+
if self._state != CircuitState.OPEN or self._opened_at is None:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
elapsed = time.monotonic() - self._opened_at
|
|
222
|
+
remaining = self._config.cooldown_seconds - elapsed
|
|
223
|
+
return max(0, remaining)
|
|
224
|
+
|
|
225
|
+
async def execute(
|
|
226
|
+
self,
|
|
227
|
+
operation: Callable[[], Awaitable[T]],
|
|
228
|
+
fallback: Callable[[], Awaitable[T]] | None = None,
|
|
229
|
+
) -> T:
|
|
230
|
+
"""Execute an operation through the circuit breaker.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
operation: Async operation to execute
|
|
234
|
+
fallback: Optional fallback if circuit is open
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Operation result
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
CircuitOpenError: If circuit is open and no fallback
|
|
241
|
+
"""
|
|
242
|
+
async with self._lock:
|
|
243
|
+
self._check_state_transition()
|
|
244
|
+
self._stats.total_requests += 1
|
|
245
|
+
|
|
246
|
+
if self._state == CircuitState.OPEN:
|
|
247
|
+
self._stats.rejected_requests += 1
|
|
248
|
+
if fallback:
|
|
249
|
+
return await fallback()
|
|
250
|
+
raise CircuitOpenError(
|
|
251
|
+
time_until_retry=self.get_time_until_retry()
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Execute operation
|
|
255
|
+
try:
|
|
256
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
257
|
+
# Limit concurrent requests in half-open state
|
|
258
|
+
async with self._half_open_semaphore:
|
|
259
|
+
result = await self._execute_with_timeout(operation)
|
|
260
|
+
else:
|
|
261
|
+
result = await self._execute_with_timeout(operation)
|
|
262
|
+
|
|
263
|
+
async with self._lock:
|
|
264
|
+
self._record_success()
|
|
265
|
+
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
except Exception:
|
|
269
|
+
async with self._lock:
|
|
270
|
+
self._record_failure()
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
async def _execute_with_timeout(
|
|
274
|
+
self, operation: Callable[[], Awaitable[T]]
|
|
275
|
+
) -> T:
|
|
276
|
+
"""Execute operation with optional timeout.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
operation: Async operation
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Operation result
|
|
283
|
+
"""
|
|
284
|
+
if self._config.timeout_seconds:
|
|
285
|
+
return await asyncio.wait_for(
|
|
286
|
+
operation(),
|
|
287
|
+
timeout=self._config.timeout_seconds,
|
|
288
|
+
)
|
|
289
|
+
return await operation()
|
|
290
|
+
|
|
291
|
+
def reset(self) -> None:
|
|
292
|
+
"""Reset circuit breaker to closed state."""
|
|
293
|
+
self._state = CircuitState.CLOSED
|
|
294
|
+
self._failure_count = 0
|
|
295
|
+
self._success_count = 0
|
|
296
|
+
self._opened_at = None
|
|
297
|
+
|
|
298
|
+
def get_stats(self) -> CircuitStats:
|
|
299
|
+
"""Get circuit breaker statistics.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
CircuitStats with current statistics
|
|
303
|
+
"""
|
|
304
|
+
return CircuitStats(
|
|
305
|
+
total_requests=self._stats.total_requests,
|
|
306
|
+
successful_requests=self._stats.successful_requests,
|
|
307
|
+
failed_requests=self._stats.failed_requests,
|
|
308
|
+
rejected_requests=self._stats.rejected_requests,
|
|
309
|
+
state_changes=self._stats.state_changes,
|
|
310
|
+
last_failure_time=self._stats.last_failure_time,
|
|
311
|
+
last_success_time=self._stats.last_success_time,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
return (
|
|
316
|
+
f"CircuitBreaker(state={self._state.value}, "
|
|
317
|
+
f"failures={self._failure_count}/{self._config.failure_threshold})"
|
|
318
|
+
)
|