Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 48
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
7f7b3991dce98a96f6f2e58370f1b369c75b64a4646631fbc357fbcf305f2b0File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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.
Uh oh!There was an error while loading. Please reload this page. | ||
| async def close(self): | ||
| if self._owns_transport: | ||
| await self._transport.close() | ||
| 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.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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))) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -571,6 +571,33 @@ def get_all(self): | ||
| pass | ||
| class AsyncFeatureRequester(ABC): | ||
| """ | ||
| Async interface for the component that acquires feature flag data in polling | ||
| mode. The default implementation can be replaced for testing purposes. | ||
| .. caution:: | ||
| This feature is experimental and should NOT be considered ready for production | ||
| use. It may change or be removed without notice and is not subject to backwards | ||
| compatibility guarantees. Pin to a specific minor version and review the changelog | ||
| before upgrading. | ||
| """ | ||
| @abstractmethod | ||
| async def get_all_data(self) -> Mapping[VersionedDataKind, Mapping[str, dict]]: | ||
| """ | ||
| Fetches all feature flag and segment data. | ||
| """ | ||
| ... | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @abstractmethod | ||
| async def close(self) -> None: | ||
| """ | ||
| Releases any resources (such as an HTTP transport) owned by the requester. | ||
| """ | ||
| ... | ||
| class DiagnosticDescription: | ||
| """ | ||
| Optional interface for components to describe their own configuration. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.