Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions packages/common_client/lib/src/data_sources/requestor.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:http/http.dart' as http;
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';

Expand Down Expand Up @@ -57,25 +59,33 @@ final class Requestor {
Future<DataSourceEvent?> requestAllFlags() async {
try {
_logger.debug(
'Making polling request, method: $_method, uri: $_uri, etag: $_lastEtag');
'Making polling request, method: $_method, hasEtag: ${_lastEtag != null}');
final additionalHeaders = switch (_lastEtag) {
final etag? => {'if-none-match': etag},
null => null,
};
final res = await _client.request(_method, _uri,
additionalHeaders: _lastEtag != null ? {'etag': _lastEtag!} : null,
additionalHeaders: additionalHeaders,
body: _method != RequestMethod.get ? _contextString : null);
return await _handleResponse(res);
} catch (err) {
return StatusEvent(ErrorKind.networkError, null, err.toString());
// Don't echo the raw exception. `http.ClientException`'s
// `toString()` formats as `'ClientException: <msg>, uri=<full-url>'`,
// and the URL embeds the base64url-encoded context in GET mode.
// Categorize into a fixed message and use that everywhere.
final sanitized = _describeError(err);
_logger.warn('Polling request failed: $sanitized');
return StatusEvent(ErrorKind.networkError, null, sanitized);
}
}

Future<DataSourceEvent?> _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;
if (res.statusCode == 304) {
// Server confirmed our cached payload is still current.
return null;
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (res.statusCode == 200) {
_lastEtag = res.headers['etag'];
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

var environmentId = getEnvironmentId(res.headers);

Expand All @@ -88,15 +98,25 @@ final class Requestor {
}

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);
}
}
if (isHttpGloballyRecoverable(res.statusCode)) {
return StatusEvent(ErrorKind.errorResponse, res.statusCode,
'Received unexpected status code: ${res.statusCode}');
}
return StatusEvent(ErrorKind.errorResponse, res.statusCode,
'Received unexpected status code: ${res.statusCode}',
shutdown: true);
}

String _describeError(Object err) {
if (err is TimeoutException) return 'Polling request timed out';
if (err is http.ClientException) {
return 'Network error during polling request';
}
final type = err.runtimeType.toString();
if (type.contains('Tls') || type.contains('Handshake')) {
return 'TLS error during polling request';
}
return 'Polling request failed';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ 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:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';

import 'package:test/test.dart';

class MockLogAdapter extends Mock implements LDLogAdapter {}

(PollingDataSource, FlagManager, DataSourceStatusManager) makeDataSourceForTest(
MockClient innerClient,
{LDContext? inContext,
HttpProperties? inProperties,
bool useReport = false,
bool withReasons = false,
Duration? testingInterval}) {
Duration? testingInterval,
LDLogger? inLogger}) {
final context = inContext ?? LDContextBuilder().kind('user', 'test').build();
// We are not testing the data source status manager here, so we just want a
// fixed time to make events easy to get.
final statusManager = DataSourceStatusManager(stamper: () => DateTime(1));
final logger = LDLogger();
final logger = inLogger ?? LDLogger();
final httpProperties = inProperties ?? HttpProperties();
const sdkKey = 'dummy-key';
final flagManager =
Expand Down Expand Up @@ -511,4 +515,127 @@ void main() {

polling.stop();
});

group('conditional requests', () {
setUpAll(() {
registerFallbackValue(LDLogRecord(
level: LDLogLevel.debug,
message: '',
time: DateTime.now(),
logTag: ''));
});

test(
'sends if-none-match on the next request after receiving a 200 with etag',
() async {
var requestNumber = 0;
Map<String, String>? secondRequestHeaders;
final innerClient = MockClient((request) async {
requestNumber++;
if (requestNumber == 1) {
return http.Response('{}', 200, headers: {'etag': 'abc-123'});
}
secondRequestHeaders = request.headers;
return http.Response('{}', 200);
});

final (polling, _, _) = makeDataSourceForTest(innerClient,
testingInterval: const Duration(milliseconds: 5));
polling.start();

// Drain to the second request before asserting.
while (requestNumber < 2) {
await Future<void>.delayed(const Duration(milliseconds: 5));
}
polling.stop();

expect(secondRequestHeaders, containsPair('if-none-match', 'abc-123'));
// The wrong header name (`etag`) must not be sent.
expect(secondRequestHeaders!.containsKey('etag'), isFalse);
});

test('304 response is treated as no-change, not as malformed data',
() async {
// The pre-fix behavior fell through 304 to DataEvent('put', '', ...),
// which downstream parsed as empty JSON and surfaced as
// `ErrorKind.invalidData`. The fix returns null on 304, so the
// status manager should never see an `invalidData` error.
var requestCount = 0;
final innerClient = MockClient((request) async {
requestCount++;
return http.Response('', 304);
});

final (polling, flagManager, statusManager) = makeDataSourceForTest(
innerClient,
testingInterval: const Duration(milliseconds: 5));
polling.start();

while (requestCount < 1) {
await Future<void>.delayed(const Duration(milliseconds: 5));
}
// Give the status pipeline a chance to surface anything.
await Future<void>.delayed(const Duration(milliseconds: 20));
polling.stop();

final lastError = statusManager.status.lastError;
expect(lastError?.kind, isNot(equals(ErrorKind.invalidData)));
// FlagManager should remain in its initial empty state -- no
// attempt was made to parse the empty body.
expect(flagManager.environmentId, isNull);
});
});

group('network error log content', () {
setUpAll(() {
registerFallbackValue(LDLogRecord(
level: LDLogLevel.debug,
message: '',
time: DateTime.now(),
logTag: ''));
});

test(
'warn-level log on a network error does not contain the encoded context',
() async {
// http.ClientException's toString embeds the request URL, which
// in turn embeds the base64url-encoded context. The requestor
// must categorize the exception into a fixed string and log
// only that.
final adapter = MockLogAdapter();
when(() => adapter.log(any())).thenReturn(null);
final logger = LDLogger(adapter: adapter, level: LDLogLevel.debug);

final secretContext =
LDContextBuilder().kind('user', 'secret-key-shibboleth').build();
final innerClient = MockClient((request) async {
throw http.ClientException('Connection refused', request.url);
});

final (polling, _, statusManager) = makeDataSourceForTest(innerClient,
inContext: secretContext,
inLogger: logger,
testingInterval: const Duration(milliseconds: 1));
polling.start();
await Future<void>.delayed(const Duration(milliseconds: 30));
polling.stop();

final encodedContext = base64UrlEncode(utf8.encode(jsonEncode(
ld_common.LDContextSerialization.toJson(secretContext,
isEvent: false))));
final records = verify(() => adapter.log(captureAny())).captured;
for (final record in records) {
expect(
(record as LDLogRecord).message, isNot(contains(encodedContext)));
expect(record.message, isNot(contains('secret-key-shibboleth')));
}

// The user-visible status surface must also not echo the URL.
final lastError = statusManager.status.lastError;
if (lastError != null) {
expect(lastError.message, isNot(contains(encodedContext)));
expect(lastError.message, isNot(contains('secret-key-shibboleth')));
}
});
});
}
Loading