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.
- {featureflip-2.2.0 → featureflip-2.3.0}/PKG-INFO +1 -1
- {featureflip-2.2.0 → featureflip-2.3.0}/pyproject.toml +1 -1
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_core.py +11 -3
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_http.py +12 -2
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_polling.py +25 -2
- featureflip-2.3.0/src/featureflip/_streaming.py +280 -0
- featureflip-2.3.0/tests/golden/vectors.json +1044 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_core.py +23 -0
- featureflip-2.3.0/tests/test_golden.py +279 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_http.py +32 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_polling.py +17 -1
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_streaming.py +212 -2
- featureflip-2.2.0/src/featureflip/_streaming.py +0 -144
- {featureflip-2.2.0 → featureflip-2.3.0}/.gitignore +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/CHANGELOG.md +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/LICENSE +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/README.md +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_events.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/_semver.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/client.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/config.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/context.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/detail.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/evaluation.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/exceptions.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/src/featureflip/models.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/integration/__init__.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/integration/test_api.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_client.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_config.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_context.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_detail.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_evaluation.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_events.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_exceptions.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_factory.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_models.py +0 -0
- {featureflip-2.2.0 → featureflip-2.3.0}/tests/test_semver.py +0 -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
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
|
|
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)
|