The Discover tab fanned out its whole request set twice on every cold start and replayed slow rows on a shrinking timeout ladder, so a healthy remote server produced anywhere from 4s to 15s of loading. Measured against a remote Jellyfin server with four libraries, 24 interleaved cold-start samples per side: requests 19 -> 9 payload 219 KB -> 94 KB settled 5231ms -> 2502ms median, 13222ms -> 5927ms p95 Four independent causes: - Retry policy. `Client.send` resolves on response headers, so the connect budget covers the server's think time and a slow-but-alive query raises `connectionTimeout`. Replaying it made the server re-run the query with a shorter budget than the one it just missed; the `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after 23s. Hub surfaces now get one whole-request deadline, retry only immediate connection errors, and the deadline bounds the whole call including the request still in flight. - Request shape. `/Items/Latest` groups a TV library by series, so its rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost a DB count each, per row. Hub rows now ask for `Overview` only; watch state survives because Jellyfin derives `UserData.Played` from `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp` sends `NextUpDateCutoff` to bound the server's series-key scan, and `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData` and `PremiereDate` leave the browse set: neither is an `ItemFields` member, so the server dropped them anyway. - Fan-out. Per-library hubs ran in batches of three separated by a barrier, so one slow library stalled every library behind it. A sliding window keeps the same peak concurrency without head-of-line blocking. Concurrent `fetchLibraries` calls now share one `/Views` instead of racing two identical round trips, Plex's global and music hub legs start together, and Jellyfin gets Plex's pool tuning. - Duplicate pass. `DiscoverScreen.initState` starts a load and the online-entry hook asked for a full refresh on top of it, which `CoalescedLoadCoordinator` correctly queued as a trailing pass. The hook now calls `primeRefresh`, which rides along with a load already in flight; profile switches still go through `fullRefresh`. Refs #1784
87 lines
3.4 KiB
Dart
87 lines
3.4 KiB
Dart
import '../exceptions/media_server_exceptions.dart';
|
|
import 'app_logger.dart';
|
|
import 'media_server_http_client.dart';
|
|
|
|
typedef MediaServerRetryCall<T> = Future<T> Function(Duration timeout, AbortController abort);
|
|
|
|
/// Runs a media-server call under a single whole-request [deadline], retrying
|
|
/// only failures that cost nothing to retry.
|
|
///
|
|
/// Retry vs failover (see `FailoverHttpClient` for the other half): retry is
|
|
/// for a *slow-but-working* endpoint, failover is for a *dead* one. Surfaces
|
|
/// wrapped in this helper should pass `allowEndpointFailover: false` on the
|
|
/// inner GET so a slow row doesn't move the whole client off an otherwise
|
|
/// working endpoint — every existing combined call site does.
|
|
///
|
|
/// ## Why timeouts are not retried
|
|
///
|
|
/// `http.Client.send` resolves when response *headers* arrive, so
|
|
/// [MediaServerHttpClient]'s connect budget covers DNS + TCP + TLS + request +
|
|
/// **the server's think time**. A Jellyfin `/Items/Latest` that needs 11s on a
|
|
/// large library therefore raises a `TimeoutException`, which
|
|
/// [MediaServerHttpException.from] types as
|
|
/// [MediaServerHttpErrorType.connectionTimeout] — indistinguishable from a
|
|
/// failed socket connect.
|
|
///
|
|
/// Replaying it makes the server re-run the same expensive query from scratch,
|
|
/// with a *shorter* budget than the one it just missed. The old
|
|
/// `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after 23s
|
|
/// (#1784). A timeout means "this endpoint is slower than we can wait", and the
|
|
/// answer to that is to give up, not to ask three times.
|
|
///
|
|
/// [MediaServerHttpErrorType.connectionError] is different: a refused
|
|
/// connection, DNS failure or reset socket fails immediately and costs the
|
|
/// server nothing, and is frequently a one-off on a mobile link. Those are
|
|
/// retried up to [maxConnectionRetries] times.
|
|
///
|
|
/// [deadline] bounds the whole call — every attempt, plus whatever the last one
|
|
/// is still waiting on — and aborts the in-flight request when it expires. Each
|
|
/// attempt is *also* handed [deadline] as its own budget so the HTTP layer
|
|
/// fails on its own terms first; the outer bound is the backstop that makes the
|
|
/// guarantee unconditional.
|
|
Future<T> retryTransientMediaServerCall<T>({
|
|
required String operation,
|
|
required Duration deadline,
|
|
required MediaServerRetryCall<T> call,
|
|
int maxConnectionRetries = 2,
|
|
}) {
|
|
if (deadline <= Duration.zero) {
|
|
throw ArgumentError.value(deadline, 'deadline', 'must be positive');
|
|
}
|
|
|
|
AbortController? inFlight;
|
|
|
|
Future<T> attempts() async {
|
|
for (var attempt = 0; ; attempt++) {
|
|
final abort = AbortController();
|
|
inFlight = abort;
|
|
try {
|
|
return await call(deadline, abort);
|
|
} on MediaServerHttpException catch (e, st) {
|
|
abort.abort();
|
|
if (e.type != MediaServerHttpErrorType.connectionError || attempt >= maxConnectionRetries) {
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
appLogger.w(
|
|
'Retrying $operation after a connection error',
|
|
error: {'attempt': attempt + 1, 'maxAttempts': maxConnectionRetries + 1, 'type': e.type.name},
|
|
);
|
|
} catch (e, st) {
|
|
abort.abort();
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
}
|
|
|
|
return attempts().timeout(
|
|
deadline,
|
|
onTimeout: () {
|
|
inFlight?.abort();
|
|
throw MediaServerHttpException(
|
|
type: MediaServerHttpErrorType.connectionTimeout,
|
|
message: '$operation exceeded its ${deadline.inSeconds}s budget',
|
|
);
|
|
},
|
|
);
|
|
}
|