Revert "feat(code_push_client): add Cloudflare-fronted fallback API endpoint" (#3760)
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
import 'package:shorebird_code_push_client/src/version.dart';
|
||||
@@ -61,208 +59,25 @@ class CodePushUpgradeRequiredException extends CodePushException {
|
||||
});
|
||||
}
|
||||
|
||||
/// A thin [http.BaseClient] decorator that injects the given headers on
|
||||
/// every outgoing request. Forwards everything else to the wrapped client.
|
||||
class _HeaderInjectingClient extends http.BaseClient {
|
||||
_HeaderInjectingClient({
|
||||
required http.Client inner,
|
||||
required Map<String, String> headers,
|
||||
}) : _inner = inner,
|
||||
_headers = headers;
|
||||
/// A wrapper around [http.Client] that ensures all outbound requests
|
||||
/// are consistent.
|
||||
/// For example, all requests include the standard `x-version` header.
|
||||
class _CodePushHttpClient extends http.BaseClient {
|
||||
_CodePushHttpClient(this._client, this._headers);
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
final http.Client _inner;
|
||||
final Map<String, String> _headers;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
request.headers.addAll(_headers);
|
||||
return _inner.send(request);
|
||||
return _client.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes requests by their URL host. Requests whose host is in the
|
||||
/// `hostsThroughPrimary` set are forwarded to the primary client. All
|
||||
/// other requests are forwarded to the passthrough client.
|
||||
///
|
||||
/// Used to direct API calls through a failover-aware client while letting
|
||||
/// requests aimed at unrelated hosts (signed GCS upload URLs, third-party
|
||||
/// assets, etc.) bypass that machinery and go straight out.
|
||||
class _HostRouter extends http.BaseClient {
|
||||
_HostRouter({
|
||||
required http.Client primaryClient,
|
||||
required http.Client passthroughClient,
|
||||
required this.hostsThroughPrimary,
|
||||
}) : _primaryClient = primaryClient,
|
||||
_passthroughClient = passthroughClient;
|
||||
|
||||
final http.Client _primaryClient;
|
||||
final http.Client _passthroughClient;
|
||||
final Set<String> hostsThroughPrimary;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
return hostsThroughPrimary.contains(request.url.host)
|
||||
? _primaryClient.send(request)
|
||||
: _passthroughClient.send(request);
|
||||
}
|
||||
|
||||
/// Closes only [_primaryClient]. Callers are expected to share the
|
||||
/// underlying transport with [_passthroughClient] (which transitively
|
||||
/// gets closed via the primary), or to manage the passthrough's
|
||||
/// lifetime themselves.
|
||||
@override
|
||||
void close() {
|
||||
_primaryClient.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A [http.BaseClient] decorator that adds primary/fallback failover.
|
||||
///
|
||||
/// Holds a [_preferredHost] (initially [primaryHost]) and an
|
||||
/// [_alternateHost] (initially [fallbackHost]). Each request is sent to
|
||||
/// [_preferredHost]. On a transport-level failure the request is retried
|
||||
/// once against [_alternateHost], and if that succeeds the two are swapped
|
||||
/// so the working host becomes preferred for subsequent calls. A session
|
||||
/// that fell over to the fallback can self-heal back to the primary if
|
||||
/// the fallback later fails and the primary has recovered.
|
||||
///
|
||||
/// This client does not decide which requests are eligible for failover.
|
||||
/// Pair with [_HostRouter] to send only the appropriate requests through
|
||||
/// it.
|
||||
class _FailoverClient extends http.BaseClient {
|
||||
_FailoverClient({
|
||||
required http.Client inner,
|
||||
required this.primaryHost,
|
||||
required this.fallbackHost,
|
||||
}) : _inner = inner,
|
||||
_preferredHost = primaryHost,
|
||||
_alternateHost = fallbackHost;
|
||||
|
||||
final http.Client _inner;
|
||||
|
||||
/// The host of the primary API endpoint, e.g. `api.shorebird.dev`.
|
||||
final String primaryHost;
|
||||
|
||||
/// The host of the fallback API endpoint, e.g. `api.shorebird.cloud`.
|
||||
final String fallbackHost;
|
||||
|
||||
/// The host the next request will be sent to. Updated only when a
|
||||
/// failover succeeds, so steady-state requests pay no per-call overhead
|
||||
/// for the host decision.
|
||||
String _preferredHost;
|
||||
|
||||
/// The host that requests fall over to when [_preferredHost] fails at
|
||||
/// the transport layer.
|
||||
String _alternateHost;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// Capture the host pair locally so this request keeps using the same
|
||||
// routing even if a concurrent in-flight request swaps the fields.
|
||||
final preferredAtStart = _preferredHost;
|
||||
final alternateAtStart = _alternateHost;
|
||||
|
||||
try {
|
||||
return await _inner.send(_routedTo(request, preferredAtStart));
|
||||
} on Exception catch (e) {
|
||||
if (!_isTransportFailure(e)) rethrow;
|
||||
}
|
||||
|
||||
final response = await _inner.send(_routedTo(request, alternateAtStart));
|
||||
// Swap only if no concurrent send already did. The check and swap are
|
||||
// atomic in Dart's single-isolate model because there is no `await`
|
||||
// between them. Invariant: at most one swap per concurrent burst of
|
||||
// failovers, so the post-burst state is deterministic regardless of
|
||||
// how many requests piled up.
|
||||
if (identical(_preferredHost, preferredAtStart)) {
|
||||
_swapHosts();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
void _swapHosts() {
|
||||
final previousPreferred = _preferredHost;
|
||||
_preferredHost = _alternateHost;
|
||||
_alternateHost = previousPreferred;
|
||||
}
|
||||
|
||||
http.BaseRequest _routedTo(http.BaseRequest original, String host) {
|
||||
if (original.url.host == host) return original;
|
||||
return _rewriteHost(original, host);
|
||||
}
|
||||
|
||||
static bool _isTransportFailure(Exception e) =>
|
||||
e is SocketException ||
|
||||
e is HandshakeException ||
|
||||
e is TimeoutException ||
|
||||
e is http.ClientException;
|
||||
|
||||
/// Returns a copy of [original] with its host swapped to [newHost].
|
||||
///
|
||||
/// Only supports request types we actually send to API hosts:
|
||||
/// [http.Request] for JSON calls, and [http.MultipartRequest] for
|
||||
/// field-only metadata POSTs (e.g. createPatchArtifact, which uses
|
||||
/// multipart as a form-data envelope and carries no files). Real file
|
||||
/// uploads target signed GCS URLs and do not reach this path because
|
||||
/// [_HostRouter] sends them straight through.
|
||||
static http.BaseRequest _rewriteHost(
|
||||
http.BaseRequest original,
|
||||
String newHost,
|
||||
) {
|
||||
final newUri = original.url.replace(host: newHost);
|
||||
if (original is http.Request) {
|
||||
return http.Request(original.method, newUri)
|
||||
..headers.addAll(original.headers)
|
||||
..bodyBytes = original.bodyBytes
|
||||
..encoding = original.encoding
|
||||
..followRedirects = original.followRedirects
|
||||
..maxRedirects = original.maxRedirects
|
||||
..persistentConnection = original.persistentConnection;
|
||||
}
|
||||
if (original is http.MultipartRequest) {
|
||||
// MultipartFile streams are single-use. Retrying a request whose
|
||||
// files have already been consumed would silently send an empty
|
||||
// body. API-host multiparts today are fields-only (file uploads
|
||||
// target signed GCS URLs via _HostRouter's passthrough path), so
|
||||
// enforce that invariant rather than hoping callers preserve it.
|
||||
// Unreachable through CodePushClient's public API; ignored for
|
||||
// the same reason as the StateError below.
|
||||
// coverage:ignore-start
|
||||
if (original.files.isNotEmpty) {
|
||||
throw StateError(
|
||||
'Cannot fail over a MultipartRequest with attached files: '
|
||||
'MultipartFile streams are single-use.',
|
||||
);
|
||||
}
|
||||
// coverage:ignore-end
|
||||
return http.MultipartRequest(original.method, newUri)
|
||||
..headers.addAll(original.headers)
|
||||
..fields.addAll(original.fields)
|
||||
..followRedirects = original.followRedirects
|
||||
..maxRedirects = original.maxRedirects
|
||||
..persistentConnection = original.persistentConnection;
|
||||
}
|
||||
// Defensive guard. Unreachable through CodePushClient's public API
|
||||
// because every request issued internally is either an http.Request
|
||||
// or an http.MultipartRequest. Marked ignore so the unreachable
|
||||
// branch does not block the 100% patch coverage check.
|
||||
// coverage:ignore-start
|
||||
throw StateError(
|
||||
'Cannot rewrite host on request of type ${original.runtimeType}',
|
||||
);
|
||||
// coverage:ignore-end
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
_client.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
@@ -272,65 +87,15 @@ class _FailoverClient extends http.BaseClient {
|
||||
/// {@endtemplate}
|
||||
class CodePushClient {
|
||||
/// {@macro code_push_client}
|
||||
factory CodePushClient({
|
||||
CodePushClient({
|
||||
http.Client? httpClient,
|
||||
Uri? hostedUri,
|
||||
Uri? fallbackHostedUri,
|
||||
Map<String, String>? customHeaders,
|
||||
}) {
|
||||
final resolvedHosted = hostedUri ?? defaultHostedUri;
|
||||
final resolvedFallback = fallbackHostedUri ?? defaultFallbackHostedUri;
|
||||
final transport = httpClient ?? buildDefaultHttpClient();
|
||||
final apiClient = resolvedHosted.host == resolvedFallback.host
|
||||
? transport
|
||||
: _FailoverClient(
|
||||
inner: transport,
|
||||
primaryHost: resolvedHosted.host,
|
||||
fallbackHost: resolvedFallback.host,
|
||||
);
|
||||
final router = _HostRouter(
|
||||
primaryClient: apiClient,
|
||||
passthroughClient: transport,
|
||||
hostsThroughPrimary: {resolvedHosted.host, resolvedFallback.host},
|
||||
);
|
||||
final wrapped = _HeaderInjectingClient(
|
||||
inner: router,
|
||||
headers: {...standardHeaders, ...?customHeaders},
|
||||
);
|
||||
return CodePushClient._(
|
||||
httpClient: wrapped,
|
||||
hostedUri: resolvedHosted,
|
||||
fallbackHostedUri: resolvedFallback,
|
||||
);
|
||||
}
|
||||
|
||||
CodePushClient._({
|
||||
required http.Client httpClient,
|
||||
required this.hostedUri,
|
||||
required this.fallbackHostedUri,
|
||||
}) : _httpClient = httpClient;
|
||||
|
||||
/// The default primary URI for the Shorebird CodePush API.
|
||||
static final Uri defaultHostedUri = Uri.https('api.shorebird.dev');
|
||||
|
||||
/// The default fallback URI used when the primary is unreachable.
|
||||
static final Uri defaultFallbackHostedUri = Uri.https('api.shorebird.cloud');
|
||||
|
||||
/// How long to wait for a TCP/TLS handshake before treating the endpoint
|
||||
/// as unreachable and falling back. Applies only to connection setup, not
|
||||
/// to response time. Healthy handshakes complete in well under a second.
|
||||
static const defaultConnectionTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Builds the default [http.Client] used when no client is supplied. The
|
||||
/// client is configured with [defaultConnectionTimeout] so that an
|
||||
/// unreachable primary surfaces as a transport-level error rather than
|
||||
/// hanging.
|
||||
static http.Client buildDefaultHttpClient({
|
||||
Duration connectionTimeout = defaultConnectionTimeout,
|
||||
}) {
|
||||
final inner = HttpClient()..connectionTimeout = connectionTimeout;
|
||||
return IOClient(inner);
|
||||
}
|
||||
}) : _httpClient = _CodePushHttpClient(httpClient ?? http.Client(), {
|
||||
...standardHeaders,
|
||||
...?customHeaders,
|
||||
}),
|
||||
hostedUri = hostedUri ?? Uri.https('api.shorebird.dev');
|
||||
|
||||
/// The standard headers applied to all requests.
|
||||
static const standardHeaders = <String, String>{'x-version': packageVersion};
|
||||
@@ -343,10 +108,6 @@ class CodePushClient {
|
||||
/// The hosted uri for the Shorebird CodePush API.
|
||||
final Uri hostedUri;
|
||||
|
||||
/// The fallback hosted uri used when [hostedUri] is unreachable at the
|
||||
/// transport layer. Defaults to `https://api.shorebird.cloud`.
|
||||
final Uri fallbackHostedUri;
|
||||
|
||||
Uri get _v1 => Uri.parse('$hostedUri/api/v1');
|
||||
|
||||
/// Fetches the currently logged-in user.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -2151,271 +2150,6 @@ void main() {
|
||||
verify(() => httpClient.close()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('fallback retry', () {
|
||||
late CodePushClient client;
|
||||
|
||||
setUp(() {
|
||||
client = CodePushClient(
|
||||
httpClient: httpClient,
|
||||
hostedUri: Uri.https('primary.example.com'),
|
||||
fallbackHostedUri: Uri.https('fallback.example.com'),
|
||||
);
|
||||
});
|
||||
|
||||
http.StreamedResponse okResponse() => http.StreamedResponse(
|
||||
Stream.value(utf8.encode(json.encode({'apps': <Object>[]}))),
|
||||
HttpStatus.ok,
|
||||
);
|
||||
|
||||
test(
|
||||
'does not contact fallback when primary succeeds',
|
||||
() async {
|
||||
when(
|
||||
() => httpClient.send(any()),
|
||||
).thenAnswer((_) async => okResponse());
|
||||
|
||||
await client.getApps();
|
||||
|
||||
final sent = verify(() => httpClient.send(captureAny())).captured;
|
||||
expect(sent, hasLength(1));
|
||||
expect(
|
||||
(sent.single as http.BaseRequest).url.host,
|
||||
equals('primary.example.com'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'retries on fallback host when primary throws SocketException',
|
||||
() async {
|
||||
var callCount = 0;
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'primary.example.com') {
|
||||
throw const SocketException('blocked');
|
||||
}
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await client.getApps();
|
||||
|
||||
expect(callCount, equals(2));
|
||||
final sent = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured.cast<http.BaseRequest>();
|
||||
expect(sent[0].url.host, equals('primary.example.com'));
|
||||
expect(sent[1].url.host, equals('fallback.example.com'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sticks to fallback after a successful retry',
|
||||
() async {
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'primary.example.com') {
|
||||
throw const SocketException('blocked');
|
||||
}
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await client.getApps();
|
||||
await client.getApps();
|
||||
|
||||
final sent = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured.cast<http.BaseRequest>();
|
||||
// First call: primary (throws) + fallback (ok). Second call:
|
||||
// fallback only, primary skipped due to sticky host.
|
||||
expect(sent.map((r) => r.url.host), [
|
||||
'primary.example.com',
|
||||
'fallback.example.com',
|
||||
'fallback.example.com',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'does not retry on non-transport errors (4xx response)',
|
||||
() async {
|
||||
when(() => httpClient.send(any())).thenAnswer(
|
||||
(_) async => http.StreamedResponse(
|
||||
const Stream.empty(),
|
||||
HttpStatus.forbidden,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.getApps();
|
||||
} on Exception {
|
||||
// expected
|
||||
}
|
||||
|
||||
final sent = verify(() => httpClient.send(captureAny())).captured;
|
||||
expect(sent, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'surfaces error when both primary and fallback fail',
|
||||
() async {
|
||||
when(
|
||||
() => httpClient.send(any()),
|
||||
).thenAnswer((_) async => throw const SocketException('blocked'));
|
||||
|
||||
await expectLater(
|
||||
client.getApps(),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'falls back from sticky host when it later fails (self-heal)',
|
||||
() async {
|
||||
// Phase 1: primary blocked, fallback works. Sticky becomes fallback.
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'primary.example.com') {
|
||||
throw const SocketException('blocked');
|
||||
}
|
||||
return okResponse();
|
||||
});
|
||||
await client.getApps();
|
||||
|
||||
// Phase 2: fallback now broken, primary recovered. The session
|
||||
// should fall over from the now-failing sticky to the recovered
|
||||
// primary, and re-stick to primary.
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'fallback.example.com') {
|
||||
throw const SocketException('cf down');
|
||||
}
|
||||
return okResponse();
|
||||
});
|
||||
await client.getApps();
|
||||
|
||||
// A third call should now go directly to primary, no failover.
|
||||
await client.getApps();
|
||||
|
||||
final hosts = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured.cast<http.BaseRequest>().map((r) => r.url.host).toList();
|
||||
expect(hosts, [
|
||||
// Phase 1 cold: primary tried, fails, falls over to fallback.
|
||||
'primary.example.com',
|
||||
'fallback.example.com',
|
||||
// Phase 2: sticky=fallback tried, fails, falls over to primary.
|
||||
'fallback.example.com',
|
||||
'primary.example.com',
|
||||
// Third call: sticky=primary, no failover needed.
|
||||
'primary.example.com',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
// Each transport-failure exception type lives on its own line of
|
||||
// _isTransportFailure. Because || short-circuits, only the matched
|
||||
// type's line and the lines before it execute. Test all four to
|
||||
// cover the full chain.
|
||||
for (final entry in <String, Exception>{
|
||||
'HandshakeException': const HandshakeException('tls failure'),
|
||||
'TimeoutException': TimeoutException('connect timeout'),
|
||||
'http.ClientException': http.ClientException('client error'),
|
||||
}.entries) {
|
||||
test('falls over when primary throws ${entry.key}', () async {
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'primary.example.com') {
|
||||
throw entry.value;
|
||||
}
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await client.getApps();
|
||||
|
||||
final hosts = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured.cast<http.BaseRequest>().map((r) => r.url.host).toList();
|
||||
expect(hosts, ['primary.example.com', 'fallback.example.com']);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'rewrites a MultipartRequest preserving fields when failing over',
|
||||
() async {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
addTearDown(() => tempDir.deleteSync(recursive: true));
|
||||
final fixture = File(path.join(tempDir.path, 'patch.txt'))
|
||||
..writeAsStringSync('contents');
|
||||
|
||||
when(() => httpClient.send(any())).thenAnswer((invocation) async {
|
||||
final req =
|
||||
invocation.positionalArguments.first as http.BaseRequest;
|
||||
if (req.url.host == 'primary.example.com') {
|
||||
throw const SocketException('blocked');
|
||||
}
|
||||
if (req.url.host == 'fallback.example.com') {
|
||||
return http.StreamedResponse(
|
||||
Stream.value(
|
||||
utf8.encode(
|
||||
json.encode({
|
||||
'id': 1,
|
||||
'patch_id': 0,
|
||||
'arch': 'aarch64',
|
||||
'platform': 'android',
|
||||
'hash': 'test-hash',
|
||||
'size': 8,
|
||||
'url': 'https://upload.gcs.example.com/x',
|
||||
}),
|
||||
),
|
||||
),
|
||||
HttpStatus.ok,
|
||||
);
|
||||
}
|
||||
// GCS upload passthrough.
|
||||
return http.StreamedResponse(
|
||||
const Stream.empty(),
|
||||
HttpStatus.ok,
|
||||
);
|
||||
});
|
||||
|
||||
await client.createPatchArtifact(
|
||||
appId: 'app-id',
|
||||
artifactPath: fixture.path,
|
||||
patchId: 0,
|
||||
arch: 'aarch64',
|
||||
platform: ReleasePlatform.android,
|
||||
hash: 'test-hash',
|
||||
);
|
||||
|
||||
final sent = verify(
|
||||
() => httpClient.send(captureAny()),
|
||||
).captured.cast<http.BaseRequest>();
|
||||
// Three sends: primary metadata (threw), fallback metadata
|
||||
// (rewritten copy, succeeded), GCS upload (passthrough, no
|
||||
// rewrite).
|
||||
expect(sent.length, equals(3));
|
||||
expect(sent[0], isA<http.MultipartRequest>());
|
||||
expect(sent[0].url.host, equals('primary.example.com'));
|
||||
expect(sent[1], isA<http.MultipartRequest>());
|
||||
expect(sent[1].url.host, equals('fallback.example.com'));
|
||||
expect(sent[1].method, equals('POST'));
|
||||
expect(
|
||||
(sent[1] as http.MultipartRequest).fields,
|
||||
containsPair('arch', 'aarch64'),
|
||||
);
|
||||
expect(sent[2].url.host, equals('upload.gcs.example.com'));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user