Files
plezy/test/utils/media_server_retry_test.dart
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
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
2026-08-04 04:35:06 +02:00

205 lines
6.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/media_server_retry.dart';
MediaServerHttpException _error(MediaServerHttpErrorType type) =>
MediaServerHttpException(type: type, message: type.name);
void main() {
group('retryTransientMediaServerCall', () {
test('runs the call once with the whole deadline as its budget', () async {
final seenTimeouts = <Duration>[];
final result = await retryTransientMediaServerCall<String>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (timeout, _) async {
seenTimeouts.add(timeout);
return 'ok';
},
);
expect(result, 'ok');
expect(seenTimeouts, [const Duration(seconds: 20)]);
});
// The regression this whole policy exists for: `http.Client.send` resolves
// on response HEADERS, so a slow-but-alive server surfaces as
// connectionTimeout. Replaying it made the server re-run the same query and
// turned an 11s answer into an empty row after 23s (#1784).
test('does not replay a timeout — the server is working, just slow', () async {
var attempts = 0;
await expectLater(
retryTransientMediaServerCall<void>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (_, _) async {
attempts++;
throw _error(MediaServerHttpErrorType.connectionTimeout);
},
),
throwsA(
isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.connectionTimeout),
),
);
expect(attempts, 1);
});
test('a slow response that lands inside the deadline resolves, and is requested once', () {
fakeAsync((async) {
var attempts = 0;
Object? result;
// Answers at T+11s, well past the old 10s first-attempt budget.
retryTransientMediaServerCall<String>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (_, _) async {
attempts++;
await Future<void>.delayed(const Duration(seconds: 11));
return 'late but fine';
},
).then((value) => result = value);
async.elapse(const Duration(seconds: 10, milliseconds: 900));
expect(result, isNull, reason: 'still in flight');
expect(attempts, 1);
async.elapse(const Duration(milliseconds: 200));
expect(result, 'late but fine');
expect(attempts, 1, reason: 'never replayed');
});
});
test('retries an immediate connection error', () {
fakeAsync((async) {
final aborts = <AbortController>[];
Object? result;
retryTransientMediaServerCall<String>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (_, abort) async {
aborts.add(abort);
await Future<void>.delayed(const Duration(seconds: 1));
if (aborts.length < 3) throw _error(MediaServerHttpErrorType.connectionError);
return 'ok';
},
).then((value) => result = value);
async.elapse(const Duration(seconds: 5));
expect(result, 'ok');
expect(aborts, hasLength(3));
expect(aborts[0].isAborted, isTrue);
expect(aborts[1].isAborted, isTrue);
expect(aborts[2].isAborted, isFalse);
});
});
test('gives up at the deadline and aborts the in-flight request', () {
fakeAsync((async) {
var attempts = 0;
AbortController? last;
Duration? settledAt;
Object? error;
retryTransientMediaServerCall<void>(
operation: 'test operation',
deadline: const Duration(seconds: 10),
call: (_, abort) async {
attempts++;
last = abort;
// Never answers: the request the deadline has to cut off.
await Future<void>.delayed(const Duration(days: 1));
},
).catchError((Object e) {
error = e;
settledAt = async.elapsed;
});
async.elapse(const Duration(seconds: 30));
expect(attempts, 1, reason: 'a timeout is never replayed');
expect(settledAt, const Duration(seconds: 10));
expect(last?.isAborted, isTrue, reason: 'the in-flight request is torn down');
expect(
error,
isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.connectionTimeout),
);
});
});
test('bounds total wall time by the deadline, not by attempts × timeout', () {
fakeAsync((async) {
Duration? settledAt;
Object? error;
retryTransientMediaServerCall<void>(
operation: 'test operation',
deadline: const Duration(seconds: 15),
call: (_, _) async {
// Every attempt burns most of the budget before failing in a
// retryable way — the worst case for a retry loop.
await Future<void>.delayed(const Duration(seconds: 6));
throw _error(MediaServerHttpErrorType.connectionError);
},
).catchError((Object e) {
error = e;
settledAt = async.elapsed;
});
async.elapse(const Duration(seconds: 120));
expect(error, isA<MediaServerHttpException>());
expect(settledAt, isNotNull);
expect(settledAt!, lessThanOrEqualTo(const Duration(seconds: 15)));
});
});
test('does not retry non-transient failures', () async {
var attempts = 0;
await expectLater(
retryTransientMediaServerCall<void>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (_, _) async {
attempts++;
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: 404,
message: 'HTTP 404',
);
},
),
throwsA(isA<MediaServerHttpException>().having((e) => e.statusCode, 'statusCode', 404)),
);
expect(attempts, 1);
});
test('does not swallow a cancellation as a retryable failure', () async {
var attempts = 0;
await expectLater(
retryTransientMediaServerCall<void>(
operation: 'test operation',
deadline: const Duration(seconds: 20),
call: (_, _) async {
attempts++;
throw _error(MediaServerHttpErrorType.cancelled);
},
),
throwsA(isA<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
);
expect(attempts, 1);
});
});
}