Skip to content
Open
22 changes: 22 additions & 0 deletions packages/common_client/lib/src/data_sources/fdv2/endpoints.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// FDv2 endpoint paths.
///
/// These paths are uniform across mobile and browser SDKs; FDv2 does
/// not distinguish between platforms at the endpoint level.
abstract final class FDv2Endpoints {
/// Polling path. Used as-is for POST requests (context sent in the
/// request body) and as the prefix for GET requests via [pollingGet].
static const String polling = '/sdk/poll/eval';

/// Streaming path. Used as-is for POST requests (context sent in the
/// request body) and as the prefix for GET requests via [streamingGet].
static const String streaming = '/sdk/stream/eval';

/// Builds the polling GET path with the base64url-encoded context
/// embedded in the URL path.
static String pollingGet(String encodedContext) => '$polling/$encodedContext';

/// Builds the streaming GET path with the base64url-encoded context
/// embedded in the URL path.
static String streamingGet(String encodedContext) =>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the FDv1 endpoints these methods included the decision about where the credential would go. That now isn't at the endpoints layer. It isn't covered in this PR, but a distinction will need to be made about when to put into a header versus when to use a query parameter. I think this a discriminator on the event source capability in JS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will either add a "capabilities" field to the event source, or have some hadCapability(SSECapabilities.requestHeaders) method. So the event source can determine if it uses headers or query parameters.

'$streaming/$encodedContext';
}
215 changes: 215 additions & 0 deletions packages/common_client/lib/src/data_sources/fdv2/polling_base.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:convert';

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

import 'flag_eval_mapper.dart';
import 'payload.dart';
import 'protocol_handler.dart';
import 'protocol_types.dart';
import 'requestor.dart';
import 'selector.dart';
import 'source_result.dart';

