-
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
Changes from all commits
41a8f1d
92dd55d
65f8f9d
3d01323
ef5803c
File filter
Filter by extension
Conversations
Jump to
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 FeatureRequester | ||
| from ldclient.versioned_data_kind import FEATURES, SEGMENTS | ||
|
|
||
| CacheEntry = namedtuple('CacheEntry', ['data', 'etag']) | ||
|
|
||
|
|
||
| class AsyncFeatureRequesterImpl(FeatureRequester): | ||
| 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) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| """ | ||
| 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.async_feature_requester import ( | ||
| AsyncFeatureRequesterImpl | ||
| ) | ||
| 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 ( | ||
| AsyncFeatureStore, | ||
| AsyncUpdateProcessor, | ||
| DataSourceErrorInfo, | ||
| DataSourceErrorKind, | ||
| DataSourceState | ||
| ) | ||
|
|
||
|
|
||
| class AsyncPollingUpdateProcessor(AsyncUpdateProcessor): | ||
| def __init__(self, config: AsyncConfig, requester: AsyncFeatureRequesterImpl, 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() is True and self._store.initialized is True | ||
|
|
||
| async def stop(self): | ||
| self.__stop_with_error_info(None) | ||
| # Wait for the current poll to finish before closing the transport. This way | ||
| # stop() does not return until background work has stopped, so we never close | ||
| # the transport while a request is still using it. | ||
| await self._task.wait_stopped() | ||
| await self._requester.close() | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| 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() | ||
|
|
||
| # Signal VALID once the store is populated. | ||
| if self._store.initialized and self._data_source_update_sink is not None: | ||
| self._data_source_update_sink.update_status(DataSourceState.VALID, None) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. VALID status requires store initializedMedium Severity After a successful poll, async polling only emits Reviewed by Cursor Bugbot for commit 5053397. Configure here. |
||
| 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))) | ||


Uh oh!
There was an error while loading. Please reload this page.