featureflip 2.2.0__tar.gz → 2.3.1__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.
- {featureflip-2.2.0 → featureflip-2.3.1}/PKG-INFO +1 -1
- {featureflip-2.2.0 → featureflip-2.3.1}/pyproject.toml +1 -1
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_core.py +48 -14
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_http.py +12 -2
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_polling.py +25 -2
- featureflip-2.3.1/src/featureflip/_streaming.py +280 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/client.py +4 -11
- featureflip-2.3.1/tests/golden/vectors.json +1044 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/integration/test_api.py +107 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_client.py +26 -13
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_core.py +23 -0
- featureflip-2.3.1/tests/test_golden.py +279 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_http.py +32 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_polling.py +17 -1
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_streaming.py +212 -2
- featureflip-2.2.0/src/featureflip/_streaming.py +0 -144
- {featureflip-2.2.0 → featureflip-2.3.1}/.gitignore +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/CHANGELOG.md +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/LICENSE +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/README.md +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_events.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_semver.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/config.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/context.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/detail.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/evaluation.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/exceptions.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/models.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/integration/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_config.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_context.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_detail.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_evaluation.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_events.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_exceptions.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_factory.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_models.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_semver.py +0 -0
|
@@ -37,6 +37,19 @@ logger = structlog.get_logger()
|
|
|
37
37
|
T = TypeVar("T")
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _resolve_user_id(context: dict[str, Any]) -> str | None:
|
|
41
|
+
"""Resolve the built-in user id from a raw context dict.
|
|
42
|
+
|
|
43
|
+
Honors the ``user_id`` / ``userId`` alias (universal across the engine and
|
|
44
|
+
every SDK) so a caller passing either spelling gets the same top-level
|
|
45
|
+
``userId`` on analytics events. Returns ``None`` when neither is present.
|
|
46
|
+
"""
|
|
47
|
+
raw = context.get("user_id")
|
|
48
|
+
if raw is None:
|
|
49
|
+
raw = context.get("userId")
|
|
50
|
+
return str(raw) if raw is not None else None
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
class _SharedFeatureflipCore:
|
|
41
54
|
"""Internal shared core owning all expensive resources of a FeatureflipClient.
|
|
42
55
|
|
|
@@ -299,8 +312,13 @@ class _SharedFeatureflipCore:
|
|
|
299
312
|
if self._http_client is None:
|
|
300
313
|
return
|
|
301
314
|
flags, segments = self._http_client.get_flags()
|
|
302
|
-
|
|
303
|
-
|
|
315
|
+
# get_flags() returns the full current snapshot, so REPLACE the store
|
|
316
|
+
# (drop flags/segments deleted server-side) rather than per-key
|
|
317
|
+
# merging — otherwise a delete only lands via a later flag.deleted
|
|
318
|
+
# delta or a poll/sync full-replace. Mirrors _on_polling_update.
|
|
319
|
+
with self._flags_lock:
|
|
320
|
+
self._flags = {flag.key: flag for flag in flags}
|
|
321
|
+
self._segments = {segment.key: segment for segment in segments}
|
|
304
322
|
except Exception as e:
|
|
305
323
|
logger.warning("streaming_segment_refetch_error", error=str(e))
|
|
306
324
|
|
|
@@ -323,15 +341,18 @@ class _SharedFeatureflipCore:
|
|
|
323
341
|
|
|
324
342
|
def _start_streaming(self) -> None:
|
|
325
343
|
# _start_streaming is only called from the production __init__ path,
|
|
326
|
-
# which has already validated that sdk_key
|
|
344
|
+
# which has already validated that sdk_key and http_client are not None.
|
|
327
345
|
assert self._sdk_key is not None
|
|
346
|
+
assert self._http_client is not None
|
|
328
347
|
self._streaming_handler = StreamingHandler(
|
|
329
348
|
sdk_key=self._sdk_key,
|
|
330
349
|
config=self._config,
|
|
350
|
+
http_client=self._http_client,
|
|
331
351
|
on_flag_updated=self._on_streaming_flag_updated,
|
|
332
352
|
on_flag_deleted=self._on_streaming_flag_deleted,
|
|
333
353
|
on_segment_updated=self._on_streaming_segment_updated,
|
|
334
354
|
on_error=self._on_streaming_error,
|
|
355
|
+
on_update=self._on_polling_update,
|
|
335
356
|
)
|
|
336
357
|
self._streaming_handler.start()
|
|
337
358
|
|
|
@@ -365,10 +386,13 @@ class _SharedFeatureflipCore:
|
|
|
365
386
|
"""Queue a custom event."""
|
|
366
387
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
367
388
|
return
|
|
389
|
+
# Send the backend SdkEventDto shape: top-level userId, no `context`.
|
|
390
|
+
# See apps/evaluation-api Controllers/SdkController.RecordEvents — the
|
|
391
|
+
# backend has no context/value/reason field, so anything else is dropped.
|
|
368
392
|
event = {
|
|
369
393
|
"type": "Custom",
|
|
370
394
|
"flagKey": event_name,
|
|
371
|
-
"
|
|
395
|
+
"userId": _resolve_user_id(context or {}),
|
|
372
396
|
"metadata": metadata or {},
|
|
373
397
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
374
398
|
}
|
|
@@ -378,10 +402,19 @@ class _SharedFeatureflipCore:
|
|
|
378
402
|
"""Send user attributes for segment building."""
|
|
379
403
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
380
404
|
return
|
|
405
|
+
# Promote the user id to the top-level userId field and put the remaining
|
|
406
|
+
# attributes in metadata (stripping both alias spellings so the identity
|
|
407
|
+
# isn't duplicated inside the metadata bag).
|
|
408
|
+
attributes = {
|
|
409
|
+
k: v
|
|
410
|
+
for k, v in (context or {}).items()
|
|
411
|
+
if k not in ("user_id", "userId")
|
|
412
|
+
}
|
|
381
413
|
event = {
|
|
382
414
|
"type": "Identify",
|
|
383
415
|
"flagKey": "$identify",
|
|
384
|
-
"
|
|
416
|
+
"userId": _resolve_user_id(context or {}),
|
|
417
|
+
"metadata": attributes,
|
|
385
418
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
386
419
|
}
|
|
387
420
|
self._event_processor.queue_event(event)
|
|
@@ -394,24 +427,25 @@ class _SharedFeatureflipCore:
|
|
|
394
427
|
def queue_evaluation_event(
|
|
395
428
|
self,
|
|
396
429
|
key: str,
|
|
397
|
-
value: Any,
|
|
398
430
|
context: dict[str, Any],
|
|
399
|
-
|
|
400
|
-
rule_id: str | None = None,
|
|
431
|
+
variation_key: str | None,
|
|
401
432
|
) -> None:
|
|
402
|
-
"""Queue an evaluation event (called by the handle's variation() method).
|
|
433
|
+
"""Queue an evaluation event (called by the handle's variation() method).
|
|
434
|
+
|
|
435
|
+
Emits the backend SdkEventDto shape — top-level ``userId`` and
|
|
436
|
+
``variation`` — mirroring the JS SDK. The backend has no context/value/
|
|
437
|
+
reason/ruleId fields, so they are not sent (System.Text.Json would drop
|
|
438
|
+
them and bind UserId/Variation to null; see #1920).
|
|
439
|
+
"""
|
|
403
440
|
if self._event_processor is None:
|
|
404
441
|
return
|
|
405
442
|
event: dict[str, Any] = {
|
|
406
443
|
"type": "Evaluation",
|
|
407
444
|
"flagKey": key,
|
|
408
|
-
"
|
|
409
|
-
"
|
|
410
|
-
"reason": reason.value,
|
|
445
|
+
"userId": _resolve_user_id(context or {}),
|
|
446
|
+
"variation": variation_key,
|
|
411
447
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
412
448
|
}
|
|
413
|
-
if rule_id is not None:
|
|
414
|
-
event["ruleId"] = rule_id
|
|
415
449
|
self._event_processor.queue_event(event)
|
|
416
450
|
|
|
417
451
|
# --- Shutdown ---
|
|
@@ -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
|
-
|
|
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.
|
|
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)
|
|
@@ -103,10 +103,8 @@ class FeatureflipClient:
|
|
|
103
103
|
if track:
|
|
104
104
|
self._core.queue_evaluation_event(
|
|
105
105
|
key=key,
|
|
106
|
-
value=value,
|
|
107
106
|
context=context or {},
|
|
108
|
-
|
|
109
|
-
rule_id=detail.rule_id,
|
|
107
|
+
variation_key=detail.variation_key,
|
|
110
108
|
)
|
|
111
109
|
return cast("T", value)
|
|
112
110
|
except Exception as e:
|
|
@@ -254,20 +252,15 @@ class FeatureflipClient:
|
|
|
254
252
|
def _queue_evaluation_event(
|
|
255
253
|
self,
|
|
256
254
|
key: str,
|
|
257
|
-
value: Any,
|
|
258
255
|
context: dict[str, Any],
|
|
259
|
-
|
|
260
|
-
rule_id: str | None = None,
|
|
256
|
+
variation_key: str | None = None,
|
|
261
257
|
) -> None:
|
|
262
258
|
"""Queue an evaluation event. Delegates to the shared core.
|
|
263
259
|
|
|
264
|
-
Preserved for
|
|
265
|
-
this method directly on the client.
|
|
260
|
+
Preserved for tests that call this method directly on the client.
|
|
266
261
|
"""
|
|
267
262
|
self._core.queue_evaluation_event(
|
|
268
263
|
key=key,
|
|
269
|
-
value=value,
|
|
270
264
|
context=context,
|
|
271
|
-
|
|
272
|
-
rule_id=rule_id,
|
|
265
|
+
variation_key=variation_key,
|
|
273
266
|
)
|