diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 9ec5b112..5fe033a8 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; @@ -35,7 +36,7 @@ public class DiscordClient { using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); - // Don't validate because token can have invalid characters + // Don't validate because token can have special characters // https://github.com/Tyrrrz/DiscordChatExporter/issues/828 request.Headers.TryAddWithoutValidation( "Authorization", @@ -44,11 +45,32 @@ public class DiscordClient : _token ); - return await Http.Client.SendAsync( + var response = await Http.Client.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, innerCancellationToken ); + + // If this was the last request available before hitting the rate limit, + // wait out the reset time so that future requests can succeed. + // This may add an unnecessary delay in case the user doesn't intend to + // make any more requests, but implementing a smarter solution would + // require properly keeping track of Discord's global/per-route/per-resource + // rate limits and that's not worth the effort. + var remainingRequestCount = response + .Headers + .TryGetValue("X-RateLimit-Remaining")? + .Pipe(s => int.Parse(s, CultureInfo.InvariantCulture)); + + var resetAfterDelay = response + .Headers + .TryGetValue("X-RateLimit-Reset-After")? + .Pipe(s => TimeSpan.FromSeconds(double.Parse(s, CultureInfo.InvariantCulture))); + + if (remainingRequestCount <= 0 && resetAfterDelay is not null) + await Task.Delay(resetAfterDelay.Value + TimeSpan.FromSeconds(1), innerCancellationToken); + + return response; }, cancellationToken); } diff --git a/DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs b/DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs index 0f74ad1a..971a8e34 100644 --- a/DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs +++ b/DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs @@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Utils.Extensions; public static class HttpExtensions { - public static string? TryGetValue(this HttpContentHeaders headers, string name) => + public static string? TryGetValue(this HttpHeaders headers, string name) => headers.TryGetValues(name, out var values) ? string.Concat(values) : null; diff --git a/DiscordChatExporter.Core/Utils/Http.cs b/DiscordChatExporter.Core/Utils/Http.cs index 77ae52ad..ab25951b 100644 --- a/DiscordChatExporter.Core/Utils/Http.cs +++ b/DiscordChatExporter.Core/Utils/Http.cs @@ -39,19 +39,9 @@ public static class Http 8, (i, result, _) => { - // If rate-limited, use retry-after as a guide - if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests) - { - // Only start respecting retry-after after a few attempts, because - // Discord often sends unreasonable (20+ minutes) retry-after - // on the very first request. - if (i > 3) - { - var retryAfterDelay = result.Result.Headers.RetryAfter?.Delta; - if (retryAfterDelay is not null) - return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case - } - } + // If rate-limited, use retry-after header as the guide + if (result.Result.Headers.RetryAfter?.Delta is { } retryAfter) + return retryAfter + TimeSpan.FromSeconds(1); return TimeSpan.FromSeconds(Math.Pow(2, i) + 1); },