-
Notifications
You must be signed in to change notification settings - Fork 47
feat: Add async FDv1 polling data source and feature requester #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
7f7b399
feat: Add async FDv1 polling data source and feature requester
jsonbailey 1dce98a
refactor: Import FDV1_POLLING_ENDPOINT from datasource_common
jsonbailey 96f6f2e
fix: Address polling review findings
jsonbailey 58370f1
fix: Await in-flight poll before closing the transport on stop
jsonbailey b369c75
docs: Simplify async polling stop comment
jsonbailey b64a464
fix: Report polling VALID on every successful poll to match sync
jsonbailey 6631fbc
fix: Add close() to the AsyncFeatureRequester interface
jsonbailey 357fbcf
fix: Don't swallow caller cancellation in AsyncRepeatingTask.wait_sto…
jsonbailey 305f2b0
fix: Close polling transport in a finally so a cancelled stop() does …
jsonbailey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """ | ||
| Default implementation of feature flag polling requests. | ||
| """ | ||
|
|
||
| import json | ||
| from collections import namedtuple | ||
| from typing import Optional | ||
| from urllib import parse | ||
|
|
||
| from ldclient.impl.aio.transport import AsyncHTTPTransport | ||
| from ldclient.impl.datasource.datasource_common import FDV1_POLLING_ENDPOINT | ||
| from ldclient.impl.util import _headers, log, throw_if_unsuccessful_response | ||
| from ldclient.interfaces import AsyncFeatureRequester | ||
| from ldclient.versioned_data_kind import FEATURES, SEGMENTS | ||
|
|
||
| CacheEntry = namedtuple('CacheEntry', ['data', 'etag']) | ||
|
|
||
|
|
||
| class AsyncFeatureRequesterImpl(AsyncFeatureRequester): | ||
| def __init__(self, config, transport: Optional[AsyncHTTPTransport] = None): | ||
| self._cache: dict = dict() | ||
| # Only close the transport on shutdown if we created it; an injected | ||
| # transport is owned by the caller. | ||
| self._owns_transport = transport is None | ||
| self._transport = transport if transport is not None else AsyncHTTPTransport(config) | ||
| self._config = config | ||
| self._poll_uri = config.base_uri + FDV1_POLLING_ENDPOINT | ||
| if config.payload_filter_key is not None: | ||
| self._poll_uri += '?%s' % parse.urlencode({'filter': config.payload_filter_key}) | ||
|
|
||
| async def get_all_data(self): | ||
| uri = self._poll_uri | ||
| hdrs = _headers(self._config) | ||
| cache_entry = self._cache.get(uri) | ||
| hdrs['Accept-Encoding'] = 'gzip' | ||
| if cache_entry is not None: | ||
| hdrs['If-None-Match'] = cache_entry.etag | ||
| r = await self._transport.request('GET', uri, headers=hdrs) | ||
| throw_if_unsuccessful_response(r) | ||
| if r.status == 304 and cache_entry is not None: | ||
| data = cache_entry.data | ||
| etag = cache_entry.etag | ||
| from_cache = True | ||
| else: | ||
| data = json.loads(r.body) | ||
| etag = r.headers.get('ETag') | ||
| from_cache = False | ||
| if etag is not None: | ||
| self._cache[uri] = CacheEntry(data=data, etag=etag) | ||
| log.debug("%s response status:[%d] From cache? [%s] ETag:[%s]", uri, r.status, from_cache, etag) | ||
|
|
||
| return {FEATURES: data['flags'], SEGMENTS: data['segments']} | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| async def close(self): | ||
| if self._owns_transport: | ||
| await self._transport.close() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| """ | ||
| Default implementation of the polling component. | ||
| """ | ||
|
|
||
| # currently excluded from documentation - see docs/README.md | ||
|
|
||
| import time | ||
| from typing import Optional | ||
|
|
||
| from ldclient.async_config import AsyncConfig | ||
| from ldclient.impl.aio.concurrency import AsyncEvent, AsyncRepeatingTask | ||
| from ldclient.impl.datasource.datasource_common import sink_or_store | ||
| from ldclient.impl.util import ( | ||
| UnsuccessfulResponseException, | ||
| http_error_message, | ||
| is_http_error_recoverable, | ||
| log | ||
| ) | ||
| from ldclient.interfaces import ( | ||
| AsyncFeatureRequester, | ||
| AsyncFeatureStore, | ||
| AsyncUpdateProcessor, | ||
| DataSourceErrorInfo, | ||
| DataSourceErrorKind, | ||
| DataSourceState | ||
| ) | ||
|
|
||
|
|
||
| class AsyncPollingUpdateProcessor(AsyncUpdateProcessor): | ||
| def __init__(self, config: AsyncConfig, requester: AsyncFeatureRequester, store: AsyncFeatureStore, ready: AsyncEvent): | ||
| self._config = config | ||
| self._data_source_update_sink = config.data_source_update_sink | ||
| self._requester = requester | ||
| self._store = store | ||
| self._ready = ready | ||
| self._task = AsyncRepeatingTask("ldclient.datasource.polling", config.poll_interval, 0, self._fetch_and_store) | ||
|
|
||
| def start(self): | ||
| log.info("Starting AsyncPollingUpdateProcessor with request interval: " + str(self._config.poll_interval)) | ||
| self._task.start() | ||
|
|
||
| def initialized(self): | ||
| return self._ready.is_set() and self._store.initialized | ||
|
|
||
| async def stop(self): | ||
| self.__stop_with_error_info(None) | ||
| # Wait for the current poll to finish before closing the transport, so we do | ||
| # not close it while a request is still using it. The close is in a finally | ||
| # so an owned transport is still released if stop() is cancelled mid-wait. | ||
| try: | ||
| await self._task.wait_stopped() | ||
| finally: | ||
| await self._requester.close() | ||
|
|
||
| def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]): | ||
| log.info("Stopping AsyncPollingUpdateProcessor") | ||
| self._task.stop() | ||
|
|
||
| if self._data_source_update_sink is None: | ||
| return | ||
|
|
||
| self._data_source_update_sink.update_status(DataSourceState.OFF, error) | ||
|
|
||
| async def _fetch_and_store(self): | ||
| try: | ||
| all_data = await self._requester.get_all_data() | ||
| await sink_or_store(self._data_source_update_sink, self._store).init(all_data) | ||
| if not self._ready.is_set() and self._store.initialized: | ||
| log.info("AsyncPollingUpdateProcessor initialized ok") | ||
| self._ready.set() | ||
|
|
||
| if self._data_source_update_sink is not None: | ||
| self._data_source_update_sink.update_status(DataSourceState.VALID, None) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| except UnsuccessfulResponseException as e: | ||
| error_info = DataSourceErrorInfo(DataSourceErrorKind.ERROR_RESPONSE, e.status, time.time(), str(e)) | ||
|
|
||
| http_error_message_result = http_error_message(e.status, "polling request") | ||
| if not is_http_error_recoverable(e.status): | ||
| log.error(http_error_message_result) | ||
| self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited | ||
| self.__stop_with_error_info(error_info) | ||
| else: | ||
| log.warning(http_error_message_result) | ||
|
|
||
| if self._data_source_update_sink is not None: | ||
| self._data_source_update_sink.update_status(DataSourceState.INTERRUPTED, error_info) | ||
| except Exception as e: | ||
| log.exception('Error: Exception encountered when updating flags. %s' % e) | ||
| if self._data_source_update_sink is not None: | ||
| self._data_source_update_sink.update_status(DataSourceState.INTERRUPTED, DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(e))) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.