Skip to content

Commit e55dca5

Browse files
committed
chore: Add FDv2-compatible contract test support
1 parent 8e0eae3 commit e55dca5

File tree

9 files changed

+287
-94
lines changed

9 files changed

+287
-94
lines changed

contract-tests/client_entity.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
Stage
1616
)
1717
from ldclient.config import BigSegmentsConfig
18+
from ldclient.impl.datasourcev2.polling import PollingDataSourceBuilder
19+
from ldclient.impl.datasystem.config import (
20+
custom,
21+
polling_ds_builder,
22+
streaming_ds_builder
23+
)
1824

1925

2026
class ClientEntity:
@@ -29,7 +35,70 @@ def __init__(self, tag, config):
2935
'version': tags.get('applicationVersion', ''),
3036
}
3137

32-
if config.get("streaming") is not None:
38+
datasystem_config = config.get('dataSystem')
39+
if datasystem_config is not None:
40+
datasystem = custom()
41+
42+
init_configs = datasystem_config.get('initializers')
43+
if init_configs is not None:
44+
initializers = []
45+
for init_config in init_configs:
46+
polling = init_config.get('polling')
47+
if polling is not None:
48+
if polling.get("baseUri") is not None:
49+
opts["base_uri"] = polling["baseUri"]
50+
_set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval")
51+
polling = polling_ds_builder()
52+
initializers.append(polling)
53+
54+
datasystem.initializers(initializers)
55+
sync_config = datasystem_config.get('synchronizers')
56+
if sync_config is not None:
57+
primary = sync_config.get('primary')
58+
secondary = sync_config.get('secondary')
59+
60+
primary_builder = None
61+
secondary_builder = None
62+
63+
if primary is not None:
64+
streaming = primary.get('streaming')
65+
if streaming is not None:
66+
primary_builder = streaming_ds_builder()
67+
if streaming.get("baseUri") is not None:
68+
opts["stream_uri"] = streaming["baseUri"]
69+
_set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay")
70+
primary_builder = streaming_ds_builder()
71+
elif primary.get('polling') is not None:
72+
polling = primary.get('polling')
73+
if polling.get("baseUri") is not None:
74+
opts["base_uri"] = polling["baseUri"]
75+
_set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval")
76+
primary_builder = polling_ds_builder()
77+
78+
if secondary is not None:
79+
streaming = secondary.get('streaming')
80+
if streaming is not None:
81+
secondary_builder = streaming_ds_builder()
82+
if streaming.get("baseUri") is not None:
83+
opts["stream_uri"] = streaming["baseUri"]
84+
_set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay")
85+
secondary_builder = streaming_ds_builder()
86+
elif secondary.get('polling') is not None:
87+
polling = secondary.get('polling')
88+
if polling.get("baseUri") is not None:
89+
opts["base_uri"] = polling["baseUri"]
90+
_set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval")
91+
secondary_builder = polling_ds_builder()
92+
93+
if primary_builder is not None:
94+
datasystem.synchronizers(primary_builder, secondary_builder)
95+
96+
if datasystem_config.get("payloadFilter") is not None:
97+
opts["payload_filter_key"] = datasystem_config["payloadFilter"]
98+
99+
opts["datasystem_config"] = datasystem.build()
100+
101+
elif config.get("streaming") is not None:
33102
streaming = config["streaming"]
34103
if streaming.get("baseUri") is not None:
35104
opts["stream_uri"] = streaming["baseUri"]

