Skip to content

Commit 91b6144

Browse files
committed
chore: Support x-ld-envid in updates (#370)
1 parent eda897b commit 91b6144

File tree

9 files changed

+460
-65
lines changed

9 files changed

+460
-65
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test-all: install
3838
.PHONY: lint
3939
lint: #! Run type analysis and linting checks
4040
lint: install
41+
@mkdir -p .mypy_cache
4142
@poetry run mypy ldclient
4243
@poetry run isort --check --atomic ldclient contract-tests
4344
@poetry run pycodestyle ldclient contract-tests

ldclient/impl/datasourcev2/polling.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from ldclient.impl.http import _http_factory
3333
from ldclient.impl.repeating_task import RepeatingTask
3434
from ldclient.impl.util import (
35+
_LD_ENVID_HEADER,
36+
_LD_FD_FALLBACK_HEADER,
3537
UnsuccessfulResponseException,
3638
_Fail,
3739
_headers,
@@ -117,6 +119,13 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
117119
while self._stop.is_set() is False:
118120
result = self._requester.fetch(ss.selector())
119121
if isinstance(result, _Fail):
122+
fallback = None
123+
envid = None
124+
125+
if result.headers is not None:
126+
fallback = result.headers.get(_LD_FD_FALLBACK_HEADER) == 'true'
127+
envid = result.headers.get(_LD_ENVID_HEADER)
128+
120129
if isinstance(result.exception, UnsuccessfulResponseException):
121130
error_info = DataSourceErrorInfo(
122131
kind=DataSourceErrorKind.ERROR_RESPONSE,
@@ -127,28 +136,28 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
127136
),
128137
)
129138

130-
fallback = result.exception.headers.get("X-LD-FD-Fallback") == 'true'
131139
if fallback:
132140
yield Update(
133141
state=DataSourceState.OFF,
134142
error=error_info,
135-
revert_to_fdv1=True
143+
revert_to_fdv1=True,
144+
environment_id=envid,
136145
)
137146
break
138147

139148
status_code = result.exception.status
140149
if is_http_error_recoverable(status_code):
141-
# TODO(fdv2): Add support for environment ID
142150
yield Update(
143151
state=DataSourceState.INTERRUPTED,
144152
error=error_info,
153+
environment_id=envid,
145154
)
146155
continue
147156

148-
# TODO(fdv2): Add support for environment ID
149157
yield Update(
150158
state=DataSourceState.OFF,
151159
error=error_info,
160+
environment_id=envid,
152161
)
153162
break
154163

@@ -159,19 +168,18 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
159168
message=result.error,
160169
)
161170

162-
# TODO(fdv2): Go has a designation here to handle JSON decoding separately.
163-
# TODO(fdv2): Add support for environment ID
164171
yield Update(
165172
state=DataSourceState.INTERRUPTED,
166173
error=error_info,
174+
environment_id=envid,
167175
)
168176
else:
169177
(change_set, headers) = result.value
170178
yield Update(
171179
state=DataSourceState.VALID,
172180
change_set=change_set,
173-
environment_id=headers.get("X-LD-EnvID"),
174-
revert_to_fdv1=headers.get('X-LD-FD-Fallback') == 'true'
181+
environment_id=headers.get(_LD_ENVID_HEADER),
182+
revert_to_fdv1=headers.get(_LD_FD_FALLBACK_HEADER) == 'true'
175183
)
176184

177185
if self._event.wait(self._poll_interval):
@@ -208,7 +216,7 @@ def _poll(self, ss: SelectorStore) -> BasisResult:
208216

209217
(change_set, headers) = result.value
210218

211-
env_id = headers.get("X-LD-EnvID")
219+
env_id = headers.get(_LD_ENVID_HEADER)
212220
if not isinstance(env_id, str):
213221
env_id = None
214222

@@ -273,14 +281,14 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult:
273281
),
274282
retries=1,
275283
)
284+
headers = response.headers
276285

277286
if response.status >= 400:
278287
return _Fail(
279-
f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers)
288+
f"HTTP error {response}", UnsuccessfulResponseException(response.status),
289+
headers=headers,
280290
)
281291

282-
headers = response.headers
283-
284292
if response.status == 304:
285293
return _Success(value=(ChangeSetBuilder.no_changes(), headers))
286294

@@ -304,6 +312,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult:
304312
return _Fail(
305313
error=changeset_result.error,
306314
exception=changeset_result.exception,
315+
headers=headers, # type: ignore
307316
)
308317

309318

@@ -436,13 +445,13 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult:
436445
retries=1,
437446
)
438447

448+
headers = response.headers
439449
if response.status >= 400:
440450
return _Fail(
441-
f"HTTP error {response}", UnsuccessfulResponseException(response.status, response.headers)
451+
f"HTTP error {response}", UnsuccessfulResponseException(response.status),
452+
headers=headers
442453
)
443454

