Skip to content
Merged
7 changes: 7 additions & 0 deletions ldclient/impl/aio/concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ def stop(self):
if task is not None and task is not asyncio.current_task():
task.cancel()

async def wait_stopped(self):
"""Waits for the task to finish unwinding after ``stop()``. A no-op if
the task never started or is the current task."""
task = self.__task
if task is not None and task is not asyncio.current_task():
await asyncio.wait({task})

async def _run(self):
try:
if self.__initial_delay > 0:
Expand Down
56 changes: 56 additions & 0 deletions ldclient/impl/datasource/async_feature_requester.py
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)
Comment thread
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']}
Comment thread
cursor[bot] marked this conversation as resolved.

async def close(self):
if self._owns_transport:
await self._transport.close()
90 changes: 90 additions & 0 deletions ldclient/impl/datasource/async_polling.py
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)
Comment thread
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)))
27 changes: 27 additions & 0 deletions ldclient/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
...
Comment thread
cursor[bot] marked this conversation as resolved.

@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.
Expand Down
Loading
Loading