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 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