/// Performs a single FDv2 poll and translates the response into an
/// [FDv2SourceResult].
///
/// Wraps an [FDv2Requestor] with FDv2 protocol semantics:
///
/// - Network errors --> [SourceState.interrupted] with a sanitized
/// message.
/// - HTTP `304 Not Modified` --> an empty change set with
/// [PayloadType.none], confirming the cached data is current.
/// - Other 4xx/5xx --> interrupted (recoverable) or terminalError
/// (non-recoverable) based on [isHttpGloballyRecoverable].
/// - `200` --> body is parsed as an [FDv2EventsCollection] and fed
/// through an [FDv2ProtocolHandler]. The first emitted action
/// determines the result.
///
/// `x-ld-fd-fallback: true` is treated as an annotation on whatever
/// result the response would otherwise produce: the body is still
/// parsed and used, the 304 is still treated as no-op, errors are
/// still classified by status code, and `fdv1Fallback: true` is
/// stamped on the resulting [FDv2SourceResult]. The orchestrator can
/// consume the data and transition to FDv1 in the same step.
final class FDv2PollingBase {
final LDLogger _logger;
final FDv2Requestor _requestor;
final DateTime Function() _now;

FDv2PollingBase({
required LDLogger logger,
required FDv2Requestor requestor,
DateTime Function()? now,
}) : _logger = logger.subLogger('FDv2PollingBase'),
_requestor = requestor,
_now = now ?? DateTime.now;

/// Performs a single poll. Never throws; all failures, including
/// malformed response bodies, are reported as [StatusResult]s.
Future<FDv2SourceResult> pollOnce({Selector basis = Selector.empty}) async {
final RequestorResponse response;
try {
response = await _requestor.request(basis: basis);
} catch (err) {
// Log only the sanitized form. The raw exception's `toString()` can
// embed PII (e.g. `http.ClientException` formats as
// `'ClientException: <msg>, uri=<full-url>'`, and the URL contains
// the base64url-encoded context in GET mode).
final sanitized = _describeError(err);
_logger.warn('Polling request failed: $sanitized');
return FDv2SourceResults.interrupted(message: sanitized);
}
return _processResponse(response);
}

FDv2SourceResult _processResponse(RequestorResponse response) {
// Match `x-ld-fd-fallback` case-insensitively.
final fdv1Fallback =
response.headers['x-ld-fd-fallback']?.toLowerCase() == 'true';
final environmentId = response.headers['x-ld-envid'];

// 304 Not Modified means the SDK's cached data is confirmed current.
if (response.status == 304) {
return ChangeSetResult(
payload: const Payload(type: PayloadType.none, updates: []),
environmentId: environmentId,
freshness: _now(),
persist: true,
fdv1Fallback: fdv1Fallback,
);
}

if (response.status >= 400) {
final message = 'Received unexpected status code: ${response.status}';
if (isHttpGloballyRecoverable(response.status)) {
_logger.warn('$message; will retry');
return FDv2SourceResults.interrupted(
statusCode: response.status,
message: message,
fdv1Fallback: fdv1Fallback,
);
}
_logger.error('$message; will not retry');
return FDv2SourceResults.terminalError(
statusCode: response.status,
message: message,
fdv1Fallback: fdv1Fallback,
);
}

return _parseBody(
response,
environmentId: environmentId,
fdv1Fallback: fdv1Fallback,
);
}

FDv2SourceResult _parseBody(
RequestorResponse response, {
String? environmentId,
required bool fdv1Fallback,
}) {
// The whole parse path is wrapped: jsonDecode plus the structural
// casts inside FDv2EventsCollection.fromJson and the per-event
// PutObjectEvent/DeleteObjectEvent/PayloadIntent/etc. fromJson calls
// can all throw on shapes the protocol types don't accept.
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return FDv2SourceResults.interrupted(
statusCode: response.status,
message: 'Polling response was not a JSON object',
fdv1Fallback: fdv1Fallback,
);
}

final collection = FDv2EventsCollection.fromJson(decoded);
final handler = FDv2ProtocolHandler(
objProcessors: {flagEvalKind: processFlagEval},
logger: _logger,
);

for (final event in collection.events) {
final action = handler.processEvent(event);
switch (action) {
case ActionPayload(:final payload):
return ChangeSetResult(
payload: payload,
environmentId: environmentId,
freshness: _now(),
persist: true,
fdv1Fallback: fdv1Fallback,
);
case ActionGoodbye(:final reason):
return FDv2SourceResults.goodbyeResult(
message: reason,
fdv1Fallback: fdv1Fallback,
);
case ActionServerError(:final reason):
return FDv2SourceResults.interrupted(
message: reason,
fdv1Fallback: fdv1Fallback,
);
case ActionError(:final message):
return FDv2SourceResults.interrupted(
message: message,
fdv1Fallback: fdv1Fallback,
);
case ActionNone():
// Continue accumulating events until a payload-transferred or
// terminal action is reached.
break;
}
}

// The response had no payload-transferred event. The protocol
// handler is left in a partial state with nothing to emit, which
// is a protocol violation for a polling response.
return FDv2SourceResults.interrupted(
statusCode: response.status,
message: 'Polling response did not include a complete payload',
fdv1Fallback: fdv1Fallback,
);
} catch (err, stack) {
// Log only the type at error level (not the message — `jsonDecode`
// includes a slice of the offending body, which is server-supplied).
// The full detail goes to debug, where it is gated by the user's
// log level.
_logger.error('Failed to parse polling response (${err.runtimeType})');
_logger.debug('Polling response parse failure detail: $err\n$stack');
return FDv2SourceResults.interrupted(
statusCode: response.status,
message: 'Polling response body was malformed',
fdv1Fallback: fdv1Fallback,
);
}
}

/// Categorizes an exception thrown by the requestor into a fixed,
/// sanitized message. The raw exception's string form (which can carry
/// remote address, certificate detail, OS error strings, or — in the
/// case of `http.ClientException` — the full request URL) is never
/// echoed to the public status surface or to the warn log.
///
/// Type checks via `is` are minification-safe (unlike substring
/// matches against `runtimeType.toString()`).
String _describeError(Object err) {
if (err is TimeoutException) {
return 'Polling request timed out';
}
if (err is http.ClientException) {
return 'Network error during polling request';
}
// dart:io's TlsException / HandshakeException can't be caught by `is`
// here without making this file io-only, so fall back to the type
// name. This is a best-effort label; if minification mangles the
// type name we land in the default branch below, which is still safe.
final type = err.runtimeType.toString();
if (type.contains('Tls') || type.contains('Handshake')) {
return 'TLS error during polling request';
}
return 'Polling request failed';
}
}
140 changes: 140 additions & 0 deletions packages/common_client/lib/src/data_sources/fdv2/requestor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';

import 'endpoints.dart';
import 'selector.dart';

typedef HttpClientFactory = HttpClient Function(HttpProperties httpProperties);

HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) {
return HttpClient(httpProperties: httpProperties);
}

/// The shape of a completed HTTP response from the FDv2 polling endpoint.
typedef RequestorResponse = ({
int status,
Map<String, String> headers,
String body,
});

