From fa054153aebb40d35ee4b1c423a9176ac03d50cd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:22:13 +0200 Subject: [PATCH] fix(plex): surface a stale-endpoint failure instead of cascading after a mid-probe promotion --- lib/utils/failover_http_client.dart | 6 ++++- test/utils/failover_http_client_test.dart | 31 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/utils/failover_http_client.dart b/lib/utils/failover_http_client.dart index 41e992db8..a22bd241b 100644 --- a/lib/utils/failover_http_client.dart +++ b/lib/utils/failover_http_client.dart @@ -180,7 +180,9 @@ class FailoverHttpClient extends MediaServerHttpClient { /// With [retryInPlace] (a connection error, as opposed to a timeout or 5xx) /// the current endpoint gets a chance to prove it is alive before any switch: /// see [_retryOnCurrentEndpoint]. Otherwise, or when that retry fails, the - /// cascade in [_cascade] runs. + /// cascade in [_cascade] runs — unless a background promotion moved the + /// active endpoint meanwhile, in which case the failure belongs to an + /// endpoint that is no longer current and the caller surfaces it as-is. Future _failoverOnce({ required String verb, required Future Function() send, @@ -188,11 +190,13 @@ class FailoverHttpClient extends MediaServerHttpClient { bool retryInPlace = false, }) async { final manager = _endpointManager!; + final generation = manager.generation; _failoverSwitching = true; try { if (retryInPlace) { final revived = await _retryOnCurrentEndpoint(manager, verb: verb, send: send, abort: abort); if (revived != null) return revived; + if (manager.generation != generation) return null; } return await _cascade(manager, verb: verb, send: send, abort: abort); } finally { diff --git a/test/utils/failover_http_client_test.dart b/test/utils/failover_http_client_test.dart index 4d9f47d3d..cbabf8c03 100644 --- a/test/utils/failover_http_client_test.dart +++ b/test/utils/failover_http_client_test.dart @@ -344,6 +344,37 @@ void main() { expect(h.requests.map((u) => u.host), ['primary.example.com', 'fallback.example.com']); expect(h.client.baseUrl, fallback); }); + + test('a promotion landing during the probe surfaces the failure instead of cascading off it', () async { + final probeStarted = Completer(); + final releaseProbe = Completer(); + final h = build( + endpoints: const [primary, fallback, tertiary], + handler: (request, _) async { + if (request.url.host == 'primary.example.com') { + throw http.ClientException('connection reset', request.url); + } + return ok(request.url.host); + }, + validateCandidate: (_, _) { + probeStarted.complete(); + return releaseProbe.future; + }, + ); + + final pending = h.client.get('/path'); + await probeStarted.future; + // Background optimization promotes a different endpoint mid-probe. + h.client.resetEndpoints(const [tertiary, primary, fallback], currentBaseUrl: tertiary); + h.client.baseUrl = tertiary; + releaseProbe.complete(false); + + await expectLater(pending, throwsA(isA())); + expect(h.requests.map((u) => u.host), ['primary.example.com']); + expect(h.switches, isEmpty); + expect(h.exhausted, isEmpty); + expect(h.client.baseUrl, tertiary); + }); }); test('allowEndpointFailover: false rethrows without switching', () async {