rollgate 1.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.
- rollgate/__init__.py +135 -0
- rollgate/cache.py +260 -0
- rollgate/circuit_breaker.py +240 -0
- rollgate/client.py +562 -0
- rollgate/dedup.py +172 -0
- rollgate/errors.py +162 -0
- rollgate/evaluate.py +345 -0
- rollgate/metrics.py +567 -0
- rollgate/reasons.py +115 -0
- rollgate/retry.py +177 -0
- rollgate/tracing.py +434 -0
- rollgate-1.0.0.dist-info/METADATA +288 -0
- rollgate-1.0.0.dist-info/RECORD +14 -0
- rollgate-1.0.0.dist-info/WHEEL +4 -0
rollgate/__init__.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rollgate Python SDK - Feature flags made simple.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from rollgate import RollgateClient
|
|
6
|
+
|
|
7
|
+
client = RollgateClient(api_key="your-api-key")
|
|
8
|
+
await client.init()
|
|
9
|
+
|
|
10
|
+
if client.is_enabled("my-feature"):
|
|
11
|
+
# Feature is enabled
|
|
12
|
+
pass
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from rollgate.client import RollgateClient, RollgateConfig, UserContext
|
|
16
|
+
from rollgate.circuit_breaker import (
|
|
17
|
+
CircuitBreaker,
|
|
18
|
+
CircuitBreakerConfig,
|
|
19
|
+
CircuitOpenError,
|
|
20
|
+
CircuitState,
|
|
21
|
+
)
|
|
22
|
+
from rollgate.retry import RetryConfig, calculate_backoff, is_retryable_error
|
|
23
|
+
from rollgate.cache import CacheConfig, CacheStats, FlagCache
|
|
24
|
+
from rollgate.errors import (
|
|
25
|
+
RollgateError,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
NetworkError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
InternalError,
|
|
31
|
+
ErrorCategory,
|
|
32
|
+
)
|
|
33
|
+
from rollgate.dedup import RequestDeduplicator, DedupConfig
|
|
34
|
+
from rollgate.metrics import (
|
|
35
|
+
SDKMetrics,
|
|
36
|
+
MetricsSnapshot,
|
|
37
|
+
RequestMetrics,
|
|
38
|
+
FlagStats,
|
|
39
|
+
get_metrics,
|
|
40
|
+
create_metrics,
|
|
41
|
+
)
|
|
42
|
+
from rollgate.tracing import (
|
|
43
|
+
TraceContext,
|
|
44
|
+
RequestTrace,
|
|
45
|
+
TracingManager,
|
|
46
|
+
get_tracer,
|
|
47
|
+
create_tracer,
|
|
48
|
+
)
|
|
49
|
+
from rollgate.evaluate import (
|
|
50
|
+
Condition,
|
|
51
|
+
TargetingRule,
|
|
52
|
+
FlagRule,
|
|
53
|
+
RulesPayload,
|
|
54
|
+
EvaluationResult,
|
|
55
|
+
LocalEvaluator,
|
|
56
|
+
evaluate_flag,
|
|
57
|
+
evaluate_all_flags,
|
|
58
|
+
)
|
|
59
|
+
from rollgate.reasons import (
|
|
60
|
+
EvaluationReason,
|
|
61
|
+
EvaluationDetail,
|
|
62
|
+
EvaluationReasonKind,
|
|
63
|
+
EvaluationErrorKind,
|
|
64
|
+
off_reason,
|
|
65
|
+
target_match_reason,
|
|
66
|
+
rule_match_reason,
|
|
67
|
+
fallthrough_reason,
|
|
68
|
+
error_reason,
|
|
69
|
+
unknown_reason,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__version__ = "0.1.0"
|
|
73
|
+
__all__ = [
|
|
74
|
+
# Client
|
|
75
|
+
"RollgateClient",
|
|
76
|
+
"RollgateConfig",
|
|
77
|
+
"UserContext",
|
|
78
|
+
# Circuit Breaker
|
|
79
|
+
"CircuitBreaker",
|
|
80
|
+
"CircuitBreakerConfig",
|
|
81
|
+
"CircuitOpenError",
|
|
82
|
+
"CircuitState",
|
|
83
|
+
# Retry
|
|
84
|
+
"RetryConfig",
|
|
85
|
+
"calculate_backoff",
|
|
86
|
+
"is_retryable_error",
|
|
87
|
+
# Cache
|
|
88
|
+
"CacheConfig",
|
|
89
|
+
"CacheStats",
|
|
90
|
+
"FlagCache",
|
|
91
|
+
# Errors
|
|
92
|
+
"RollgateError",
|
|
93
|
+
"AuthenticationError",
|
|
94
|
+
"NetworkError",
|
|
95
|
+
"RateLimitError",
|
|
96
|
+
"ValidationError",
|
|
97
|
+
"InternalError",
|
|
98
|
+
"ErrorCategory",
|
|
99
|
+
# Dedup
|
|
100
|
+
"RequestDeduplicator",
|
|
101
|
+
"DedupConfig",
|
|
102
|
+
# Metrics
|
|
103
|
+
"SDKMetrics",
|
|
104
|
+
"MetricsSnapshot",
|
|
105
|
+
"RequestMetrics",
|
|
106
|
+
"FlagStats",
|
|
107
|
+
"get_metrics",
|
|
108
|
+
"create_metrics",
|
|
109
|
+
# Tracing
|
|
110
|
+
"TraceContext",
|
|
111
|
+
"RequestTrace",
|
|
112
|
+
"TracingManager",
|
|
113
|
+
"get_tracer",
|
|
114
|
+
"create_tracer",
|
|
115
|
+
# Evaluation
|
|
116
|
+
"Condition",
|
|
117
|
+
"TargetingRule",
|
|
118
|
+
"FlagRule",
|
|
119
|
+
"RulesPayload",
|
|
120
|
+
"EvaluationResult",
|
|
121
|
+
"LocalEvaluator",
|
|
122
|
+
"evaluate_flag",
|
|
123
|
+
"evaluate_all_flags",
|
|
124
|
+
# Reasons
|
|
125
|
+
"EvaluationReason",
|
|
126
|
+
"EvaluationDetail",
|
|
127
|
+
"EvaluationReasonKind",
|
|
128
|
+
"EvaluationErrorKind",
|
|
129
|
+
"off_reason",
|
|
130
|
+
"target_match_reason",
|
|
131
|
+
"rule_match_reason",
|
|
132
|
+
"fallthrough_reason",
|
|
133
|
+
"error_reason",
|
|
134
|
+
"unknown_reason",
|
|
135
|
+
]
|
rollgate/cache.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flag cache with stale-while-revalidate support.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Callable, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CacheConfig:
|
|
14
|
+
"""Configuration for cache behavior."""
|
|
15
|
+
|
|
16
|
+
ttl_ms: int = 300000
|
|
17
|
+
"""Time-to-live for fresh cache entries (default: 5 minutes)."""
|
|
18
|
+
|
|
19
|
+
stale_ttl_ms: int = 3600000
|
|
20
|
+
"""Time-to-live for stale cache entries (default: 1 hour)."""
|
|
21
|
+
|
|
22
|
+
persist_path: Optional[str] = None
|
|
23
|
+
"""File path for persistent cache."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CacheStats:
|
|
28
|
+
"""Cache statistics."""
|
|
29
|
+
|
|
30
|
+
hits: int = 0
|
|
31
|
+
misses: int = 0
|
|
32
|
+
stale_hits: int = 0
|
|
33
|
+
size: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CacheEntry:
|
|
38
|
+
"""Cache entry with metadata."""
|
|
39
|
+
|
|
40
|
+
value: Dict[str, bool]
|
|
41
|
+
timestamp: float
|
|
42
|
+
stale: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CacheResult:
|
|
47
|
+
"""Result of a cache lookup."""
|
|
48
|
+
|
|
49
|
+
flags: Dict[str, bool]
|
|
50
|
+
stale: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_CACHE_CONFIG = CacheConfig()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FlagCache:
|
|
57
|
+
"""
|
|
58
|
+
Flag cache with stale fallback support.
|
|
59
|
+
|
|
60
|
+
Features:
|
|
61
|
+
- In-memory caching with configurable TTL
|
|
62
|
+
- Stale-while-revalidate pattern
|
|
63
|
+
- File persistence
|
|
64
|
+
- Event callbacks for cache state changes
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: Optional[CacheConfig] = None):
|
|
68
|
+
self._config = config or DEFAULT_CACHE_CONFIG
|
|
69
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
70
|
+
self._stats = CacheStats()
|
|
71
|
+
self._callbacks: Dict[str, list[Callable]] = {
|
|
72
|
+
"cache_hit": [],
|
|
73
|
+
"cache_miss": [],
|
|
74
|
+
"cache_set": [],
|
|
75
|
+
"cache_expired": [],
|
|
76
|
+
"cache_stale": [],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def on(self, event: str, callback: Callable) -> None:
|
|
80
|
+
"""Register an event callback."""
|
|
81
|
+
if event in self._callbacks:
|
|
82
|
+
self._callbacks[event].append(callback)
|
|
83
|
+
|
|
84
|
+
def off(self, event: str, callback: Callable) -> None:
|
|
85
|
+
"""Remove an event callback."""
|
|
86
|
+
if event in self._callbacks and callback in self._callbacks[event]:
|
|
87
|
+
self._callbacks[event].remove(callback)
|
|
88
|
+
|
|
89
|
+
def _emit(self, event: str, *args) -> None:
|
|
90
|
+
"""Emit an event to all registered callbacks."""
|
|
91
|
+
for callback in self._callbacks.get(event, []):
|
|
92
|
+
try:
|
|
93
|
+
callback(*args)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def get(self, key: str = "flags") -> Optional[CacheResult]:
|
|
98
|
+
"""
|
|
99
|
+
Get cached flags.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
key: Cache key
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
CacheResult with flags and stale indicator, or None if not found
|
|
106
|
+
"""
|
|
107
|
+
entry = self._cache.get(key)
|
|
108
|
+
|
|
109
|
+
if entry is None:
|
|
110
|
+
self._stats.misses += 1
|
|
111
|
+
self._emit("cache_miss", key)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
age_ms = (time.time() - entry.timestamp) * 1000
|
|
115
|
+
|
|
116
|
+
# Fresh cache
|
|
117
|
+
if age_ms < self._config.ttl_ms:
|
|
118
|
+
self._stats.hits += 1
|
|
119
|
+
self._emit("cache_hit", key, False, age_ms)
|
|
120
|
+
return CacheResult(flags=entry.value, stale=False)
|
|
121
|
+
|
|
122
|
+
# Stale but usable
|
|
123
|
+
if age_ms < self._config.stale_ttl_ms:
|
|
124
|
+
self._stats.stale_hits += 1
|
|
125
|
+
self._emit("cache_hit", key, True, age_ms)
|
|
126
|
+
self._emit("cache_stale", key, age_ms)
|
|
127
|
+
return CacheResult(flags=entry.value, stale=True)
|
|
128
|
+
|
|
129
|
+
# Expired - remove from cache
|
|
130
|
+
del self._cache[key]
|
|
131
|
+
self._stats.size = len(self._cache)
|
|
132
|
+
self._stats.misses += 1
|
|
133
|
+
self._emit("cache_expired", key, age_ms)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def set(self, key: str, flags: Dict[str, bool]) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Store flags in cache.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
key: Cache key
|
|
142
|
+
flags: Flags dictionary
|
|
143
|
+
"""
|
|
144
|
+
entry = CacheEntry(
|
|
145
|
+
value=flags,
|
|
146
|
+
timestamp=time.time(),
|
|
147
|
+
stale=False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
self._cache[key] = entry
|
|
151
|
+
self._stats.size = len(self._cache)
|
|
152
|
+
self._emit("cache_set", key, len(flags))
|
|
153
|
+
|
|
154
|
+
# Persist if configured
|
|
155
|
+
self._persist()
|
|
156
|
+
|
|
157
|
+
def has_fresh(self, key: str = "flags") -> bool:
|
|
158
|
+
"""Check if cache has fresh data."""
|
|
159
|
+
entry = self._cache.get(key)
|
|
160
|
+
if entry is None:
|
|
161
|
+
return False
|
|
162
|
+
age_ms = (time.time() - entry.timestamp) * 1000
|
|
163
|
+
return age_ms < self._config.ttl_ms
|
|
164
|
+
|
|
165
|
+
def has_any(self, key: str = "flags") -> bool:
|
|
166
|
+
"""Check if cache has any data (fresh or stale)."""
|
|
167
|
+
entry = self._cache.get(key)
|
|
168
|
+
if entry is None:
|
|
169
|
+
return False
|
|
170
|
+
age_ms = (time.time() - entry.timestamp) * 1000
|
|
171
|
+
return age_ms < self._config.stale_ttl_ms
|
|
172
|
+
|
|
173
|
+
def clear(self) -> None:
|
|
174
|
+
"""Clear all cached data."""
|
|
175
|
+
self._cache.clear()
|
|
176
|
+
self._stats.size = 0
|
|
177
|
+
|
|
178
|
+
def get_stats(self) -> CacheStats:
|
|
179
|
+
"""Get cache statistics."""
|
|
180
|
+
return CacheStats(
|
|
181
|
+
hits=self._stats.hits,
|
|
182
|
+
misses=self._stats.misses,
|
|
183
|
+
stale_hits=self._stats.stale_hits,
|
|
184
|
+
size=self._stats.size,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def get_hit_rate(self) -> float:
|
|
188
|
+
"""Get hit rate (hits / (hits + misses))."""
|
|
189
|
+
total = self._stats.hits + self._stats.stale_hits + self._stats.misses
|
|
190
|
+
if total == 0:
|
|
191
|
+
return 0.0
|
|
192
|
+
return (self._stats.hits + self._stats.stale_hits) / total
|
|
193
|
+
|
|
194
|
+
def load(self) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Load cache from persistent storage.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
True if cache was loaded successfully
|
|
200
|
+
"""
|
|
201
|
+
if not self._config.persist_path:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
path = Path(self._config.persist_path)
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
data = json.loads(path.read_text())
|
|
210
|
+
entries = data.get("entries", [])
|
|
211
|
+
|
|
212
|
+
for key, entry_data in entries:
|
|
213
|
+
timestamp = entry_data.get("timestamp", 0)
|
|
214
|
+
age_ms = (time.time() - timestamp) * 1000
|
|
215
|
+
|
|
216
|
+
# Only restore if within stale TTL
|
|
217
|
+
if age_ms < self._config.stale_ttl_ms:
|
|
218
|
+
self._cache[key] = CacheEntry(
|
|
219
|
+
value=entry_data.get("value", {}),
|
|
220
|
+
timestamp=timestamp,
|
|
221
|
+
stale=age_ms >= self._config.ttl_ms,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self._stats.size = len(self._cache)
|
|
225
|
+
return True
|
|
226
|
+
except Exception:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def _persist(self) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Persist cache to storage.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if cache was persisted successfully
|
|
235
|
+
"""
|
|
236
|
+
if not self._config.persist_path:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
data = {
|
|
241
|
+
"version": 1,
|
|
242
|
+
"entries": [
|
|
243
|
+
[key, {"value": entry.value, "timestamp": entry.timestamp}]
|
|
244
|
+
for key, entry in self._cache.items()
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
path = Path(self._config.persist_path)
|
|
248
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
path.write_text(json.dumps(data))
|
|
250
|
+
return True
|
|
251
|
+
except Exception:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def close(self) -> None:
|
|
255
|
+
"""Cleanup resources and final persist."""
|
|
256
|
+
if self._config.persist_path:
|
|
257
|
+
self._persist()
|
|
258
|
+
# Clear all callbacks to prevent memory leaks
|
|
259
|
+
for event in self._callbacks:
|
|
260
|
+
self._callbacks[event].clear()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Circuit Breaker implementation.
|
|
3
|
+
|
|
4
|
+
Prevents cascading failures by failing fast when a service is down.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Callable, TypeVar, Optional, Awaitable, List
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CircuitState(str, Enum):
|
|
16
|
+
"""Circuit breaker states."""
|
|
17
|
+
|
|
18
|
+
CLOSED = "closed"
|
|
19
|
+
"""Normal operation, requests pass through."""
|
|
20
|
+
|
|
21
|
+
OPEN = "open"
|
|
22
|
+
"""Circuit is open, requests fail fast."""
|
|
23
|
+
|
|
24
|
+
HALF_OPEN = "half_open"
|
|
25
|
+
"""Testing if service has recovered."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CircuitBreakerConfig:
|
|
30
|
+
"""Configuration for circuit breaker behavior."""
|
|
31
|
+
|
|
32
|
+
failure_threshold: int = 5
|
|
33
|
+
"""Number of failures before opening circuit."""
|
|
34
|
+
|
|
35
|
+
recovery_timeout_ms: int = 30000
|
|
36
|
+
"""Time to wait before attempting recovery."""
|
|
37
|
+
|
|
38
|
+
monitoring_window_ms: int = 60000
|
|
39
|
+
"""Window for counting failures."""
|
|
40
|
+
|
|
41
|
+
success_threshold: int = 3
|
|
42
|
+
"""Number of successful requests in half-open to close circuit."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CircuitBreakerStats:
|
|
47
|
+
"""Statistics about circuit breaker state."""
|
|
48
|
+
|
|
49
|
+
state: CircuitState
|
|
50
|
+
failures: int
|
|
51
|
+
last_failure_time: Optional[float]
|
|
52
|
+
half_open_successes: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CircuitOpenError(Exception):
|
|
56
|
+
"""Raised when circuit breaker is open."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str = "Circuit breaker is open", retry_after_ms: int = 0):
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.retry_after_ms = retry_after_ms
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG = CircuitBreakerConfig()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CircuitBreaker:
|
|
67
|
+
"""
|
|
68
|
+
Circuit Breaker implementation.
|
|
69
|
+
|
|
70
|
+
States:
|
|
71
|
+
- CLOSED: Normal operation, all requests pass through
|
|
72
|
+
- OPEN: Service is down, all requests fail immediately
|
|
73
|
+
- HALF_OPEN: Testing recovery, limited requests allowed
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config: Optional[CircuitBreakerConfig] = None):
|
|
77
|
+
self._config = config or DEFAULT_CIRCUIT_BREAKER_CONFIG
|
|
78
|
+
self._state = CircuitState.CLOSED
|
|
79
|
+
self._failures: List[float] = []
|
|
80
|
+
self._last_failure_time: float = 0
|
|
81
|
+
self._half_open_successes: int = 0
|
|
82
|
+
self._callbacks: dict[str, list[Callable]] = {
|
|
83
|
+
"state_change": [],
|
|
84
|
+
"circuit_open": [],
|
|
85
|
+
"circuit_closed": [],
|
|
86
|
+
"circuit_half_open": [],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def on(self, event: str, callback: Callable) -> None:
|
|
90
|
+
"""Register an event callback."""
|
|
91
|
+
if event in self._callbacks:
|
|
92
|
+
self._callbacks[event].append(callback)
|
|
93
|
+
|
|
94
|
+
def off(self, event: str, callback: Callable) -> None:
|
|
95
|
+
"""Remove an event callback."""
|
|
96
|
+
if event in self._callbacks and callback in self._callbacks[event]:
|
|
97
|
+
self._callbacks[event].remove(callback)
|
|
98
|
+
|
|
99
|
+
def clear_callbacks(self) -> None:
|
|
100
|
+
"""Clear all callbacks (for cleanup)."""
|
|
101
|
+
for event in self._callbacks:
|
|
102
|
+
self._callbacks[event].clear()
|
|
103
|
+
|
|
104
|
+
def _emit(self, event: str, *args) -> None:
|
|
105
|
+
"""Emit an event to all registered callbacks."""
|
|
106
|
+
for callback in self._callbacks.get(event, []):
|
|
107
|
+
try:
|
|
108
|
+
callback(*args)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass # Don't let callback errors affect circuit breaker
|
|
111
|
+
|
|
112
|
+
async def execute(self, fn: Callable[[], Awaitable[T]]) -> T:
|
|
113
|
+
"""
|
|
114
|
+
Execute a function through the circuit breaker.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
fn: Async function to execute
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Result of the function
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
CircuitOpenError: If circuit is open
|
|
124
|
+
Exception: Any exception from the function
|
|
125
|
+
"""
|
|
126
|
+
# Check if circuit should transition from OPEN to HALF_OPEN
|
|
127
|
+
if self._state == CircuitState.OPEN:
|
|
128
|
+
if self._should_attempt_reset():
|
|
129
|
+
self._transition_to(CircuitState.HALF_OPEN)
|
|
130
|
+
else:
|
|
131
|
+
retry_after = self._get_time_until_retry()
|
|
132
|
+
raise CircuitOpenError(
|
|
133
|
+
f"Circuit breaker is open. Will retry after {retry_after}ms",
|
|
134
|
+
retry_after_ms=retry_after,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = await fn()
|
|
139
|
+
self._on_success()
|
|
140
|
+
return result
|
|
141
|
+
except Exception as error:
|
|
142
|
+
self._on_failure()
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def _on_success(self) -> None:
|
|
146
|
+
"""Handle successful request."""
|
|
147
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
148
|
+
self._half_open_successes += 1
|
|
149
|
+
|
|
150
|
+
if self._half_open_successes >= self._config.success_threshold:
|
|
151
|
+
self._reset()
|
|
152
|
+
|
|
153
|
+
# Clean up old failures outside monitoring window
|
|
154
|
+
self._cleanup_old_failures()
|
|
155
|
+
|
|
156
|
+
def _on_failure(self) -> None:
|
|
157
|
+
"""Handle failed request."""
|
|
158
|
+
now = time.time() * 1000 # Convert to ms
|
|
159
|
+
self._failures.append(now)
|
|
160
|
+
self._last_failure_time = now
|
|
161
|
+
|
|
162
|
+
# If in HALF_OPEN, immediately open the circuit
|
|
163
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
164
|
+
self._transition_to(CircuitState.OPEN)
|
|
165
|
+
self._half_open_successes = 0
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Clean up old failures and check threshold
|
|
169
|
+
self._cleanup_old_failures()
|
|
170
|
+
|
|
171
|
+
if len(self._failures) >= self._config.failure_threshold:
|
|
172
|
+
self._transition_to(CircuitState.OPEN)
|
|
173
|
+
|
|
174
|
+
def _cleanup_old_failures(self) -> None:
|
|
175
|
+
"""Remove failures outside the monitoring window."""
|
|
176
|
+
cutoff = time.time() * 1000 - self._config.monitoring_window_ms
|
|
177
|
+
self._failures = [t for t in self._failures if t > cutoff]
|
|
178
|
+
|
|
179
|
+
def _should_attempt_reset(self) -> bool:
|
|
180
|
+
"""Check if enough time has passed to attempt reset."""
|
|
181
|
+
elapsed = time.time() * 1000 - self._last_failure_time
|
|
182
|
+
return elapsed >= self._config.recovery_timeout_ms
|
|
183
|
+
|
|
184
|
+
def _get_time_until_retry(self) -> int:
|
|
185
|
+
"""Get time until next retry attempt is allowed."""
|
|
186
|
+
elapsed = time.time() * 1000 - self._last_failure_time
|
|
187
|
+
return max(0, int(self._config.recovery_timeout_ms - elapsed))
|
|
188
|
+
|
|
189
|
+
def _transition_to(self, new_state: CircuitState) -> None:
|
|
190
|
+
"""Transition to a new state."""
|
|
191
|
+
old_state = self._state
|
|
192
|
+
self._state = new_state
|
|
193
|
+
|
|
194
|
+
self._emit("state_change", old_state, new_state)
|
|
195
|
+
|
|
196
|
+
if new_state == CircuitState.OPEN:
|
|
197
|
+
self._emit("circuit_open", len(self._failures), self._last_failure_time)
|
|
198
|
+
elif new_state == CircuitState.CLOSED:
|
|
199
|
+
self._emit("circuit_closed")
|
|
200
|
+
elif new_state == CircuitState.HALF_OPEN:
|
|
201
|
+
self._emit("circuit_half_open")
|
|
202
|
+
|
|
203
|
+
def _reset(self) -> None:
|
|
204
|
+
"""Reset the circuit breaker to closed state."""
|
|
205
|
+
self._failures = []
|
|
206
|
+
self._half_open_successes = 0
|
|
207
|
+
self._transition_to(CircuitState.CLOSED)
|
|
208
|
+
|
|
209
|
+
def force_reset(self) -> None:
|
|
210
|
+
"""Force reset the circuit breaker (for testing/manual recovery)."""
|
|
211
|
+
self._reset()
|
|
212
|
+
|
|
213
|
+
def force_open(self) -> None:
|
|
214
|
+
"""Force open the circuit breaker (for testing/manual circuit trip)."""
|
|
215
|
+
self._last_failure_time = time.time() * 1000
|
|
216
|
+
self._transition_to(CircuitState.OPEN)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def state(self) -> CircuitState:
|
|
220
|
+
"""Get current circuit state."""
|
|
221
|
+
return self._state
|
|
222
|
+
|
|
223
|
+
def get_stats(self) -> CircuitBreakerStats:
|
|
224
|
+
"""Get circuit breaker statistics."""
|
|
225
|
+
return CircuitBreakerStats(
|
|
226
|
+
state=self._state,
|
|
227
|
+
failures=len(self._failures),
|
|
228
|
+
last_failure_time=self._last_failure_time if self._last_failure_time else None,
|
|
229
|
+
half_open_successes=self._half_open_successes,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def is_allowing_requests(self) -> bool:
|
|
233
|
+
"""Check if circuit is allowing requests."""
|
|
234
|
+
if self._state == CircuitState.CLOSED:
|
|
235
|
+
return True
|
|
236
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
237
|
+
return True
|
|
238
|
+
if self._state == CircuitState.OPEN and self._should_attempt_reset():
|
|
239
|
+
return True
|
|
240
|
+
return False
|