Skip to content

Commit c6978e9

Browse files
chore: Support flag change listeners in contract tests
Co-Authored-By: mkeeler@launchdarkly.com <keelerm84@gmail.com>
1 parent 60272b2 commit c6978e9

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

contract-tests/client_entity.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import requests
88
from big_segment_store_fixture import BigSegmentStoreFixture
9+
from flag_change_listener import ListenerRegistry
910
from hook import PostingHook
1011

1112
from ldclient import *
@@ -158,6 +159,7 @@ def __init__(self, tag, config):
158159
config = Config(**opts)
159160

160161
self.client = client.LDClient(config, start_wait / 1000.0)
162+
self.listeners = ListenerRegistry(self.client.flag_tracker)
161163

162164
def is_initializing(self) -> bool:
163165
return self.client.is_initialized()
@@ -282,7 +284,26 @@ def fn(payload) -> Result:
282284
result = migrator.write(params["key"], Context.from_dict(params["context"]), Stage.from_str(params["defaultStage"]), params["payload"])
283285
return {"result": result.authoritative.value if result.authoritative.is_success() else result.authoritative.error}
284286

287+
def register_flag_change_listener(self, params: dict):
288+
self.listeners.register_flag_change_listener(
289+
listener_id=params['listenerId'],
290+
callback_uri=params['callbackUri'],
291+
)
292+
293+
def register_flag_value_change_listener(self, params: dict):
294+
self.listeners.register_flag_value_change_listener(
295+
listener_id=params['listenerId'],
296+
flag_key=params['flagKey'],
297+
context=Context.from_dict(params['context']),
298+
default_value=params['defaultValue'],
299+
callback_uri=params['callbackUri'],
300+
)
301+
302+
def unregister_listener(self, params: dict) -> bool:
303+
return self.listeners.unregister(params['listenerId'])
304+
285305
def close(self):
306+
self.listeners.close_all()
286307
self.client.close()
287308
self.log.info('Test ended')
288309

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import logging
2+
import threading
3+
from typing import Callable, Dict, Optional
4+
5+
import requests
6+
7+
from ldclient.context import Context
8+
from ldclient.interfaces import FlagChange, FlagTracker, FlagValueChange
9+
10+
log = logging.getLogger('testservice')
11+
12+
13+
class ListenerRegistry:
14+
"""Manages all active flag change listener registrations for a single SDK client entity."""
15+
16+
def __init__(self, tracker: FlagTracker):
17+
self._tracker = tracker
18+
self._lock = threading.Lock()
19+
# Maps listener_id -> (sdk_listener callable, cleanup function)
20+
self._listeners: Dict[str, Callable] = {}
21+
22+
def register_flag_change_listener(self, listener_id: str, callback_uri: str):
23+
"""Register a general flag change listener that fires on any flag configuration change."""
24+
def on_flag_change(flag_change: FlagChange):
25+
payload = {
26+
'listenerId': listener_id,
27+
'flagKey': flag_change.key,
28+
}
29+
try:
30+
requests.post(callback_uri, json=payload)
31+
except Exception as e:
32+
log.warning('Failed to post flag change notification: %s', e)
33+
34+
with self._lock:
35+
# If a listener with this ID already exists, unregister the old one first
36+
if listener_id in self._listeners:
37+
self._tracker.remove_listener(self._listeners[listener_id])
38+
39+
self._listeners[listener_id] = on_flag_change
40+
41+
self._tracker.add_listener(on_flag_change)
42+
43+
def register_flag_value_change_listener(
44+
self,
45+
listener_id: str,
46+
flag_key: str,
47+
context: Context,
48+
default_value,
49+
callback_uri: str,
50+
):
51+
"""Register a flag value change listener that fires when the evaluated value changes."""
52+
def on_value_change(change: FlagValueChange):
53+
payload = {
54+
'listenerId': listener_id,
55+
'flagKey': change.key,
56+
'oldValue': change.old_value,
57+
'newValue': change.new_value,
58+
}
59+
try:
60+
requests.post(callback_uri, json=payload)
61+
except Exception as e:
62+
log.warning('Failed to post flag value change notification: %s', e)
63+
64+
# add_flag_value_change_listener returns the underlying listener
65+
# that must be passed to remove_listener to unsubscribe
66+
underlying_listener = self._tracker.add_flag_value_change_listener(flag_key, context, on_value_change)
67+
68+
with self._lock:
69+
if listener_id in self._listeners:
70+
self._tracker.remove_listener(self._listeners[listener_id])
71+
72+
self._listeners[listener_id] = underlying_listener
73+
74+
def unregister(self, listener_id: str) -> bool:
75+
"""Unregister a previously registered listener. Returns False if not found."""
76+
with self._lock:
77+
listener = self._listeners.pop(listener_id, None)
78+
79+
if listener is None:
80+
return False
81+
82+
self._tracker.remove_listener(listener)
83+
return True
84+
85+
def close_all(self):
86+
"""Unregister all listeners. Called when the SDK client entity shuts down."""
87+
with self._lock:
88+
listeners = dict(self._listeners)
89+
self._listeners.clear()
90+
91+
for listener in listeners.values():
92+
self._tracker.remove_listener(listener)

contract-tests/service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ def status():
8282
'persistent-data-store-redis',
8383
'persistent-data-store-dynamodb',
8484
'persistent-data-store-consul',
85+
'flag-change-listeners',
86+
'flag-value-change-listeners',
8587
]
8688
}
8789
return json.dumps(body), 200, {'Content-type': 'application/json'}
@@ -150,6 +152,13 @@ def post_client_command(id):
150152
response = client.migration_variation(sub_params)
151153
elif command == "migrationOperation":
152154
response = client.migration_operation(sub_params)
155+
elif command == "registerFlagChangeListener":
156+
client.register_flag_change_listener(sub_params)
157+
elif command == "registerFlagValueChangeListener":
158+
client.register_flag_value_change_listener(sub_params)
159+
elif command == "unregisterListener":
160+
if not client.unregister_listener(sub_params):
161+
return 'no listener with id "%s"' % sub_params['listenerId'], 400
153162
else:
154163
return '', 400
155164

0 commit comments

Comments
 (0)