-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add Initializer and Synchronizer source contracts #259
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
Open
kinyoklion
wants to merge
10
commits into
main
Choose a base branch
from
rlamb/sdk-2183/fdv2-requestor-polling-base
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d540dd6
feat: add Initializer and Synchronizer source contracts
kinyoklion 1d18961
feat: add FDv2 requestor and polling base
kinyoklion 5c3d6f4
style: dart format
kinyoklion e65bfb0
style: replace unicode arrows with ASCII
kinyoklion 2b59ed9
fix: address review findings on FDv2 requestor and polling base
kinyoklion 70fa4c7
fix: address round-2 review findings on FDv2 polling base
kinyoklion 3fe5f42
refactor: treat x-ld-fd-fallback as an annotation, not a replacement
kinyoklion 86a7a43
style: trim x-ld-fd-fallback comment
kinyoklion 94bb233
test: align log capture with the codebase's mocktail pattern
kinyoklion eba8fb0
refactor: use Dart 3 case-pattern null narrowing in requestor
kinyoklion File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
22 changes: 22 additions & 0 deletions
22
packages/common_client/lib/src/data_sources/fdv2/endpoints.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) => | ||
| '$streaming/$encodedContext'; | ||
| } | ||
215 changes: 215 additions & 0 deletions
215
packages/common_client/lib/src/data_sources/fdv2/polling_base.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
140
packages/common_client/lib/src/data_sources/fdv2/requestor.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.