Files
plezy/lib/utils/failover_http_client.dart
T
edde746 0c5308cfa1 refactor(networking): drop Cronet and NSURLSession for the tuned dart:io client
Plezy carried three native HTTP clients on the assumption that they beat
Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS,
Windows, Linux and three Android devices, two of them do not.

Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN
body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on
sequential API calls. It also fails a 60-way fan-out outright with
net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and
cost 70-705 ms of CronetEngine.build on first use. Paying that build off the
critical path is the only reason AndroidPlatformHttpClient,
warmUpPlatformHttpClient and the per-request delegate swap existed; all three
go away with it.

CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs
60 req/s, and no reported issue ever justified it. tvOS already shipped the
dart:io client, so Apple platforms now agree with it.

WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io
equivalent, and it brings the system proxy and the Schannel trust store.

The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient
so generic tracker and auth clients stayed disposable, but every dart:io client
has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so
tuned and untuned already share shutdown semantics and the flag only cost
throughput: 12 connections per host with a 90s idle measured ~4x the dart:io
default on a 60-way fan-out on Linux and ~2x on Android.

media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's
CronetDataSource independently of package:cronet_http.

Refs #2140.
2026-08-27 09:21:48 +02:00

315 lines
12 KiB
Dart

import 'app_logger.dart';
import 'media_server_http_client.dart';
import '../exceptions/media_server_exceptions.dart';
/// [MediaServerHttpClient] with endpoint failover, shared by both backends
/// (the single implementation of what used to be `PlexClient._getWithFailover`
/// and `_JellyfinFailoverHttpClient`).
///
/// Semantics — decided once, here ([retryTransientMediaServerCall]'s doc
/// cross-references this):
///
/// - **Failover is GET-only.** Mutations (POST/PUT/DELETE) fail fast on both
/// backends: replaying a mutation against a second endpoint when the first
/// was flaky-but-alive risks double-application, and no caller needs it.
/// - **Trigger:** a transient transport failure
/// ([MediaServerHttpException.isTransient]) or a 5xx — whether thrown or
/// returned as a response. 4xx answers never trigger failover.
/// - **One authenticated retry per cascade.** Candidate validation may skip
/// rejected endpoints before that retry. A failed retry (transport error or
/// error status) resets the list to the preferred endpoint and fires
/// [onAllEndpointsExhausted]; the next cascade starts from the best candidate
/// again. Concurrent requests are generation-stamped so a request raced by a
/// switch doesn't cascade a second time.
/// - **Persistence is two-phase:** the switch is applied with
/// `persist: false` for the retry, and only a successful retry persists the
/// winner (`persist: true`).
/// - **Retry interplay:** [retryTransientMediaServerCall] is for
/// *slow-but-working* endpoints (per-surface timeout budgets); surfaces
/// that wrap it pass `allowEndpointFailover: false` so a slow row doesn't
/// move the whole client off an otherwise working endpoint. Failover is for
/// *dead* endpoints.
///
/// Endpoint orchestration diagnostics never contain raw endpoint literals.
/// Backends still register configured endpoints before construction to protect
/// unavoidable lower-level HTTP diagnostics.
class FailoverHttpClient extends MediaServerHttpClient {
/// [prioritizedEndpoints] may be empty (failover disabled — plain client
/// behavior). A single-entry list still arms [onAllEndpointsExhausted]:
/// the lone endpoint failing *is* exhaustion, and the owning manager uses
/// that to flip server status and reconnect.
FailoverHttpClient({
super.client,
required super.baseUrl,
required super.defaultHeaders,
super.connectTimeout,
super.receiveTimeout,
required this.logLabel,
required List<String> prioritizedEndpoints,
required this.onEndpointSwitch,
this.onAllEndpointsExhausted,
this.validateCandidate,
}) : _endpointManager = prioritizedEndpoints.isNotEmpty ? EndpointFailoverManager(prioritizedEndpoints) : null;
/// Backend name for log lines ('Plex' / 'Jellyfin') — keeps failover logs
/// greppable per backend now that the implementation is shared.
final String logLabel;
final EndpointFailoverManager? _endpointManager;
/// Applies a base-URL change on the owning client. The callback must update
/// this client's [baseUrl] alongside its own config/connection snapshot
/// (the two-phase protocol calls it with `persist: false` before the retry
/// and `persist: true` only after a success — persistence must not be gated
/// on the URL having changed, since the second call sees it already applied).
final Future<void> Function(String newBaseUrl, {required bool persist}) onEndpointSwitch;
/// Optional trust gate run after a fallback is selected but before any
/// switch callback, base-URL mutation, or authenticated retry. The active
/// request's abort controller must cancel validation as well as the retry.
final Future<bool> Function(String candidateBaseUrl, AbortController? abort)? validateCandidate;
/// Fired when a cascade ends without a working endpoint (or the retry
/// itself fails). The owning manager debounces this into a server-offline
/// flip + reconnection.
final void Function()? onAllEndpointsExhausted;
bool _failoverSwitching = false;
/// Endpoints currently configured, preferred-first (test/diagnostic view).
List<String> get endpoints => _endpointManager?.endpoints ?? const [];
/// Replace the endpoint list after a connection refresh, keeping
/// [currentBaseUrl] active when provided (it must be present in the list).
void resetEndpoints(List<String> prioritizedEndpoints, {String? currentBaseUrl}) {
_endpointManager?.reset(prioritizedEndpoints, currentBaseUrl: currentBaseUrl);
}
@override
Future<MediaServerResponse> get(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
}) async {
final generation = _endpointManager?.generation;
final MediaServerResponse response;
try {
response = await super.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
} on MediaServerHttpException catch (e) {
if (!allowEndpointFailover || !_shouldAttemptFailover(exception: e) || !_canFailover(generation)) {
rethrow;
}
final retried = await _failoverOnce(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
if (retried == null) rethrow;
return retried;
}
if (!allowEndpointFailover ||
!_shouldAttemptFailover(statusCode: response.statusCode) ||
!_canFailover(generation)) {
return response;
}
return await _failoverOnce(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
) ??
response;
}
bool _canFailover(int? requestGeneration) {
final manager = _endpointManager;
return manager != null && !_failoverSwitching && requestGeneration == manager.generation;
}
bool _shouldAttemptFailover({MediaServerHttpException? exception, int? statusCode}) {
if (exception != null) {
if (exception.isTransient) return true;
statusCode = exception.statusCode;
}
return statusCode != null && statusCode >= 500 && statusCode <= 599;
}
/// One step of the cascade: validate candidates in priority order, move to
/// the first accepted endpoint, and retry the authenticated request once.
///
/// Returns the retry's response on success. Returns `null` when no accepted
/// fallback exists (after firing [onAllEndpointsExhausted]) — the caller
/// surfaces its original failure. A retry that answers with an error status
/// is returned as-is (the caller's status handling applies), and a retry that
/// throws rethrows; both count as exhaustion: the list resets to the
/// preferred endpoint so the next cascade starts from the best candidate.
Future<MediaServerResponse?> _failoverOnce(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) async {
final manager = _endpointManager!;
if (!manager.hasFallback) {
await _resetToPreferred(manager);
onAllEndpointsExhausted?.call();
return null;
}
final endpoints = manager.endpoints;
final currentIndex = endpoints.indexOf(manager.current);
if (currentIndex < 0 || currentIndex >= endpoints.length - 1) return null;
final candidateGeneration = manager.generation;
_failoverSwitching = true;
try {
final validator = validateCandidate;
String? selectedBaseUrl;
for (var candidateIndex = currentIndex + 1; candidateIndex < endpoints.length; candidateIndex++) {
final candidateBaseUrl = endpoints[candidateIndex];
var accepted = validator == null;
if (validator != null) {
try {
accepted = await validator(candidateBaseUrl, abort);
} catch (error) {
if (error is MediaServerHttpException && error.isCancellation) rethrow;
accepted = false;
}
}
if (accepted) {
selectedBaseUrl = candidateBaseUrl;
break;
}
}
if (selectedBaseUrl == null) {
// Validation happens before moving the cursor, so the last accepted
// endpoint remains authoritative for both the manager and live client.
onAllEndpointsExhausted?.call();
return null;
}
abort?.throwIfAborted();
if (manager.generation != candidateGeneration || manager.current != endpoints[currentIndex]) {
return null;
}
String? movedBaseUrl;
do {
movedBaseUrl = manager.moveToNext();
} while (movedBaseUrl != null && movedBaseUrl != selectedBaseUrl);
if (movedBaseUrl != selectedBaseUrl) return null;
appLogger.i('Switching $logLabel endpoint after GET failure');
await onEndpointSwitch(selectedBaseUrl, persist: false);
final response = await super.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
if (response.statusCode < 400) {
appLogger.i('$logLabel endpoint failover retry succeeded');
await onEndpointSwitch(selectedBaseUrl, persist: true);
return response;
}
await _resetToPreferred(manager);
onAllEndpointsExhausted?.call();
return response;
} catch (error) {
await _resetToPreferred(manager);
if (error is! MediaServerHttpException || !error.isCancellation) {
onAllEndpointsExhausted?.call();
}
rethrow;
} finally {
_failoverSwitching = false;
}
}
Future<void> _resetToPreferred(EndpointFailoverManager manager) async {
final resetBaseUrl = manager.resetToFirst();
if (resetBaseUrl != null) {
await onEndpointSwitch(resetBaseUrl, persist: false);
}
}
}
/// Maintains the list of endpoints we can cycle through when one fails.
class EndpointFailoverManager {
EndpointFailoverManager(List<String> urls) {
_setEndpoints(urls);
}
late List<String> _endpoints;
int _currentIndex = 0;
/// Incremented every time the active endpoint changes. Requests stamped with
/// an older generation should not trigger additional failover cascades.
int _generation = 0;
int get generation => _generation;
List<String> get endpoints => List.unmodifiable(_endpoints);
String get current => _endpoints[_currentIndex];
bool get hasFallback => _currentIndex < _endpoints.length - 1;
/// Move to the next endpoint, returning its URL or null if exhausted.
String? moveToNext() {
if (!hasFallback) return null;
_currentIndex++;
_generation++;
return _endpoints[_currentIndex];
}
/// Reset back to the first (preferred) endpoint. Called when all endpoints
/// are exhausted so the next failure cycle starts from the best candidate.
String? resetToFirst() {
if (_currentIndex != 0) {
_currentIndex = 0;
_generation++;
appLogger.d('Failover endpoint list reset to first candidate');
return _endpoints[_currentIndex];
}
return null;
}
/// Replace the endpoint list and optionally set the active endpoint.
void reset(List<String> urls, {String? currentBaseUrl}) {
_setEndpoints(urls);
if (currentBaseUrl != null) {
final index = _endpoints.indexOf(currentBaseUrl);
_currentIndex = index >= 0 ? index : 0;
} else {
_currentIndex = 0;
}
_generation++;
}
void _setEndpoints(List<String> urls) {
final sanitized = <String>[];
final seen = <String>{};
for (final url in urls) {
if (url.isEmpty || seen.contains(url)) continue;
seen.add(url);
sanitized.add(url);
}
if (sanitized.isEmpty) {
throw ArgumentError('At least one endpoint is required');
}
_endpoints = sanitized;
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1);
}
}