diff --git a/.gitignore b/.gitignore index c9d2d8b..f3ba121 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ pubspec_overrides.yaml **/coverage/ **/.flutter-plugins-dependencies **/.flutter-plugins +**/devtools_options.yaml diff --git a/apps/flutter_client_contract_test_service/pubspec.lock b/apps/flutter_client_contract_test_service/pubspec.lock index 52e6ac2..20e4039 100644 --- a/apps/flutter_client_contract_test_service/pubspec.lock +++ b/apps/flutter_client_contract_test_service/pubspec.lock @@ -53,18 +53,18 @@ packages: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.0" build_resolvers: dependency: transitive description: @@ -77,18 +77,18 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.13" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.2" built_collection: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.12.0" characters: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" chunked_stream: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.11.0" collection: dependency: transitive description: @@ -197,18 +197,18 @@ packages: dependency: transitive description: name: coverage - sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" dart_style: dependency: transitive description: @@ -229,26 +229,26 @@ packages: dependency: transitive description: name: device_info_plus - sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" + sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33 url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "12.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.0.3" dio: dependency: transitive description: name: dio - sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 url: "https://pub.dev" source: hosted - version: "5.8.0+1" + version: "5.9.0" dio_web_adapter: dependency: transitive description: @@ -261,10 +261,10 @@ packages: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: @@ -327,10 +327,10 @@ packages: dependency: "direct dev" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" http_multi_server: dependency: transitive description: @@ -343,10 +343,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" intl: dependency: transitive description: @@ -393,7 +393,7 @@ packages: path: "../../packages/common_client" relative: true source: path - version: "1.6.2" + version: "1.7.0" launchdarkly_dart_common: dependency: "direct overridden" description: @@ -414,7 +414,7 @@ packages: path: "../../packages/flutter_client_sdk" relative: true source: path - version: "4.11.2" + version: "4.13.0" lints: dependency: "direct dev" description: @@ -435,18 +435,18 @@ packages: dependency: transitive description: name: logging_appenders - sha256: e329e7472f99416d0edaaf6451fe6c02dec91d34535bd252e284a0b94ab23d79 + sha256: "7fefa09636824f312432721c0bf77967ab19003116650729bd202bdf98142d70" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0+1" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -467,10 +467,10 @@ packages: dependency: transitive description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" nm: dependency: transitive description: @@ -523,18 +523,18 @@ packages: dependency: transitive description: name: package_info_plus - sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.0" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" path: dependency: transitive description: @@ -571,10 +571,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.0.1" platform: dependency: transitive description: @@ -595,10 +595,10 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" pub_semver: dependency: transitive description: @@ -611,10 +611,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" quiver: dependency: transitive description: @@ -635,26 +635,26 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.15" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_linux: dependency: transitive description: @@ -691,10 +691,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -715,10 +715,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -764,14 +764,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" stack_trace: dependency: transitive description: @@ -816,26 +808,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.12" timing: dependency: transitive description: @@ -848,10 +840,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" uri: dependency: transitive description: @@ -864,10 +856,10 @@ packages: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_math: dependency: transitive description: @@ -880,18 +872,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.4" web: dependency: transitive description: @@ -900,14 +892,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: @@ -920,18 +920,18 @@ packages: dependency: transitive description: name: win32 - sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.5.4" + version: "5.15.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "2.1.0" xdg_directories: dependency: transitive description: @@ -944,10 +944,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -957,5 +957,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 9620e81..ddc62c1 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -1,5 +1,10 @@ export 'src/ld_common_config.dart' - show LDCommonConfig, PersistenceConfig, DataSourceConfig, AutoEnvAttributes; + show + LDCommonConfig, + PersistenceConfig, + DataSourceConfig, + AutoEnvAttributes, + PollingConfig; export 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' show diff --git a/packages/common_client/lib/src/data_sources/polling_data_source.dart b/packages/common_client/lib/src/data_sources/polling_data_source.dart index a36a8f4..e218a48 100644 --- a/packages/common_client/lib/src/data_sources/polling_data_source.dart +++ b/packages/common_client/lib/src/data_sources/polling_data_source.dart @@ -1,17 +1,14 @@ import 'dart:async'; import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; import 'dart:math'; import '../config/data_source_config.dart'; -import '../config/defaults/credential_type.dart'; -import '../config/defaults/default_config.dart'; import 'data_source.dart'; import 'data_source_status.dart'; -import 'get_environment_id.dart'; +import 'requestor.dart'; -HttpClient _defaultClientFactory(HttpProperties httpProperties) { +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { return HttpClient(httpProperties: httpProperties); } @@ -22,12 +19,6 @@ HttpClient _defaultClientFactory(HttpProperties httpProperties) { final class PollingDataSource implements DataSource { final LDLogger _logger; - final ServiceEndpoints _endpoints; - - final PollingDataSourceConfig _dataSourceConfig; - - late final HttpClient _client; - Timer? _pollTimer; final Stopwatch _pollStopwatch = Stopwatch(); @@ -38,23 +29,19 @@ final class PollingDataSource implements DataSource { bool _stopped = false; - late final Uri _uri; - - late final RequestMethod _method; - final StreamController _eventController = StreamController(); late final String _credential; + late final Requestor _requestor; + @override Stream get events => _eventController.stream; - String? _lastEtag; - /// Used to track if there has been an unrecoverable error. bool _permanentShutdown = false; - /// The [client] parameter is primarily intended for testing, but it also + /// The [httpClientFactory] parameter is primarily intended for testing, but it also /// could be used for customized clients which support functionality /// our default client support does not. For instance domain sockets or /// other connection mechanisms. @@ -69,23 +56,13 @@ final class PollingDataSource implements DataSource { required HttpProperties httpProperties, Duration? testingInterval, String? etag, - HttpClient Function(HttpProperties) clientFactory = - _defaultClientFactory}) - : _endpoints = endpoints, - _logger = logger.subLogger('PollingDataSource'), - _dataSourceConfig = dataSourceConfig, + HttpClient Function(HttpProperties)? httpClientFactory}) + : _logger = logger.subLogger('PollingDataSource'), _credential = credential { _pollingInterval = testingInterval ?? dataSourceConfig.pollingInterval; - if (_dataSourceConfig.useReport) { - final updatedProperties = - httpProperties.withHeaders({'content-type': 'application/json'}); - _method = RequestMethod.report; - _client = clientFactory(updatedProperties); - } else { - _method = RequestMethod.get; - _client = clientFactory(httpProperties); - } + final method = + dataSourceConfig.useReport ? RequestMethod.report : RequestMethod.get; final plainContextString = jsonEncode(LDContextSerialization.toJson(context, isEvent: false)); @@ -95,90 +72,49 @@ final class PollingDataSource implements DataSource { _contextString = base64UrlEncode(utf8.encode(plainContextString)); } - String completeUrl; - if (_dataSourceConfig.useReport) { - completeUrl = appendPath(_endpoints.polling, - _dataSourceConfig.pollingReportPath(credential, _contextString)); - } else { - completeUrl = appendPath(_endpoints.polling, - _dataSourceConfig.pollingGetPath(credential, _contextString)); - } - if (_dataSourceConfig.withReasons) { - completeUrl = '$completeUrl?withReasons=true'; - } - - _uri = Uri.parse(completeUrl); + _requestor = Requestor( + logger: logger, + contextString: _contextString, + endpoints: endpoints, + dataSourceConfig: dataSourceConfig, + method: method, + httpProperties: httpProperties, + credential: _credential, + httpClientFactory: httpClientFactory ?? _defaultHttpClientFactory); } - Future _makeRequest() async { - try { - _logger.debug( - 'Making polling request, method: $_method, uri: $_uri, etag: $_lastEtag'); - final res = await _client.request(_method, _uri, - additionalHeaders: _lastEtag != null ? {'etag': _lastEtag!} : null, - body: _dataSourceConfig.useReport ? _contextString : null); - await _handleResponse(res); - } catch (err) { - _logger.error('encountered error with polling request: $err, will retry'); - _eventController.sink - .add(StatusEvent(ErrorKind.networkError, null, err.toString())); - } - } + Future _doPoll() async { + _pollStopwatch.reset(); + _pollStopwatch.start(); + + final event = await _requestor.requestAllFlags(); - Future _handleResponse(http.Response res) async { - // The data source has been instructed to stop, so we discard the response. if (_stopped) { return; } - if (res.statusCode == 200 || res.statusCode == 304) { - final etag = res.headers['etag']; - if (etag != null && etag == _lastEtag) { - // The response has not changed, so we don't need to do the work of - // updating the store, calculating changes, or persisting the payload. + switch (event) { + case null: + // No change. return; - } - _lastEtag = etag; - - var environmentId = getEnvironmentId(res.headers); - - if (environmentId == null && - DefaultConfig.credentialConfig.credentialType == - CredentialType.clientSideId) { - // When using a client-side ID we can use it to represent the - // environment. - environmentId = _credential; - } - - _eventController.sink - .add(DataEvent('put', res.body, environmentId: environmentId)); - } else { - if (isHttpGloballyRecoverable(res.statusCode)) { - _eventController.sink.add(StatusEvent( - ErrorKind.networkError, - res.statusCode, - 'Received unexpected status code: ${res.statusCode}')); - _logger.error( - 'received unexpected status code when polling: ${res.statusCode}, will retry'); - } else { - _logger.error( - 'received unexpected status code when polling: ${res.statusCode}, stopping polling'); - _eventController.sink.add(StatusEvent( - ErrorKind.networkError, - res.statusCode, - 'Received unexpected status code: ${res.statusCode}', - shutdown: true)); - _permanentShutdown = true; - stop(); - } + case DataEvent(): + _eventController.sink.add(event); + case StatusEvent(): + _eventController.sink.add(event); + final suffix = event.shutdown ? 'stopping polling' : 'will retry'; + final message = event.kind == ErrorKind.errorResponse + ? 'received unexpected status code when polling' + : 'encountered error with polling request'; + final argument = event.kind == ErrorKind.errorResponse + ? event.statusCode + : event.message; + _logger.error('$message: $argument, $suffix'); + if (event.shutdown) { + _permanentShutdown = true; + stop(); + } } - } - - Future _doPoll() async { - _pollStopwatch.reset(); - _pollStopwatch.start(); - await _makeRequest(); _schedulePoll(); } @@ -194,7 +130,7 @@ final class PollingDataSource implements DataSource { // Example: If the poll took 5 seconds, and the interval is 30 seconds, then // we want to poll after 25 seconds. final delay = Duration( - milliseconds: max( + milliseconds: min( _pollingInterval.inMilliseconds - timeSincePoll.inMilliseconds, _pollingInterval.inMilliseconds)); diff --git a/packages/common_client/lib/src/data_sources/requestor.dart b/packages/common_client/lib/src/data_sources/requestor.dart new file mode 100644 index 0000000..d6c66f7 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/requestor.dart @@ -0,0 +1,102 @@ +import 'package:http/http.dart' as http; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; + +import '../config/data_source_config.dart'; +import '../config/defaults/credential_type.dart'; +import '../config/defaults/default_config.dart'; +import 'data_source.dart'; +import 'data_source_status.dart'; +import 'get_environment_id.dart'; + +typedef HttpClientFactory = HttpClient Function(HttpProperties httpProperties); + +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { + return HttpClient(httpProperties: httpProperties); +} + +final class Requestor { + String? _lastEtag; + final LDLogger _logger; + late final String _contextString; + late final Uri _uri; + final RequestMethod _method; + final HttpClient _client; + final String _credential; + + Requestor({ + required LDLogger logger, + required String contextString, + required RequestMethod method, + required HttpProperties httpProperties, + required String credential, + required ServiceEndpoints endpoints, + required PollingDataSourceConfig dataSourceConfig, + HttpClientFactory httpClientFactory = _defaultHttpClientFactory, + }) : _logger = logger, + _contextString = contextString, + _method = method, + _client = httpClientFactory(method != RequestMethod.get + ? httpProperties.withHeaders({'content-type': 'application/json'}) + : httpProperties), + _credential = credential { + String completeUrl; + if (dataSourceConfig.useReport) { + completeUrl = appendPath(endpoints.polling, + dataSourceConfig.pollingReportPath(credential, _contextString)); + } else { + completeUrl = appendPath(endpoints.polling, + dataSourceConfig.pollingGetPath(credential, _contextString)); + } + if (dataSourceConfig.withReasons) { + completeUrl = '$completeUrl?withReasons=true'; + } + + _uri = Uri.parse(completeUrl); + } + + Future requestAllFlags() async { + try { + _logger.debug( + 'Making polling request, method: $_method, uri: $_uri, etag: $_lastEtag'); + final res = await _client.request(_method, _uri, + additionalHeaders: _lastEtag != null ? {'etag': _lastEtag!} : null, + body: _method != RequestMethod.get ? _contextString : null); + return await _handleResponse(res); + } catch (err) { + return StatusEvent(ErrorKind.networkError, null, err.toString()); + } + } + + Future _handleResponse(http.Response res) async { + if (res.statusCode == 200 || res.statusCode == 304) { + final etag = res.headers['etag']; + if (etag != null && etag == _lastEtag) { + // The response has not changed, so we don't need to do the work of + // updating the store, calculating changes, or persisting the payload. + return null; + } + _lastEtag = etag; + + var environmentId = getEnvironmentId(res.headers); + + if (environmentId == null && + DefaultConfig.credentialConfig.credentialType == + CredentialType.clientSideId) { + // When using a client-side ID we can use it to represent the + // environment. + environmentId = _credential; + } + + return DataEvent('put', res.body, environmentId: environmentId); + } else { + if (isHttpGloballyRecoverable(res.statusCode)) { + return StatusEvent(ErrorKind.errorResponse, res.statusCode, + 'Received unexpected status code: ${res.statusCode}'); + } else { + return StatusEvent(ErrorKind.errorResponse, res.statusCode, + 'Received unexpected status code: ${res.statusCode}', + shutdown: true); + } + } + } +} diff --git a/packages/common_client/lib/src/data_sources/streaming_data_source.dart b/packages/common_client/lib/src/data_sources/streaming_data_source.dart index dc8bb0c..85a99f7 100644 --- a/packages/common_client/lib/src/data_sources/streaming_data_source.dart +++ b/packages/common_client/lib/src/data_sources/streaming_data_source.dart @@ -10,6 +10,12 @@ import '../config/defaults/default_config.dart'; import 'data_source.dart'; import 'data_source_status.dart'; import 'get_environment_id.dart'; +import 'requestor.dart'; + +const String _pingEvent = 'ping'; +const String _patchEvent = 'patch'; +const String _putEvent = 'put'; +const String _delete = 'delete'; typedef MessageHandler = void Function(MessageEvent); typedef ErrorHandler = void Function(dynamic); @@ -22,18 +28,20 @@ typedef SseClientFactory = SSEClient Function( SSEClient _defaultClientFactory(Uri uri, HttpProperties httpProperties, String? body, SseHttpMethod? method, EventSourceLogger? logger) { - return SSEClient(uri, {'put', 'patch', 'delete'}, + return SSEClient(uri, {_putEvent, _patchEvent, _delete, _pingEvent}, headers: httpProperties.baseHeaders, body: body, httpMethod: method ?? SseHttpMethod.get, logger: logger); } +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { + return HttpClient(httpProperties: httpProperties); +} + final class StreamingDataSource implements DataSource { final LDLogger _logger; - final ServiceEndpoints _endpoints; - final StreamingDataSourceConfig _dataSourceConfig; final SseClientFactory _clientFactory; @@ -57,6 +65,8 @@ final class StreamingDataSource implements DataSource { final String _credential; + late final Requestor _requestor; + @override Stream get events => _dataController.stream; @@ -67,16 +77,19 @@ final class StreamingDataSource implements DataSource { /// could be used for customized SSE clients which support functionality /// our default client support does not, or for alternative implementations /// which are not based on SSE. + /// The [httpClientFactory] parameter is primarily intended for testing the + /// requestor used for ping events. StreamingDataSource( {required String credential, required LDContext context, required ServiceEndpoints endpoints, required LDLogger logger, required StreamingDataSourceConfig dataSourceConfig, + required PollingDataSourceConfig pollingDataSourceConfig, required HttpProperties httpProperties, - SseClientFactory clientFactory = _defaultClientFactory}) - : _endpoints = endpoints, - _logger = logger.subLogger('StreamingDataSource'), + SseClientFactory clientFactory = _defaultClientFactory, + HttpClientFactory? httpClientFactory}) + : _logger = logger.subLogger('StreamingDataSource'), _dataSourceConfig = dataSourceConfig, _clientFactory = clientFactory, _httpProperties = httpProperties, @@ -101,13 +114,23 @@ final class StreamingDataSource implements DataSource { ? _dataSourceConfig.streamingReportPath(credential, _contextString) : _dataSourceConfig.streamingGetPath(credential, _contextString); - String completeUrl = appendPath(_endpoints.streaming, path); + String completeUrl = appendPath(endpoints.streaming, path); if (_dataSourceConfig.withReasons) { completeUrl = '$completeUrl?withReasons=true'; } _uri = Uri.parse(completeUrl); + + _requestor = Requestor( + logger: logger, + contextString: _contextString, + method: _useReport ? RequestMethod.report : RequestMethod.get, + httpProperties: httpProperties, + dataSourceConfig: pollingDataSourceConfig, + endpoints: endpoints, + credential: _credential, + httpClientFactory: httpClientFactory ?? _defaultHttpClientFactory); } @override @@ -131,9 +154,32 @@ final class StreamingDataSource implements DataSource { switch (event) { case MessageEvent(): - _logger.debug('Received message event, data: ${event.data}'); - _dataController.sink.add( - DataEvent(event.type, event.data, environmentId: _environmentId)); + if (event.type == _pingEvent) { + final res = await _requestor.requestAllFlags(); + if (_stopped) { + return; + } + switch (res) { + case null: + // No update, so things stay the same. + return; + case DataEvent(): + _dataController.sink.add(res); + case StatusEvent(): + final message = res.kind == ErrorKind.errorResponse + ? 'received unexpected status code when polling in response to a ping event' + : 'encountered error with polling request in response to a ping event'; + final argument = res.kind == ErrorKind.errorResponse + ? res.statusCode + : res.message; + _logger.error('$message: $argument'); + _dataController.sink.add(res); + } + } else { + _logger.debug('Received message event, data: ${event.data}'); + _dataController.sink.add(DataEvent(event.type, event.data, + environmentId: _environmentId)); + } case OpenEvent(): _logger.debug('Received connect event, data: ${event.headers}'); if (event.headers != null) { diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index eabe146..4f725ad 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -76,6 +76,10 @@ typedef DataSourceFactoriesFn = Map Function( Map _defaultFactories( LDCommonConfig config, LDLogger logger, HttpProperties httpProperties) { + final pollingDataSourceConfig = PollingDataSourceConfig( + useReport: config.dataSourceConfig.useReport, + withReasons: config.dataSourceConfig.evaluationReasons, + pollingInterval: config.dataSourceConfig.polling.pollingInterval); return { ConnectionMode.streaming: (LDContext context) { return StreamingDataSource( @@ -86,6 +90,7 @@ Map _defaultFactories( dataSourceConfig: StreamingDataSourceConfig( useReport: config.dataSourceConfig.useReport, withReasons: config.dataSourceConfig.evaluationReasons), + pollingDataSourceConfig: pollingDataSourceConfig, httpProperties: httpProperties); }, ConnectionMode.polling: (LDContext context) { @@ -94,10 +99,7 @@ Map _defaultFactories( context: context, endpoints: config.serviceEndpoints, logger: logger, - dataSourceConfig: PollingDataSourceConfig( - useReport: config.dataSourceConfig.useReport, - withReasons: config.dataSourceConfig.evaluationReasons, - pollingInterval: config.dataSourceConfig.polling.pollingInterval), + dataSourceConfig: pollingDataSourceConfig, httpProperties: httpProperties); }, }; diff --git a/packages/common_client/test/data_sources/polling_data_source_test.dart b/packages/common_client/test/data_sources/polling_data_source_test.dart index 7780ec5..2fb3af1 100644 --- a/packages/common_client/test/data_sources/polling_data_source_test.dart +++ b/packages/common_client/test/data_sources/polling_data_source_test.dart @@ -45,7 +45,7 @@ import 'package:test/test.dart'; withReasons: withReasons, useReport: useReport), httpProperties: httpProperties, - clientFactory: (properties) => + httpClientFactory: (properties) => ld_common.HttpClient(client: innerClient, httpProperties: properties), testingInterval: testingInterval); @@ -418,7 +418,7 @@ void main() { withReasons: false, useReport: false), httpProperties: HttpProperties(), - clientFactory: (properties) => ld_common.HttpClient( + httpClientFactory: (properties) => ld_common.HttpClient( client: innerClient, httpProperties: properties), testingInterval: Duration(milliseconds: 50)); @@ -479,7 +479,7 @@ void main() { withReasons: false, useReport: false), httpProperties: HttpProperties(), - clientFactory: (properties) => ld_common.HttpClient( + httpClientFactory: (properties) => ld_common.HttpClient( client: innerClient, httpProperties: properties), testingInterval: Duration(milliseconds: 50)); diff --git a/packages/common_client/test/data_sources/streaming_data_source_test.dart b/packages/common_client/test/data_sources/streaming_data_source_test.dart index 957a6a2..f671fe9 100644 --- a/packages/common_client/test/data_sources/streaming_data_source_test.dart +++ b/packages/common_client/test/data_sources/streaming_data_source_test.dart @@ -2,14 +2,19 @@ import 'dart:async'; +import 'package:http/testing.dart'; import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; import 'package:launchdarkly_common_client/src/config/data_source_config.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_event_handler.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_status.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; import 'package:launchdarkly_common_client/src/data_sources/streaming_data_source.dart'; import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + as ld_common; import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart'; +import 'package:http/http.dart' as http; import 'package:test/test.dart'; class MockSseClient implements SSEClient { @@ -37,6 +42,7 @@ class MockSseClient implements SSEClient { bool useReport = false, bool withReasons = false, Duration? testingInterval, + MockClient? mockHttpClient, Function(Uri, HttpProperties, String?, SseHttpMethod?)? factoryCallback}) { final context = inContext ?? LDContextBuilder().kind('user', 'test').build(); // We are not testing the data source status manager here, so we just want a @@ -44,13 +50,15 @@ class MockSseClient implements SSEClient { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final logger = LDLogger(); - final httpProperties = inProperties ?? HttpProperties(); + final properties = inProperties ?? HttpProperties(); + final client = MockSseClient(mockStream); const sdkKey = 'dummy-key'; final flagManager = FlagManager(sdkKey: sdkKey, logger: logger, maxCachedContexts: 5); final eventHandler = DataSourceEventHandler( logger: logger, flagManager: flagManager, statusManager: statusManager); + final streaming = StreamingDataSource( credential: sdkKey, context: context, @@ -58,7 +66,13 @@ class MockSseClient implements SSEClient { logger: logger, dataSourceConfig: StreamingDataSourceConfig( withReasons: withReasons, useReport: useReport), - httpProperties: httpProperties, + pollingDataSourceConfig: PollingDataSourceConfig( + useReport: useReport, withReasons: withReasons), + httpProperties: properties, + httpClientFactory: mockHttpClient != null + ? (httpProps) => ld_common.HttpClient( + client: mockHttpClient, httpProperties: httpProps) + : null, clientFactory: (Uri uri, HttpProperties properties, String? body, SseHttpMethod? method, EventSourceLogger? logger) { factoryCallback?.call(uri, properties, body, method); @@ -245,4 +259,240 @@ void main() { expect(thirdChange.keys, ['my-boolean-flag']); expect(flagManager.get('my-boolean-flag')!.flag, isNull); }); + + group('ping event handling', () { + test('it makes a polling request when receiving a ping event', () async { + var pollingRequestMade = false; + final innerHttpClient = MockClient((request) async { + pollingRequestMade = true; + return http.Response('{}', 200); + }); + + final controller = StreamController(); + final (dataSource, _, statusManager) = makeDataSourceForTest( + controller.stream, + mockHttpClient: innerHttpClient); + + // Wait for initial put event to establish valid state + expectLater( + statusManager.changes, + emits(DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)))); + + dataSource.start(); + controller.sink.add(MessageEvent('put', '{}', null)); + + // Wait for the initial state to be established + await Future.delayed(Duration(milliseconds: 50)); + + // Now send a ping event + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait for the polling request to be made + await Future.delayed(Duration(milliseconds: 50)); + + expect(pollingRequestMade, isTrue); + }); + + test('it updates flags when ping triggers successful polling response', + () async { + final innerHttpClient = MockClient((request) async { + return http.Response( + '{"updated-flag":{"version":20,"flagVersion":10,"value":true,"variation":0,"trackEvents":false}}', + 200); + }); + + final controller = StreamController(); + final (dataSource, flagManager, statusManager) = makeDataSourceForTest( + controller.stream, + mockHttpClient: innerHttpClient); + + expectLater( + statusManager.changes, + emits(DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)))); + + dataSource.start(); + controller.sink.add(MessageEvent( + 'put', + '{"my-boolean-flag":{"version":11,"flagVersion":5,"value":false,"variation":1,"trackEvents":false}}', + null)); + + // Wait for initial flags to be processed and consume that change event + final firstChange = await flagManager.changes.first; + expect(firstChange.keys, ['my-boolean-flag']); + + // Send a ping event + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait for the polling request and flag update from the ping + final secondChange = await flagManager.changes.first; + + expect(secondChange.keys.toSet(), {'updated-flag', 'my-boolean-flag'}); + expect(flagManager.get('updated-flag')!.flag!.detail.value.booleanValue(), + true); + }); + + test('it does not update when ping triggers 304 response', () async { + var requestCount = 0; + final innerHttpClient = MockClient((request) async { + requestCount++; + if (requestCount == 1) { + // First request returns data with etag + return http.Response('{"flag1":{"version":1,"value":true}}', 200, + headers: {'etag': 'abc123'}); + } else { + // Second request (from ping) returns 304 + return http.Response('', 304, headers: {'etag': 'abc123'}); + } + }); + + final controller = StreamController(); + final (dataSource, flagManager, _) = makeDataSourceForTest( + controller.stream, + mockHttpClient: innerHttpClient); + + dataSource.start(); + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait for first change from the initial payload. + await flagManager.changes.first; + + var changeCount = 0; + flagManager.changes.listen((_) => changeCount++); + + // Send a ping event which will trigger a 304 response + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait to ensure no additional changes + await Future.delayed(Duration(milliseconds: 100)); + + // Should have no additional changes since 304 means no update + expect(changeCount, 0); + }); + + test('it reports error when ping triggers error response from polling', + () async { + final innerHttpClient = MockClient((request) async { + return http.Response('{}', 503); + }); + + final controller = StreamController(); + final (dataSource, _, statusManager) = makeDataSourceForTest( + controller.stream, + mockHttpClient: innerHttpClient); + + // First expect valid state from the put event + expectLater( + statusManager.changes, + emitsInOrder([ + DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)), + DataSourceStatus( + state: DataSourceState.interrupted, + stateSince: DateTime(1), + lastError: DataSourceStatusErrorInfo( + kind: ErrorKind.errorResponse, + message: 'Received unexpected status code: 503', + time: DateTime(1), + statusCode: 503)) + ])); + + dataSource.start(); + controller.sink.add(MessageEvent('put', '{}', null)); + + // Wait for initial state + await Future.delayed(Duration(milliseconds: 50)); + + // Send a ping event which will trigger an error response + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait for error to be processed + await Future.delayed(Duration(milliseconds: 50)); + }); + + test('it handles network error during polling request from ping', () async { + final innerHttpClient = MockClient((request) async { + throw Exception('Network error'); + }); + + final controller = StreamController(); + final (dataSource, _, statusManager) = makeDataSourceForTest( + controller.stream, + mockHttpClient: innerHttpClient); + + // First expect valid state from the put event + expectLater( + statusManager.changes, + emitsInOrder([ + DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)), + predicate((status) => + status.state == DataSourceState.interrupted && + status.lastError?.kind == ErrorKind.networkError) + ])); + + dataSource.start(); + controller.sink.add(MessageEvent('put', '{}', null)); + + // Wait for initial state + await Future.delayed(Duration(milliseconds: 50)); + + // Send a ping event which will trigger a network error + controller.sink.add(MessageEvent('ping', '', null)); + + // Wait for error to be processed + await Future.delayed(Duration(milliseconds: 100)); + }); + + test('it uses GET method for ping polling when useReport is false', + () async { + String? actualMethod; + final innerHttpClient = MockClient((request) async { + actualMethod = request.method; + return http.Response('{}', 200); + }); + + final controller = StreamController(); + final (dataSource, _, _) = makeDataSourceForTest(controller.stream, + mockHttpClient: innerHttpClient, useReport: false); + + dataSource.start(); + controller.sink.add(MessageEvent('put', '{}', null)); + + await Future.delayed(Duration(milliseconds: 50)); + + // Send a ping event + controller.sink.add(MessageEvent('ping', '', null)); + + await Future.delayed(Duration(milliseconds: 50)); + + expect(actualMethod, 'GET'); + }); + + test('it uses REPORT method for ping polling when useReport is true', + () async { + String? actualMethod; + final innerHttpClient = MockClient((request) async { + actualMethod = request.method; + return http.Response('{}', 200); + }); + + final controller = StreamController(); + final (dataSource, _, _) = makeDataSourceForTest(controller.stream, + mockHttpClient: innerHttpClient, useReport: true); + + dataSource.start(); + controller.sink.add(MessageEvent('put', '{}', null)); + + await Future.delayed(Duration(milliseconds: 50)); + + // Send a ping event + controller.sink.add(MessageEvent('ping', '', null)); + + await Future.delayed(Duration(milliseconds: 50)); + + expect(actualMethod, 'REPORT'); + }); + }); }