fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an unreachable fallback (e.g. the server host's Docker bridge gateway, which plex.tv advertises as a local connection) for a full connect timeout, failing every request in flight during that window (log bbr90). The cascade now probes each candidate with an unauthenticated /identity request under the discovery-race budget and only switches when it answers as the expected server, mirroring the Jellyfin trust gate. Unreachable-looking private IPv4 candidates stay in the list — a client on the server host can legitimately reach them, so reachability is probed, not inferred.
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
@@ -525,6 +526,119 @@ void main() {
|
||||
expect(transport.requestCount, 1);
|
||||
});
|
||||
|
||||
group('Plex endpoint failover candidate validation', () {
|
||||
http.Response identity(String machineIdentifier) => http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'machineIdentifier': machineIdentifier},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
|
||||
test('validates the candidate unauthenticated before the authenticated retry and switches', () async {
|
||||
const primary = 'https://plex.example.com';
|
||||
const fallback = 'https://plex-fallback.example.com';
|
||||
final events = <String>[];
|
||||
final probeRequests = <http.Request>[];
|
||||
final client = testPlexClient(
|
||||
serverId: publicServerId,
|
||||
profileScopeId: defaultProfileScopeId,
|
||||
httpClient: MockClient((request) async {
|
||||
events.add('application:${request.url.host}');
|
||||
expect(request.headers['X-Plex-Token'], isNotNull);
|
||||
if (request.url.host == 'plex.example.com') {
|
||||
throw TimeoutException('primary down');
|
||||
}
|
||||
return identity('server-id');
|
||||
}),
|
||||
prioritizedEndpoints: const [primary, fallback],
|
||||
endpointProbeHttpClientFactory: () => MockClient((request) async {
|
||||
probeRequests.add(request);
|
||||
events.add('probe:${request.url.host}');
|
||||
return identity('server-id');
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
expect(await client.getMachineIdentifier(), 'server-id');
|
||||
|
||||
expect(events, [
|
||||
'application:plex.example.com',
|
||||
'probe:plex-fallback.example.com',
|
||||
'application:plex-fallback.example.com',
|
||||
]);
|
||||
expect(probeRequests.single.url.path, '/identity');
|
||||
expect(probeRequests.single.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-plex-token')));
|
||||
expect(client.config.baseUrl, fallback);
|
||||
});
|
||||
|
||||
test('wrong-machine candidate is skipped before one authenticated retry to a valid candidate', () async {
|
||||
final events = <String>[];
|
||||
var exhausted = 0;
|
||||
final client = testPlexClient(
|
||||
serverId: publicServerId,
|
||||
profileScopeId: defaultProfileScopeId,
|
||||
httpClient: MockClient((request) async {
|
||||
events.add('application:${request.url.host}');
|
||||
if (request.url.host == 'plex.example.com') {
|
||||
throw TimeoutException('primary down');
|
||||
}
|
||||
expect(request.url.host, 'valid.example.com');
|
||||
return identity('server-id');
|
||||
}),
|
||||
prioritizedEndpoints: const [
|
||||
'https://plex.example.com',
|
||||
'https://wrong-machine.example.com',
|
||||
'https://valid.example.com',
|
||||
],
|
||||
endpointProbeHttpClientFactory: () => MockClient((request) async {
|
||||
events.add('probe:${request.url.host}');
|
||||
return identity(request.url.host == 'wrong-machine.example.com' ? 'other-server' : 'server-id');
|
||||
}),
|
||||
onAllEndpointsExhausted: () => exhausted++,
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
expect(await client.getMachineIdentifier(), 'server-id');
|
||||
|
||||
expect(events, [
|
||||
'application:plex.example.com',
|
||||
'probe:wrong-machine.example.com',
|
||||
'probe:valid.example.com',
|
||||
'application:valid.example.com',
|
||||
]);
|
||||
expect(exhausted, 0);
|
||||
expect(client.config.baseUrl, 'https://valid.example.com');
|
||||
});
|
||||
|
||||
test('unreachable candidate receives no authenticated request and the base URL stays put', () async {
|
||||
final events = <String>[];
|
||||
var exhausted = 0;
|
||||
final client = testPlexClient(
|
||||
serverId: publicServerId,
|
||||
profileScopeId: defaultProfileScopeId,
|
||||
httpClient: MockClient((request) async {
|
||||
events.add('application:${request.url.host}');
|
||||
expect(request.url.host, 'plex.example.com', reason: 'unvalidated candidates must not see the token');
|
||||
throw TimeoutException('primary down');
|
||||
}),
|
||||
prioritizedEndpoints: const ['https://plex.example.com', 'https://unreachable.example.com'],
|
||||
endpointProbeHttpClientFactory: () => MockClient((request) async {
|
||||
events.add('probe:${request.url.host}');
|
||||
throw TimeoutException('probe unavailable');
|
||||
}),
|
||||
onAllEndpointsExhausted: () => exhausted++,
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
expect(await client.getMachineIdentifier(), isNull);
|
||||
|
||||
expect(events, ['application:plex.example.com', 'probe:unreachable.example.com']);
|
||||
expect(exhausted, 1);
|
||||
expect(client.config.baseUrl, 'https://plex.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
test('metadata edit preserves locked fields and removed tag wire format', () async {
|
||||
http.Request? captured;
|
||||
final client = makeClient((request) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
@@ -248,6 +249,17 @@ void main() {
|
||||
serverName: 'Server',
|
||||
httpClient: httpClient,
|
||||
prioritizedEndpoints: const [primary, fallback],
|
||||
// The candidate must validate for the cascade to reach the
|
||||
// authenticated retry whose failure this test pins.
|
||||
endpointProbeHttpClientFactory: () => MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'machineIdentifier': 'server-id'},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
|
||||
@@ -149,6 +149,8 @@ PlexClient testPlexClient({
|
||||
http.Client? httpClient,
|
||||
Future<http.Response> Function(http.Request request)? handler,
|
||||
List<String>? prioritizedEndpoints,
|
||||
http.Client Function()? endpointProbeHttpClientFactory,
|
||||
void Function()? onAllEndpointsExhausted,
|
||||
List<({String identifier, String gridEndpoint})> epgProviders = const [],
|
||||
String? homeHubKey,
|
||||
String? promotedHubKey,
|
||||
@@ -163,6 +165,8 @@ PlexClient testPlexClient({
|
||||
serverName: serverName,
|
||||
httpClient: httpClient ?? MockClient(handler ?? _defaultResponse),
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
endpointProbeHttpClientFactory: endpointProbeHttpClientFactory,
|
||||
onAllEndpointsExhausted: onAllEndpointsExhausted,
|
||||
epgProviders: epgProviders,
|
||||
homeHubKey: homeHubKey,
|
||||
promotedHubKey: promotedHubKey,
|
||||
|
||||
Reference in New Issue
Block a user