featureflip 2.2.0__tar.gz → 2.3.0__tar.gz

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.
Files changed (40) hide show
  1. {featureflip-2.2.0 → featureflip-2.3.0}/PKG-INFO +1 -1
  2. {featureflip-2.2.0 → featureflip-2.3.0}/pyproject.toml +1 -1
  3. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_core.py +11 -3
  4. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_http.py +12 -2
  5. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_polling.py +25 -2
  6. featureflip-2.3.0/src/featureflip/_streaming.py +280 -0
  7. featureflip-2.3.0/tests/golden/vectors.json +1044 -0
  8. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_core.py +23 -0
  9. featureflip-2.3.0/tests/test_golden.py +279 -0
  10. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_http.py +32 -0
  11. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_polling.py +17 -1
  12. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_streaming.py +212 -2
  13. featureflip-2.2.0/src/featureflip/_streaming.py +0 -144
  14. {featureflip-2.2.0 → featureflip-2.3.0}/.gitignore +0 -0
  15. {featureflip-2.2.0 → featureflip-2.3.0}/CHANGELOG.md +0 -0
  16. {featureflip-2.2.0 → featureflip-2.3.0}/LICENSE +0 -0
  17. {featureflip-2.2.0 → featureflip-2.3.0}/README.md +0 -0
  18. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/__init__.py +0 -0
  19. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_events.py +0 -0
  20. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_semver.py +0 -0
  21. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/client.py +0 -0
  22. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/config.py +0 -0
  23. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/context.py +0 -0
  24. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/detail.py +0 -0
  25. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/evaluation.py +0 -0
  26. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/exceptions.py +0 -0
  27. {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/models.py +0 -0
  28. {featureflip-2.2.0 → featureflip-2.3.0}/tests/__init__.py +0 -0
  29. {featureflip-2.2.0 → featureflip-2.3.0}/tests/integration/__init__.py +0 -0
  30. {featureflip-2.2.0 → featureflip-2.3.0}/tests/integration/test_api.py +0 -0
  31. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_client.py +0 -0
  32. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_config.py +0 -0
  33. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_context.py +0 -0
  34. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_detail.py +0 -0
  35. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_evaluation.py +0 -0
  36. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_events.py +0 -0
  37. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_exceptions.py +0 -0
  38. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_factory.py +0 -0
  39. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_models.py +0 -0
  40. {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_semver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: featureflip
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Python SDK for Featureflip - a feature flag SaaS platform
5
5
  Project-URL: Homepage, https://featureflip.io
6
6
  Project-URL: Documentation, https://featureflip.io/docs/sdks/python/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "featureflip"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -299,8 +299,13 @@ class _SharedFeatureflipCore:
299
299
  if self._http_client is None:
300
300
  return
301
301
  flags, segments = self._http_client.get_flags()
302
- self._update_flags(flags)
303
- self._update_segments(segments)
302
+ # get_flags() returns the full current snapshot, so REPLACE the store
303
+ # (drop flags/segments deleted server-side) rather than per-key
304
+ # merging — otherwise a delete only lands via a later flag.deleted
305
+ # delta or a poll/sync full-replace. Mirrors _on_polling_update.
306
+ with self._flags_lock:
307
+ self._flags = {flag.key: flag for flag in flags}
308
+ self._segments = {segment.key: segment for segment in segments}
304
309
  except Exception as e:
305
310
  logger.warning("streaming_segment_refetch_error", error=str(e))
306
311
 
@@ -323,15 +328,18 @@ class _SharedFeatureflipCore:
323
328
 
324
329
  def _start_streaming(self) -> None:
325
330
  # _start_streaming is only called from the production __init__ path,
326
- # which has already validated that sdk_key is not None.
331
+ # which has already validated that sdk_key and http_client are not None.
327
332
  assert self._sdk_key is not None
333
+ assert self._http_client is not None
328
334
  self._streaming_handler = StreamingHandler(
329
335
  sdk_key=self._sdk_key,
330
336
  config=self._config,
337
+ http_client=self._http_client,
331
338
  on_flag_updated=self._on_streaming_flag_updated,
332
339
  on_flag_deleted=self._on_streaming_flag_deleted,
333
340
  on_segment_updated=self._on_streaming_segment_updated,
334
341
  on_error=self._on_streaming_error,
342
+ on_update=self._on_polling_update,
335
343
  )
336
344
  self._streaming_handler.start()
337
345
 
@@ -81,10 +81,20 @@ class HttpClient:
81
81
  logger.debug("Fetching flags from API")
82
82
  response = self._client.get("/v1/sdk/flags")
83
83
  response.raise_for_status()
84
- data = response.json()
84
+ flags, segments = self.parse_flags_response(response.json())
85
+ logger.debug("Fetched flags", flag_count=len(flags), segment_count=len(segments))
86
+ return flags, segments
87
+
88
+ def parse_flags_response(
89
+ self, data: dict[str, Any]
90
+ ) -> tuple[list[FlagConfiguration], list[Segment]]:
91
+ """Parse a GET /v1/sdk/flags-shaped snapshot into models.
92
+
93
+ Reused for the connect-time ``sync`` SSE snapshot, which carries the
94
+ identical payload shape inline (no extra HTTP round-trip).
95
+ """
85
96
  flags = [self._parse_flag(f) for f in data.get("flags", [])]
86
97
  segments = [self._parse_segment(s) for s in data.get("segments", [])]
87
- logger.debug("Fetched flags", flag_count=len(flags), segment_count=len(segments))
88
98
  return flags, segments
89
99
 
90
100
  def get_flag(self, key: str) -> FlagConfiguration:
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
15
15
 
16
16
  logger = structlog.get_logger()
17
17
 
18
+ # Ceiling for the fallback poller's exponential backoff during a sustained
19
+ # outage. Retry-forever, but escalate the interval so a long outage doesn't keep
20
+ # hammering the eval-api every poll_interval.
21
+ _MAX_POLL_BACKOFF_SECONDS = 300.0
22
+
18
23
 
19
24
  class HttpClientProtocol(Protocol):
20
25
  """Protocol for HTTP client to enable loose coupling."""
@@ -69,13 +74,31 @@ class PollingHandler:
69
74
  logger.info("polling_stopped")
70
75
 
71
76
  def _run(self) -> None:
72
- """Main polling loop."""
77
+ """Main polling loop.
78
+
79
+ Polls at ``poll_interval`` while healthy; on consecutive failures the
80
+ wait escalates exponentially (capped) so a sustained outage backs off
81
+ rather than hammering the eval-api at a fixed interval forever.
82
+ """
83
+ consecutive_failures = 0
73
84
  while not self._stop_event.is_set():
74
85
  try:
75
86
  flags, segments = self._http.get_flags()
76
87
  self._on_update(flags, segments)
88
+ consecutive_failures = 0
77
89
  logger.debug("polling_success", flag_count=len(flags))
78
90
  except Exception as e:
91
+ consecutive_failures += 1
79
92
  logger.warning("polling_error", error=str(e))
80
93
  self._on_error(e)
81
- self._stop_event.wait(self._config.poll_interval)
94
+ self._stop_event.wait(self._backoff_delay(consecutive_failures))
95
+
96
+ def _backoff_delay(self, consecutive_failures: int) -> float:
97
+ """Base interval on success/first failure, then capped exponential."""
98
+ if consecutive_failures <= 1:
99
+ return self._config.poll_interval
100
+ # Cap the exponent so a very high failure count can't overflow the float
101
+ # multiply; the result is capped anyway.
102
+ exponent = min(consecutive_failures - 1, 20)
103
+ delay: float = self._config.poll_interval * (2**exponent)
104
+ return min(delay, _MAX_POLL_BACKOFF_SECONDS)
@@ -0,0 +1,280 @@
1
+ """SSE streaming handler for real-time flag updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import threading
8
+ from typing import TYPE_CHECKING, Any, Protocol
9
+
10
+ import httpx
11
+ import structlog
12
+ from httpx_sse import connect_sse
13
+
14
+ from featureflip._polling import PollingHandler
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable
18
+
19
+ from featureflip.config import Config
20
+ from featureflip.models import FlagConfiguration, Segment
21
+
22
+ logger = structlog.get_logger()
23
+
24
+
25
+ class _StreamingHttpClient(Protocol):
26
+ """HTTP client surface the streaming handler needs.
27
+
28
+ Structural (duck-typed) so the concrete ``HttpClient`` satisfies it and it
29
+ can also feed the polling fallback (``get_flags``).
30
+ """
31
+
32
+ def get_flags(self) -> tuple[list[FlagConfiguration], list[Segment]]: ...
33
+
34
+ def parse_flags_response(
35
+ self, data: dict[str, Any]
36
+ ) -> tuple[list[FlagConfiguration], list[Segment]]: ...
37
+
38
+
39
+ # Reconnect backoff (mirrors go-sdk constants for cross-SDK consistency).
40
+ _RECONNECT_BASE_DELAY = 3.0 # seconds
41
+ _MAX_RECONNECT_DELAY = 30.0 # cap
42
+ _FALLBACK_THRESHOLD = 5 # consecutive stream failures before polling fallback
43
+ # Liveness watchdog: a finite SSE read timeout well above the ~30s server ping
44
+ # (≈3 missed pings). With read=None a half-open socket (silent LB/NAT idle-drop
45
+ # or partition, no FIN/RST) would block the reader forever and never reconnect;
46
+ # a finite read timeout surfaces as a ReadTimeout that _connect() catches and
47
+ # the reconnect/backoff/fallback loop handles.
48
+ _SSE_READ_TIMEOUT_SECONDS = 90.0
49
+
50
+
51
+ class StreamingHandler:
52
+ """Handles SSE streaming for real-time flag updates.
53
+
54
+ This handler connects to the Featureflip streaming endpoint and
55
+ receives real-time updates when flags are modified. It runs in a
56
+ background thread and calls the provided callbacks when events occur.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ sdk_key: str,
62
+ config: Config,
63
+ http_client: _StreamingHttpClient,
64
+ on_flag_updated: Callable[[str], None],
65
+ on_flag_deleted: Callable[[str], None],
66
+ on_segment_updated: Callable[[], None],
67
+ on_error: Callable[[Exception], None],
68
+ on_update: Callable[[list[FlagConfiguration], list[Segment]], None],
69
+ ) -> None:
70
+ """Initialize the streaming handler.
71
+
72
+ Args:
73
+ sdk_key: The SDK key for authentication.
74
+ config: Client configuration options.
75
+ http_client: HTTP client — parses the connect-time snapshot and backs the polling fallback.
76
+ on_flag_updated: Callback invoked with flag key when a flag is created or updated.
77
+ on_flag_deleted: Callback invoked with flag key when a flag is deleted.
78
+ on_segment_updated: Callback invoked when a segment is updated (triggers full refetch).
79
+ on_error: Callback invoked when an error occurs.
80
+ on_update: Callback invoked with a full (flags, segments) snapshot — a full store replace.
81
+ """
82
+ self._sdk_key = sdk_key
83
+ self._config = config
84
+ self._http = http_client
85
+ self._on_flag_updated = on_flag_updated
86
+ self._on_flag_deleted = on_flag_deleted
87
+ self._on_segment_updated = on_segment_updated
88
+ self._on_error = on_error
89
+ self._on_update = on_update
90
+ self._stop_event = threading.Event()
91
+ self._thread: threading.Thread | None = None
92
+ # Reconnect tunables (defaulted from module constants; tests shrink them).
93
+ self._reconnect_base_delay = _RECONNECT_BASE_DELAY
94
+ self._max_reconnect_delay = _MAX_RECONNECT_DELAY
95
+ self._fallback_threshold = _FALLBACK_THRESHOLD
96
+ self._fallback_lock = threading.Lock()
97
+ self._fallback_poller: PollingHandler | None = None
98
+
99
+ def start(self) -> None:
100
+ """Start the streaming connection in a background thread."""
101
+ self._stop_event.clear()
102
+ self._thread = threading.Thread(target=self._run, daemon=True)
103
+ self._thread.start()
104
+ logger.info("streaming_started")
105
+
106
+ def stop(self) -> None:
107
+ """Stop the streaming connection."""
108
+ # Set the stop event BEFORE reaping so a concurrent
109
+ # _start_fallback_polling (which checks the event under the lock) can't
110
+ # leak a poller.
111
+ self._stop_event.set()
112
+ self._stop_fallback_polling()
113
+ if self._thread and self._thread.is_alive():
114
+ self._thread.join(timeout=5.0)
115
+ logger.info("streaming_stopped")
116
+
117
+ def _run(self) -> None:
118
+ """Reconnect forever with capped exponential backoff.
119
+
120
+ Never gives up while the handler is alive: a clean EOF or any error
121
+ both back off before reconnecting (no busy-loop). After
122
+ ``_fallback_threshold`` consecutive failures, also starts a polling
123
+ fallback (see _start_fallback_polling); the stream keeps retrying.
124
+ """
125
+ consecutive_failures = 0
126
+ while not self._stop_event.is_set():
127
+ reached = self._connect()
128
+ if self._stop_event.is_set():
129
+ break
130
+ if reached:
131
+ consecutive_failures = 0
132
+ self._stop_fallback_polling()
133
+ else:
134
+ consecutive_failures += 1
135
+ if consecutive_failures >= self._fallback_threshold:
136
+ self._start_fallback_polling()
137
+ self._stop_event.wait(self._backoff_delay(consecutive_failures))
138
+
139
+ def _connect(self) -> bool:
140
+ """Connect to the SSE stream and process events.
141
+
142
+ Returns True if a live stream was established (at least one event
143
+ received — the server always sends a ``sync`` frame first), False if
144
+ the connection could not be established.
145
+ """
146
+ url = self._get_stream_url()
147
+ headers = self._get_headers()
148
+ reached = False
149
+ try:
150
+ with (
151
+ httpx.Client(
152
+ timeout=httpx.Timeout(
153
+ connect=self._config.connect_timeout,
154
+ # Finite read timeout as a liveness watchdog — see
155
+ # _SSE_READ_TIMEOUT_SECONDS. Healthy streams reset it on
156
+ # every event/ping (~30s), so it only fires on a
157
+ # half-open socket, triggering a reconnect.
158
+ read=_SSE_READ_TIMEOUT_SECONDS,
159
+ write=self._config.read_timeout,
160
+ pool=self._config.connect_timeout,
161
+ ),
162
+ ) as client,
163
+ connect_sse(client, "GET", url, headers=headers) as event_source,
164
+ ):
165
+ logger.debug("sse_connected", url=url)
166
+ for event in event_source.iter_sse():
167
+ if not reached:
168
+ reached = True
169
+ # The stream is live (the server sends a `sync` frame
170
+ # first). Reap the fallback poller now so a healthy
171
+ # stream is the sole data source. _connect() blocks in
172
+ # iter_sse() for the whole lifetime of a healthy stream,
173
+ # so reaping only after it returns (in _run) would leave
174
+ # the poller running for the entire healthy period — its
175
+ # periodic full-store replaces can revert an SSE delta
176
+ # applied mid-flight (stale value flapping).
177
+ self._stop_fallback_polling()
178
+ if self._stop_event.is_set():
179
+ break
180
+ self._handle_event(event.event, event.data)
181
+ except Exception as e:
182
+ logger.warning("streaming_error", error=str(e))
183
+ self._on_error(e)
184
+ return reached
185
+
186
+ def _backoff_delay(self, failures: int) -> float:
187
+ """Capped exponential backoff.
188
+
189
+ Base for the first (re)connect, then doubling up to the cap, with
190
+ [d/2, d) jitter once escalating (thundering-herd avoidance).
191
+ """
192
+ if failures <= 1:
193
+ return self._reconnect_base_delay
194
+ delay = self._reconnect_base_delay
195
+ for _ in range(1, failures):
196
+ delay *= 2
197
+ if delay >= self._max_reconnect_delay:
198
+ return self._with_jitter(self._max_reconnect_delay)
199
+ return self._with_jitter(delay)
200
+
201
+ def _with_jitter(self, delay: float) -> float:
202
+ """Return a value in [delay/2, delay)."""
203
+ half = delay / 2
204
+ return half + random.random() * half
205
+
206
+ def _start_fallback_polling(self) -> None:
207
+ """Start a parallel polling fallback (full re-fetch, retries forever).
208
+
209
+ Idempotent. A start racing a concurrent stop() is a no-op: stop() sets
210
+ the stop event before reaping, so a set event under the lock means a
211
+ post-stop start would leak a poller — bail instead.
212
+ """
213
+ with self._fallback_lock:
214
+ if self._fallback_poller is not None or self._stop_event.is_set():
215
+ return
216
+ poller = PollingHandler(
217
+ http_client=self._http,
218
+ config=self._config,
219
+ on_update=self._on_update,
220
+ on_error=self._on_error,
221
+ )
222
+ poller.start()
223
+ self._fallback_poller = poller
224
+ logger.warning("streaming_fallback_polling_started")
225
+
226
+ def _stop_fallback_polling(self) -> None:
227
+ with self._fallback_lock:
228
+ if self._fallback_poller is not None:
229
+ self._fallback_poller.stop()
230
+ self._fallback_poller = None
231
+ logger.info("streaming_fallback_polling_stopped")
232
+
233
+ def _get_stream_url(self) -> str:
234
+ """Get the streaming endpoint URL.
235
+
236
+ Returns:
237
+ The full URL for the SSE stream endpoint.
238
+ """
239
+ return f"{self._config.base_url}/v1/sdk/stream"
240
+
241
+ def _get_headers(self) -> dict[str, str]:
242
+ """Get the headers for the streaming request.
243
+
244
+ Returns:
245
+ Dictionary of HTTP headers.
246
+ """
247
+ return {
248
+ "Authorization": self._sdk_key,
249
+ "User-Agent": "featureflip-python/0.1.0",
250
+ "Accept": "text/event-stream",
251
+ }
252
+
253
+ def _handle_event(self, event_type: str, data: str) -> None:
254
+ """Handle an SSE event."""
255
+ try:
256
+ if event_type in ("flag.created", "flag.updated"):
257
+ payload = json.loads(data)
258
+ key = payload.get("key")
259
+ if key:
260
+ self._on_flag_updated(key)
261
+ logger.debug("flag_updated_via_sse", key=key)
262
+ elif event_type == "flag.deleted":
263
+ payload = json.loads(data)
264
+ key = payload.get("key")
265
+ if key:
266
+ self._on_flag_deleted(key)
267
+ logger.debug("flag_deleted_via_sse", key=key)
268
+ elif event_type == "segment.updated":
269
+ self._on_segment_updated()
270
+ logger.debug("segment_updated_via_sse")
271
+ elif event_type == "sync":
272
+ # Full config snapshot the server sends on (re)connect. Replace
273
+ # the whole store so flags changed OR deleted during a disconnect
274
+ # are re-synced. Full replace, never a per-key merge.
275
+ flags, segments = self._http.parse_flags_response(json.loads(data))
276
+ self._on_update(flags, segments)
277
+ logger.debug("sync_snapshot_applied", flag_count=len(flags))
278
+ # Unknown events are ignored silently
279
+ except Exception as e:
280
+ logger.error("event_parse_error", error=str(e), event_type=event_type)