From c28ce0eb4f83ab9d9828a09eaffca56e25bdbdf6 Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Sun, 9 Aug 2026 20:45:14 +0000 Subject: [PATCH] fix: preserve Twitch live state on API failure --- Jobs/TwitchLiveJob.cs | 9 +++++++- Morpheus.Tests/TwitchServiceTests.cs | 31 ++++++++++++++++++++++++++++ Services/TwitchService.cs | 17 ++++++++++++--- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Jobs/TwitchLiveJob.cs b/Jobs/TwitchLiveJob.cs index 89567cd..c010e1b 100644 --- a/Jobs/TwitchLiveJob.cs +++ b/Jobs/TwitchLiveJob.cs @@ -28,7 +28,14 @@ public async Task Execute(IJobExecutionContext context) return; List userIds = subscriptions.Select(s => s.TwitchUserId).Distinct().ToList(); - IReadOnlyDictionary live = await twitch.GetLiveStreamsAsync(userIds); + TwitchService.LiveStreamsResult result = await twitch.GetLiveStreamsResultAsync(userIds, context.CancellationToken); + if (!result.Succeeded) + { + logsService.Log("TwitchLiveJob: live-status request failed; preserving existing subscription state.", LogSeverity.Warning); + return; + } + + IReadOnlyDictionary live = result.Streams; bool changed = false; diff --git a/Morpheus.Tests/TwitchServiceTests.cs b/Morpheus.Tests/TwitchServiceTests.cs index 54a5a39..1b43d7c 100644 --- a/Morpheus.Tests/TwitchServiceTests.cs +++ b/Morpheus.Tests/TwitchServiceTests.cs @@ -35,6 +35,18 @@ public void CalculateTokenCacheDuration_NeverCachesBeyondExpiry(int expiresInSec Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), duration); } + [Fact] + public async Task GetLiveStreamsResultAsync_WhenStreamsRequestFails_MarksResultAsUnknown() + { + using HttpClient httpClient = new(new StreamsFailureHandler()); + TwitchService service = new(new LogsService(new LogQueue()), httpClient, "test-client", "test-secret"); + + TwitchService.LiveStreamsResult result = await service.GetLiveStreamsResultAsync(["123"]); + + Assert.False(result.Succeeded); + Assert.Empty(result.Streams); + } + private sealed class CancellationHandler(bool blockTokenRequest) : HttpMessageHandler { private readonly TaskCompletionSource requestStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -56,4 +68,23 @@ protected override async Task SendAsync(HttpRequestMessage throw new InvalidOperationException("The canceled Twitch request unexpectedly completed."); } } + + private sealed class StreamsFailureHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri?.Host == "id.twitch.tv") + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\":\"token\",\"expires_in\":3600}", Encoding.UTF8, "application/json") + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("temporarily unavailable") + }); + } + } } diff --git a/Services/TwitchService.cs b/Services/TwitchService.cs index 1f87e61..99bb2cc 100644 --- a/Services/TwitchService.cs +++ b/Services/TwitchService.cs @@ -46,6 +46,7 @@ internal TwitchService(LogsService logsService, HttpClient httpClient, string? c public record TwitchUser(string Id, string Login, string DisplayName, string? ProfileImageUrl); public record TwitchStream(string Id, string Title); + public record LiveStreamsResult(IReadOnlyDictionary Streams, bool Succeeded); /// Resolves a Twitch login (handle) to its user, or null if not found / not configured. public async Task GetUserAsync(string login, CancellationToken ct = default) @@ -67,10 +68,20 @@ public record TwitchStream(string Id, string Title); /// present in the result are offline. Empty if not configured. /// public async Task> GetLiveStreamsAsync(IReadOnlyCollection userIds, CancellationToken ct = default) + { + LiveStreamsResult result = await GetLiveStreamsResultAsync(userIds, ct); + return result.Streams; + } + + /// + /// Returns currently-live streams together with whether every Twitch request completed. + /// An unsuccessful result must not be interpreted as "everyone is offline" by polling jobs. + /// + public async Task GetLiveStreamsResultAsync(IReadOnlyCollection userIds, CancellationToken ct = default) { Dictionary live = new(); if (!IsConfigured || userIds.Count == 0) - return live; + return new LiveStreamsResult(live, true); // Helix allows up to 100 user_id params per request. foreach (string[] batch in userIds.Distinct().Chunk(100)) @@ -79,7 +90,7 @@ public async Task> GetLiveStreamsAsync string url = $"https://api.twitch.tv/helix/streams?{query}"; HelixStreamsResponse? resp = await SendHelixAsync(url, ct); if (resp?.Data == null) - continue; + return new LiveStreamsResult(live, false); foreach (StreamPayload s in resp.Data) { @@ -88,7 +99,7 @@ public async Task> GetLiveStreamsAsync } } - return live; + return new LiveStreamsResult(live, true); } private async Task SendHelixAsync(string url, CancellationToken ct) where T : class