444-
headers = response.headers
445-
446455
if response.status == 304:
447456
return _Success(value=(ChangeSetBuilder.no_changes(), headers))
448457

@@ -466,6 +475,7 @@ def fetch(self, selector: Optional[Selector]) -> PollingResult:
466475
return _Fail(
467476
error=changeset_result.error,
468477
exception=changeset_result.exception,
478+
headers=headers,
469479
)
470480

471481

ldclient/impl/datasourcev2/streaming.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
)
3939
from ldclient.impl.http import HTTPFactory, _http_factory
4040
from ldclient.impl.util import (
41+
_LD_ENVID_HEADER,
42+
_LD_FD_FALLBACK_HEADER,
4143
http_error_message,
4244
is_http_error_recoverable,
4345
log
@@ -58,7 +60,6 @@
5860

5961
STREAMING_ENDPOINT = "/sdk/stream"
6062

61-
6263
SseClientBuilder = Callable[[Config, SelectorStore], SSEClient]
6364

6465

@@ -146,6 +147,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
146147
self._running = True
147148
self._connection_attempt_start_time = time()
148149

150+
envid = None
149151
for action in self._sse.all:
150152
if isinstance(action, Fault):
151153
# If the SSE client detects the stream has closed, then it will
@@ -154,7 +156,10 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
154156
if action.error is None:
155157
continue
156158

157-
(update, should_continue) = self._handle_error(action.error)
159+
if action.headers is not None:
160+
envid = action.headers.get(_LD_ENVID_HEADER, envid)
161+
162+
(update, should_continue) = self._handle_error(action.error, envid)
158163
if update is not None:
159164
yield update
160165

@@ -163,20 +168,23 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
163168
continue
164169

165170
if isinstance(action, Start) and action.headers is not None:
166-
fallback = action.headers.get('X-LD-FD-Fallback') == 'true'
171+
fallback = action.headers.get(_LD_FD_FALLBACK_HEADER) == 'true'
172+
envid = action.headers.get(_LD_ENVID_HEADER, envid)
173+
167174
if fallback:
168175
self._record_stream_init(True)
169176
yield Update(
170177
state=DataSourceState.OFF,
171-
revert_to_fdv1=True
178+
revert_to_fdv1=True,
179+
environment_id=envid,
172180
)
173181
break
174182

175183
if not isinstance(action, Event):
176184
continue
177185

178186
try:
179-
update = self._process_message(action, change_set_builder)
187+
update = self._process_message(action, change_set_builder, envid)
180188
if update is not None:
181189
self._record_stream_init(False)
182190
self._connection_attempt_start_time = None
@@ -187,7 +195,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
187195
)
188196
self._sse.interrupt()
189197

190-
(update, should_continue) = self._handle_error(e)
198+
(update, should_continue) = self._handle_error(e, envid)
191199
if update is not None:
192200
yield update
193201
if not should_continue:
@@ -204,7 +212,7 @@ def sync(self, ss: SelectorStore) -> Generator[Update, None, None]:
204212
DataSourceErrorKind.UNKNOWN, 0, time(), str(e)
205213
),
206214
revert_to_fdv1=False,
207-
environment_id=None, # TODO(sdk-1410)
215+
environment_id=envid,
208216
)
209217

210218
self._sse.close()
@@ -226,7 +234,7 @@ def _record_stream_init(self, failed: bool):
226234

227235
# pylint: disable=too-many-return-statements
228236
def _process_message(
229-
self, msg: Event, change_set_builder: ChangeSetBuilder
237+
self, msg: Event, change_set_builder: ChangeSetBuilder, envid: Optional[str]
230238
) -> Optional[Update]:
231239
"""
232240
Processes a single message from the SSE stream and returns an Update
@@ -247,7 +255,7 @@ def _process_message(
247255
change_set_builder.expect_changes()
248256
return Update(
249257
state=DataSourceState.VALID,
250-
environment_id=None, # TODO(sdk-1410)
258+
environment_id=envid,
251259
)
252260
return None
253261

@@ -293,13 +301,13 @@ def _process_message(
293301
return Update(
294302
state=DataSourceState.VALID,
295303
change_set=change_set,
296-
environment_id=None, # TODO(sdk-1410)
304+
environment_id=envid,
297305
)
298306

299307
log.info("Unexpected event found in stream: %s", msg.event)
300308
return None
301309

302-
def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
310+
def _handle_error(self, error: Exception, envid: Optional[str]) -> Tuple[Optional[Update], bool]:
303311
"""
304312
This method handles errors that occur during the streaming process.
305313
@@ -328,7 +336,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
328336
DataSourceErrorKind.INVALID_DATA, 0, time(), str(error)
329337
),
330338
revert_to_fdv1=False,
331-
environment_id=None, # TODO(sdk-1410)
339+
environment_id=envid,
332340
)
333341
return (update, True)
334342