ldclient/impl/datasourcev2/polling.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __init__(
8686
self._requester = requester
8787
self._poll_interval = poll_interval
8888
self._event = Event()
89+
self._stop = Event()
8990
self._task = RepeatingTask(
9091
"ldclient.datasource.polling", poll_interval, 0, self._poll
9192
)
@@ -108,7 +109,8 @@ def sync(self) -> Generator[Update, None, None]:
108109
occurs.
109110
"""
110111
log.info("Starting PollingDataSourceV2 synchronizer")
111-
while True:
112+
self._stop.clear()
113+
while self._stop.is_set() is False:
112114
result = self._requester.fetch(None)
113115
if isinstance(result, _Fail):
114116
if isinstance(result.exception, UnsuccessfulResponseException):
@@ -161,6 +163,13 @@ def sync(self) -> Generator[Update, None, None]:
161163
if self._event.wait(self._poll_interval):
162164
break
163165

166+
def stop(self):
167+
"""Stops the synchronizer."""
168+
log.info("Stopping PollingDataSourceV2 synchronizer")
169+
self._event.set()
170+
self._task.stop()
171+
self._stop.set()
172+
164173
def _poll(self) -> BasisResult:
165174
try:
166175
# TODO(fdv2): Need to pass the selector through

ldclient/impl/datasourcev2/streaming.py

Lines changed: 26 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Callable, Generator, Iterable, Optional, Protocol, Tuple
1010
from urllib import parse
1111

12-
from ld_eventsource import SSEClient as SSEClientImpl
12+
from ld_eventsource import SSEClient
1313
from ld_eventsource.actions import Action, Event, Fault
1414
from ld_eventsource.config import (
1515
ConnectStrategy,
@@ -54,33 +54,19 @@
5454
STREAMING_ENDPOINT = "/sdk/stream"
5555

5656

57-
class SSEClient(Protocol): # pylint: disable=too-few-public-methods
58-
"""
59-
SSEClient is a protocol that defines the interface for a client that can
60-
connect to a Server-Sent Events (SSE) stream and provide an iterable of
61-
actions received from that stream.
62-
"""
63-
64-
@property
65-
@abstractmethod
66-
def all(self) -> Iterable[Action]:
67-
"""
68-
Returns an iterable of all actions received from the SSE stream.
69-
"""
70-
raise NotImplementedError
71-
72-
7357
SseClientBuilder = Callable[[Config], SSEClient]
7458

7559

7660
# TODO(sdk-1391): Pass a selector-retrieving function through so it can
7761
# re-connect with the last known status.
78-
def create_sse_client(config: Config) -> SSEClientImpl:
62+
def create_sse_client(config: Config) -> SSEClient:
7963
""" "
80-
create_sse_client creates an SSEClientImpl instance configured to connect
64+
create_sse_client creates an SSEClient instance configured to connect
8165
to the LaunchDarkly streaming endpoint.
8266
"""
8367
uri = config.stream_base_uri + STREAMING_ENDPOINT
68+
if config.payload_filter_key is not None:
69+
uri += "?%s" % parse.urlencode({"filter": config.payload_filter_key})
8470

8571
# We don't want the stream to use the same read timeout as the rest of the SDK.
8672
http_factory = _http_factory(config)
@@ -90,7 +76,7 @@ def create_sse_client(config: Config) -> SSEClientImpl:
9076
override_read_timeout=STREAM_READ_TIMEOUT,
9177
)
9278

93-
return SSEClientImpl(
79+
return SSEClient(
9480
connect=ConnectStrategy.http(
9581
url=uri,
9682
headers=http_factory.base_headers,
@@ -119,15 +105,11 @@ class StreamingDataSource(Synchronizer):
119105
from the streaming data source.
120106
"""
121107

122-
def __init__(
123-
self, config: Config, sse_client_builder: SseClientBuilder = create_sse_client
124-
):
125-
self._sse_client_builder = sse_client_builder
126-
self._uri = config.stream_base_uri + STREAMING_ENDPOINT
127-
if config.payload_filter_key is not None:
128-
self._uri += "?%s" % parse.urlencode({"filter": config.payload_filter_key})
108+
def __init__(self, config: Config):
109+
self._sse_client_builder = create_sse_client
129110
self._config = config
130111
self._sse: Optional[SSEClient] = None
112+
self._running = False
131113

132114
@property
133115
def name(self) -> str:
@@ -142,13 +124,13 @@ def sync(self) -> Generator[Update, None, None]:
142124
Update objects until the connection is closed or an unrecoverable error
143125
occurs.
144126
"""
145-
log.info("Starting StreamingUpdateProcessor connecting to uri: %s", self._uri)
146127
self._sse = self._sse_client_builder(self._config)
147128
if self._sse is None:
148129
log.error("Failed to create SSE client for streaming updates.")
149130
return
150131

151132
change_set_builder = ChangeSetBuilder()
133+
self._running = True
152134

153135
for action in self._sse.all:
154136
if isinstance(action, Fault):
@@ -177,8 +159,7 @@ def sync(self) -> Generator[Update, None, None]:
177159
log.info(
178160
"Error while handling stream event; will restart stream: %s", e
179161
)
180-
# TODO(sdk-1409)
181-
# self._sse.interrupt()
162+
self._sse.interrupt()
182163

183164
(update, should_continue) = self._handle_error(e)
184165
if update is not None:
@@ -189,8 +170,7 @@ def sync(self) -> Generator[Update, None, None]:
189170
log.info(
190171
"Error while handling stream event; will restart stream: %s", e
191172
)
192-
# TODO(sdk-1409)
193-
# self._sse.interrupt()
173+
self._sse.interrupt()
194174

195175
yield Update(
196176
state=DataSourceState.INTERRUPTED,
@@ -210,27 +190,16 @@ def sync(self) -> Generator[Update, None, None]:
210190
# DataSourceState.VALID, None
211191
# )
212192

213-
# if not self._ready.is_set():
214-
# log.info("StreamingUpdateProcessor initialized ok.")
215-
# self._ready.set()
216-
217-
# TODO(sdk-1409)
218-
# self._sse.close()
219-
220-
# TODO(sdk-1409)
221-
# def stop(self):
222-
# self.__stop_with_error_info(None)
223-
#
224-
# def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]):
225-
# log.info("Stopping StreamingUpdateProcessor")
226-
# self._running = False
227-
# if self._sse:
228-
# self._sse.close()
229-
#
230-
# if self._data_source_update_sink is None:
231-
# return
232-
#
233-
# self._data_source_update_sink.update_status(DataSourceState.OFF, error)
193+
self._sse.close()
194+
195+
def stop(self):
196+
"""
197+
Stops the streaming synchronizer, closing any open connections.
198+
"""
199+
log.info("Stopping StreamingUpdateProcessor")
200+
self._running = False
201+
if self._sse:
202+
self._sse.close()
234203

235204
# pylint: disable=too-many-return-statements
236205
def _process_message(
@@ -317,8 +286,8 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
317286
If an update is provided, it should be forward upstream, regardless of
318287
whether or not we are going to retry this failure.
319288
"""
320-
# if not self._running:
321-
# return (False, None) # don't retry if we've been deliberately stopped
289+
if not self._running:
290+
return (None, False) # don't retry if we've been deliberately stopped
322291

323292
update: Optional[Update] = None
324293

@@ -362,10 +331,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
362331

363332
if not is_recoverable:
364333
log.error(http_error_message_result)
365-
# TODO(sdk-1409)
366-
# self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited
367-
# self.__stop_with_error_info(error_info)
368-
# self.stop()
334+
self.stop()
369335
return (update, False)
370336

371337
log.warning(http_error_message_result)
@@ -391,8 +357,7 @@ def __enter__(self):
391357
return self
392358

393359
def __exit__(self, type, value, traceback):
394-
# self.stop()
395-
pass
360+
self.stop()
396361

397362

398363
class StreamingDataSourceBuilder: # disable: pylint: disable=too-few-public-methods

ldclient/impl/datasystem/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,11 @@ def sync(self) -> Generator[Update, None, None]:
212212
occurs.
213213
"""
214214
raise NotImplementedError
215+
216+
@abstractmethod
217+
def stop(self):
218+
"""
219+
stop should halt the synchronization process, causing the sync method
220+
to exit as soon as possible.
221+
"""
222+
raise NotImplementedError

ldclient/impl/datasystem/config.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ class ConfigBuilder: # pylint: disable=too-few-public-methods
2828
Builder for the data system configuration.
2929
"""
3030

31-
_initializers: Optional[List[Builder[Initializer]]] = None
32-
_primary_synchronizer: Optional[Builder[Synchronizer]] = None
33-
_secondary_synchronizer: Optional[Builder[Synchronizer]] = None
34-
_store_mode: DataStoreMode = DataStoreMode.READ_ONLY
35-
_data_store: Optional[FeatureStore] = None
31+
def __init__(self) -> None:
32+
self._initializers: Optional[List[Builder[Initializer]]] = None
33+
self._primary_synchronizer: Optional[Builder[Synchronizer]] = None
34+
self._secondary_synchronizer: Optional[Builder[Synchronizer]] = None
35+
self._fdv1_fallback_synchronizer: Optional[Builder[Synchronizer]] = None
36+
self._store_mode: DataStoreMode = DataStoreMode.READ_ONLY
37+
self._data_store: Optional[FeatureStore] = None
3638

3739
def initializers(self, initializers: Optional[List[Builder[Initializer]]]) -> "ConfigBuilder":
3840
"""
@@ -72,12 +74,13 @@ def build(self) -> DataSystemConfig:
7274
initializers=self._initializers,
7375
primary_synchronizer=self._primary_synchronizer,
7476
secondary_synchronizer=self._secondary_synchronizer,
77+
fdv1_fallback_synchronizer=self._fdv1_fallback_synchronizer,
7578
data_store_mode=self._store_mode,
7679
data_store=self._data_store,
7780
)
7881

7982

80-
def __polling_ds_builder() -> Builder[PollingDataSource]:
83+
def polling_ds_builder() -> Builder[PollingDataSource]:
8184
def builder(config: LDConfig) -> PollingDataSource:
8285
requester = Urllib3PollingRequester(config)
8386
polling_ds = PollingDataSourceBuilder(config)
@@ -88,7 +91,7 @@ def builder(config: LDConfig) -> PollingDataSource:
8891
return builder
8992

9093

91-
def __streaming_ds_builder() -> Builder[StreamingDataSource]:
94+
def streaming_ds_builder() -> Builder[StreamingDataSource]:
9295
def builder(config: LDConfig) -> StreamingDataSource:
9396
return StreamingDataSourceBuilder(config).build()
9497

@@ -109,8 +112,8 @@ def default() -> ConfigBuilder:
109112
for updates.
110113
"""
111114

112-
polling_builder = __polling_ds_builder()
113-
streaming_builder = __streaming_ds_builder()
115+
polling_builder = polling_ds_builder()
116+
streaming_builder = streaming_ds_builder()
114117

115118
builder = ConfigBuilder()
116119
builder.initializers([polling_builder])
@@ -126,7 +129,7 @@ def streaming() -> ConfigBuilder:
126129
with no additional latency.
127130
"""
128131

129-
streaming_builder = __streaming_ds_builder()
132+
streaming_builder = streaming_ds_builder()
130133

131134
builder = ConfigBuilder()
132135
builder.synchronizers(streaming_builder)
@@ -141,7 +144,7 @@ def polling() -> ConfigBuilder:
141144
streaming, but may be necessary in some network environments.
142145
"""
143146

144-
polling_builder: Builder[Synchronizer] = __polling_ds_builder()
147+
polling_builder: Builder[Synchronizer] = polling_ds_builder()
145148

146149
builder = ConfigBuilder()
147150
builder.synchronizers(polling_builder)

0 commit comments

Comments
 (0)