/// Issues a single HTTP poll against the FDv2 polling endpoint.
///
/// Pure HTTP layer: builds the URL, sends the request, tracks `ETag`
/// across calls on the same instance, and returns the raw response. It
/// does no FDv2 protocol parsing or error classification -- that is the
/// responsibility of the caller (see [FDv2PollingBase]).
///
/// One [FDv2Requestor] is bound to a single evaluation context. Switching
/// contexts requires a fresh instance so a previous context's `ETag`
/// can never leak into a request for a different context.
///
/// Calls to [request] are not safe to interleave on a single instance --
/// `ETag` tracking assumes serial requests. Callers (the polling
/// synchronizer) must wait for each [request] to complete before issuing
/// the next.
final class FDv2Requestor {
final LDLogger _logger;
final HttpClient _client;
final Uri _baseUri;
final String _contextEncoded;
final String _contextJson;
final bool _usePost;
final bool _withReasons;
String? _lastEtag;

FDv2Requestor({
required LDLogger logger,
required ServiceEndpoints endpoints,
required String contextEncoded,
required String contextJson,
required bool usePost,
required bool withReasons,
required HttpProperties httpProperties,
HttpClientFactory httpClientFactory = _defaultHttpClientFactory,
}) : _logger = logger.subLogger('FDv2Requestor'),
_baseUri = Uri.parse(endpoints.polling),
_contextEncoded = contextEncoded,
_contextJson = contextJson,
_usePost = usePost,
_withReasons = withReasons,
_client = httpClientFactory(usePost
? httpProperties.withHeaders({'content-type': 'application/json'})
: httpProperties);

/// Sends a single poll request, optionally including a [basis] selector
/// for delta updates. Throws on network errors; otherwise returns the
/// response. Tracks `ETag` across successful (`200`) responses on this
/// instance.
Future<RequestorResponse> request({Selector basis = Selector.empty}) async {
final uri = _buildUri(basis: basis);
final method = _usePost ? RequestMethod.post : RequestMethod.get;
final additionalHeaders = <String, String>{};
if (_lastEtag case final etag?) {
additionalHeaders['if-none-match'] = etag;
}

// Avoid logging the full URI -- in GET mode it embeds the
// base64url-encoded context, which is reversible PII.
_logger.debug('FDv2 poll: method=$method, hasEtag=${_lastEtag != null}, '
'hasBasis=${basis.isNotEmpty}');

final response = await _client.request(
method,
uri,
additionalHeaders: additionalHeaders.isEmpty ? null : additionalHeaders,
body: _usePost ? _contextJson : null,
);

// Only persist the ETag from a successful response. Non-200 responses
// could carry stale or hostile ETag values that would taint future
// conditional requests. A 304 confirms the existing ETag still matches,
// so leaving the stored value alone is correct.
//
// Reject empty-string ETags: an unquoted empty token is invalid per
// RFC 7232 §2.1, and sending `if-none-match: ` on the next request
// could be interpreted by some servers as "match anything" and pin
// the SDK to a permanent 304.
if (response.statusCode == 200) {
final etag = response.headers['etag'];
if (etag != null && etag.isNotEmpty) {
_lastEtag = etag;
}
}

return (
status: response.statusCode,
headers: response.headers,
body: response.body,
);
}

Uri _buildUri({required Selector basis}) {
final addedPath = _usePost
? FDv2Endpoints.polling
: FDv2Endpoints.pollingGet(_contextEncoded);

// Compose against the parsed base URI so a custom polling URL
// carrying its own query parameters (e.g. a relay proxy with a token)
// is preserved correctly. String concatenation against `_baseUri`
// would land the appended path inside the query component.
final basePath = _baseUri.path.endsWith('/')
? _baseUri.path.substring(0, _baseUri.path.length - 1)
: _baseUri.path;
final mergedPath = '$basePath$addedPath';

// Use queryParametersAll so a base URL like `?dup=1&dup=2` round-trips
// both values; the simpler `queryParameters` map collapses duplicates.
final mergedQuery = <String, dynamic>{};
mergedQuery.addAll(_baseUri.queryParametersAll);
if (_withReasons) {
mergedQuery['withReasons'] = 'true';
}
if (basis.state case final state? when state.isNotEmpty) {
mergedQuery['basis'] = state;
}

return _baseUri.replace(
path: mergedPath,
queryParameters: mergedQuery.isEmpty ? null : mergedQuery,
);
}
}
Loading
Loading