@@ -344,11 +352,15 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
344352
str(error),
345353
)
346354

347-
if error.headers is not None and error.headers.get("X-LD-FD-Fallback") == 'true':
355+
if envid is None and error.headers is not None:
356+
envid = error.headers.get(_LD_ENVID_HEADER)
357+
358+
if error.headers is not None and error.headers.get(_LD_FD_FALLBACK_HEADER) == 'true':
348359
update = Update(
349360
state=DataSourceState.OFF,
350361
error=error_info,
351-
revert_to_fdv1=True
362+
revert_to_fdv1=True,
363+
environment_id=envid,
352364
)
353365
return (update, False)
354366

@@ -364,7 +376,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
364376
),
365377
error=error_info,
366378
revert_to_fdv1=False,
367-
environment_id=None, # TODO(sdk-1410)
379+
environment_id=envid,
368380
)
369381

370382
if not is_recoverable:
@@ -386,7 +398,7 @@ def _handle_error(self, error: Exception) -> Tuple[Optional[Update], bool]:
386398
DataSourceErrorKind.UNKNOWN, 0, time(), str(error)
387399
),
388400
revert_to_fdv1=False,
389-
environment_id=None, # TODO(sdk-1410)
401+
environment_id=envid,
390402
)
391403
# no stacktrace here because, for a typical connection error, it'll
392404
# just be a lengthy tour of urllib3 internals
@@ -411,5 +423,4 @@ def __init__(self, config: Config):
411423

412424
def build(self) -> StreamingDataSource:
413425
"""Builds a StreamingDataSource instance with the configured parameters."""
414-
# TODO(fdv2): Add in the other controls here.
415426
return StreamingDataSource(self._config)

ldclient/impl/datasystem/config.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -210,18 +210,3 @@ def persistent_store(store: FeatureStore) -> ConfigBuilder:
210210
although it will keep it up-to-date.
211211
"""
212212
return default().data_store(store, DataStoreMode.READ_WRITE)
213-
214-
215-
# TODO(fdv2): Implement these methods
216-
#
217-
# WithEndpoints configures the data system with custom endpoints for
218-
# LaunchDarkly's streaming and polling synchronizers. This method is not
219-
# necessary for most use-cases, but can be useful for testing or custom
220-
# network configurations.
221-
#
222-
# Any endpoint that is not specified (empty string) will be treated as the
223-
# default LaunchDarkly SaaS endpoint for that service.
224-
225-
# WithRelayProxyEndpoints configures the data system with a single endpoint
226-
# for LaunchDarkly's streaming and polling synchronizers. The endpoint
227-
# should be Relay Proxy's base URI, for example http://localhost:8123.

ldclient/impl/util.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import time
55
from dataclasses import dataclass
66
from datetime import timedelta
7-
from typing import Any, Dict, Generic, Optional, TypeVar, Union
7+
from typing import Any, Dict, Generic, Mapping, Optional, TypeVar, Union
88
from urllib.parse import urlparse, urlunparse
99

1010
from ldclient.impl.http import _base_headers
@@ -35,6 +35,9 @@ def timedelta_millis(delta: timedelta) -> float:
3535
# Compiled regex pattern for valid characters in application values and SDK keys
3636
_VALID_CHARACTERS_REGEX = re.compile(r"[^a-zA-Z0-9._-]")
3737

38+
_LD_ENVID_HEADER = 'X-LD-EnvID'
39+
_LD_FD_FALLBACK_HEADER = 'X-LD-FD-Fallback'
40+
3841

3942
def validate_application_info(application: dict, logger: logging.Logger) -> dict:
4043
return {
@@ -117,23 +120,18 @@ def __str__(self, *args, **kwargs):
117120

118121

119122
class UnsuccessfulResponseException(Exception):
120-
def __init__(self, status, headers={}):
123+
def __init__(self, status):
121124
super(UnsuccessfulResponseException, self).__init__("HTTP error %d" % status)
122125
self._status = status
123-
self._headers = headers
124126

125127
@property
126128
def status(self):
127129
return self._status
128130

129-
@property
130-
def headers(self):
131-
return self._headers
132-
133131

134132
def throw_if_unsuccessful_response(resp):
135133
if resp.status >= 400:
136-
raise UnsuccessfulResponseException(resp.status, resp.headers)
134+
raise UnsuccessfulResponseException(resp.status)
137135

138136

139137
def is_http_error_recoverable(status):
@@ -290,6 +288,7 @@ class _Success(Generic[T]):
290288
class _Fail(Generic[E]):
291289
error: E
292290
exception: Optional[Exception] = None
291+
headers: Optional[Mapping[str, Any]] = None
293292

294293

295294
# TODO(breaking): Replace the above Result class with an improved generic

0 commit comments

Comments
 (0)