Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT: Initial design of the rate limit handler #51

Merged
merged 30 commits into from Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
91fb68e
initial design of the ratelimit handler
nickfloyd Mar 8, 2024
196ffc2
Merge branch 'main' into add-ratelimit-handler
nickfloyd Apr 3, 2024
4908561
updates rate limit handler to better accomidate the patterns from the…
nickfloyd Apr 3, 2024
9978b5f
Merge branch 'main' into add-ratelimit-handler
nickfloyd Apr 11, 2024
5423ffa
Run formatting to fix build errors
kfcampbell Apr 11, 2024
020b817
Add CLI for hammering the github/github API in test environment
kfcampbell Apr 11, 2024
d62b0d3
Refactor RateLimitHandler slightly for ease of debugging
kfcampbell Apr 11, 2024
db39711
Intellisense now working for me locally
kfcampbell Apr 16, 2024
e3d3f5d
adds launch, reworks test harness to call the zen API and modifies th…
nickfloyd Apr 16, 2024
69576f6
Primary rate limiting working
kfcampbell Apr 16, 2024
3571138
Secondary rate limiting triggering successfully
kfcampbell Apr 16, 2024
d0f78e9
Simplify example CLI
kfcampbell Apr 16, 2024
a9aa6dc
Log additional primary rate limiting information
kfcampbell Apr 16, 2024
4a058b2
Program.cs switches to serial requests, is formatted
kfcampbell Apr 17, 2024
aa1767d
Add explicit restore step for better error message
kfcampbell Apr 17, 2024
379caee
Build main project before restoring
kfcampbell Apr 17, 2024
7e056f5
Fix primary rate limiting logic from throwing 403s
kfcampbell Apr 17, 2024
4e4254c
Merge branch 'main' into add-ratelimit-handler
kfcampbell Apr 17, 2024
e42a0bc
CLI tweaks for triggering secondary rate limiting
kfcampbell Apr 17, 2024
76416a8
Add additional rate limit testing for IsRateLimited method
kfcampbell Apr 22, 2024
702b8e4
Refactor RateLimitHandler so ParseRateLimit and child functions take …
kfcampbell Apr 22, 2024
1492864
Add ParseRateLimit unit tests
kfcampbell Apr 22, 2024
fa5bb0b
Add ParseRetryAfterHeader and ParseXRateLimitReset tests
kfcampbell Apr 22, 2024
ebbf366
Add mocks and pipeline tests for RateLimitHandlerTests
kfcampbell Apr 23, 2024
d3ed6bd
Strip out test CLI from PR
kfcampbell Apr 23, 2024
0867399
Remove unnecessary test and shorten test time
kfcampbell Apr 23, 2024
6a94404
Remove empty line in solution file
kfcampbell Apr 23, 2024
4bab199
Small PR spacing and comment tweaks
kfcampbell Apr 23, 2024
eacfc8a
Run dotnet format
kfcampbell Apr 23, 2024
d96dba0
Merge branch 'main' into add-ratelimit-handler
kfcampbell Apr 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
81 changes: 42 additions & 39 deletions GitHub.Octokit.sln
@@ -1,39 +1,42 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Octokit.SDK", "src\GitHub.Octokit.SDK.csproj", "{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{15BC8D73-8583-4A91-9355-970404126E9B}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
LICENSE = LICENSE
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "test\Tests.csproj", "{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Release|Any CPU.Build.0 = Release|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4D28588D-BC68-4DCD-9DAD-229FC5CB50CC}
EndGlobalSection
EndGlobal
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Octokit.SDK", "src\GitHub.Octokit.SDK.csproj", "{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{15BC8D73-8583-4A91-9355-970404126E9B}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
LICENSE = LICENSE
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "test\Tests.csproj", "{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C118D1F2-D47D-4488-9D2F-88BA4A7729BD}.Release|Any CPU.Build.0 = Release|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}.Release|Any CPU.Build.0 = Release|Any CPU
{3A6900D7-23D6-4AE5-B76A-76A05CD336F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3A6900D7-23D6-4AE5-B76A-76A05CD336F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3A6900D7-23D6-4AE5-B76A-76A05CD336F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3A6900D7-23D6-4AE5-B76A-76A05CD336F1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4D28588D-BC68-4DCD-9DAD-229FC5CB50CC}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions src/Client/ClientFactory.cs
Expand Up @@ -15,6 +15,7 @@ public static class ClientFactory
[
new APIVersionHandler(),
new UserAgentHandler(),
new RateLimitHandler(),
]);

/// <summary>
Expand Down
199 changes: 199 additions & 0 deletions src/Middleware/RateLimitHandler.cs
@@ -0,0 +1,199 @@
using System.Net;

namespace GitHub.Octokit.Client.Middleware;

public enum RateLimitType
{
None,
Primary,
Secondary
}

public interface IRateLimitHandlerOptions
{
Func<HttpRequestMessage, HttpResponseMessage, RateLimitType> IsRateLimited { get; }
}

/// <summary>
/// Represents the options for the rate limit handler.
/// </summary>
public class RateLimitHandlerOptions : IRateLimitHandlerOptions
{
/// <summary>
/// Gets the function that determines if the request is rate limited.
/// The function should return the type of rate limit that is applied to the request.
/// If the request is not rate limited, the function should return <see cref="RateLimitType.None"/>.
/// </summary>
public Func<HttpRequestMessage, HttpResponseMessage, RateLimitType> IsRateLimited => (request, response) =>
{
if (response.StatusCode != HttpStatusCode.TooManyRequests
&& response.StatusCode != HttpStatusCode.Forbidden)
{
return RateLimitType.None;
}

var retryAfter = response.Headers.RetryAfter;
var rateLimitRemaining = response.Headers.Contains("X-RateLimit-Remaining")
? response.Headers.GetValues("X-RateLimit-Remaining").FirstOrDefault()
: null;

if (retryAfter != null && rateLimitRemaining != "0")
{
return RateLimitType.Secondary;
}

if (rateLimitRemaining == "0")
{
return RateLimitType.Primary;
}
else
{
return RateLimitType.None;
}
};
}

/// <summary>
/// Represents a handler that handles rate limiting.
/// This handler will check if the request is rate limited and will handle the rate limiting accordingly.
/// If the request is rate limited, the handler will wait for the specified duration and retry the request.
/// </summary>
public class RateLimitHandler : DelegatingHandler
{
public const string XRateLimitRemainingKey = "X-RateLimit-Remaining";
public const string XRateLimitResetKey = "X-RateLimit-Reset";
public const string XRateLimitLimitKey = "X-RateLimit-Limit";
public const string XRateLimitUsedKey = "X-RateLimit-Used";
public const string XRateLimitResourceKey = "X-RateLimit-Resource";
public const string RetryAfterKey = "Retry-After";

private readonly IRateLimitHandlerOptions _options;

/// <summary>
/// Initializes a new instance of the <see cref="RateLimitHandler"/> class.
/// </summary>
/// <param name="options"></param>
public RateLimitHandler(IRateLimitHandlerOptions? options = null)
{
_options = options ?? new RateLimitHandlerOptions();
}

/// <summary>
/// Sends an HTTP request to the inner handler to send to the server as an asynchronous operation.
/// This method will check if the request is rate limited and will handle the rate limiting accordingly.
/// If the request is rate limited, the handler will wait for the specified duration and retry the request.
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
var rateLimit = _options.IsRateLimited(request, response);

if (rateLimit != RateLimitType.None)
{
var retryAfterDuration = ParseRateLimit(response, DateTime.UtcNow);
if (rateLimit == RateLimitType.Primary)
{
// TODO(kfcampbell): investigate ways to do logging/notifications in a .NET library
Console.WriteLine($"Primary rate limit (reset: {response.Headers.GetValues(XRateLimitResetKey).FirstOrDefault()}) exceeded. " +
$"Sleeping for {retryAfterDuration?.TotalSeconds ?? 0} seconds before retrying.");

Console.WriteLine($"Rate limit information: {XRateLimitLimitKey}: {response.Headers.GetValues(XRateLimitLimitKey).FirstOrDefault()}, " +
$"{XRateLimitUsedKey}: {response.Headers.GetValues(XRateLimitUsedKey).FirstOrDefault()}, " +
$"{XRateLimitResourceKey}: {response.Headers.GetValues(XRateLimitResourceKey).FirstOrDefault()}");
}
else if (rateLimit == RateLimitType.Secondary)
{
Console.WriteLine($"Abuse detection mechanism (secondary rate limit) triggered. " +
$"Sleeping for {retryAfterDuration?.TotalSeconds ?? 0} seconds before retrying.");
}
if (retryAfterDuration.HasValue && retryAfterDuration.Value.TotalSeconds > 0)
{
await Task.Delay(retryAfterDuration.Value, cancellationToken);
}
else
{
Console.WriteLine($"Could not parse a valid time to wait for rate limit reset (parsed {retryAfterDuration?.TotalSeconds ?? 0} seconds). Retrying request immediately.");
}
response = await SendAsync(request, cancellationToken);
}

return response;
}

/// <summary>
/// Parses the rate limit from the response.
/// This method will parse the rate limit from the response and return the duration to wait before retrying the request.
/// If the response does not contain a rate limit, this method will return null.
/// Note that "Retry-After" headers correspond to secondary rate limits and
/// "x-ratelimit-reset" headers to primary rate limits.
/// Docs for rate limit headers:
/// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28#handle-rate-limit-errors-appropriately
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
protected TimeSpan? ParseRateLimit(HttpResponseMessage response, DateTime utcNow)
{
// "If the retry-after response header is present, you should not retry
// your request until after that many seconds has elapsed."
// (see docs link above)
if (response.Headers.RetryAfter != null)
{
return ParseRetryAfterHeader(response, utcNow);
}
// If the x-ratelimit-remaining header is 0, you should not make another
// request until after the time specified by the x-ratelimit-reset header.
// The x-ratelimit-reset header is in UTC epoch seconds.
else if (response.Headers.Contains(XRateLimitResetKey))
{
return ParseXRateLimitReset(response, utcNow);
}
return null;
}

/// <summary>
/// Parses the Retry-After header from the response.
/// This method will parse the Retry-After header from the response and return the duration to wait before retrying the request.
/// If the response does not contain a Retry-After header, this method will return null.
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
protected TimeSpan? ParseRetryAfterHeader(HttpResponseMessage response, DateTime utcNow)
{
if (response.Headers.RetryAfter != null)
{
var retryAfter = response.Headers.RetryAfter;
if (retryAfter.Delta.HasValue)
{
return retryAfter.Delta;
}
else if (retryAfter.Date.HasValue)
{
var retryAfterTimeSpan = retryAfter.Date.Value.UtcDateTime - utcNow;
return retryAfterTimeSpan.Ticks > 0 ? retryAfterTimeSpan : null;
}
}
return null;
}

/// <summary>
/// ParseXRateLimitReset returns a TimeSpan that corresponds to the time until the given
/// X-RateLimit-Reset header value. If the header is not present or the
/// value cannot be parsed into a valid TimeSpan, the method will return null.
/// </summary>
/// <param name="response"></param>
/// <returns></returns> <summary>
protected TimeSpan? ParseXRateLimitReset(HttpResponseMessage response, DateTime utcNow)
{
var rateLimitReset = response.Headers.GetValues(XRateLimitResetKey).FirstOrDefault();
if (rateLimitReset != null && long.TryParse(rateLimitReset, out var rateLimitResetValue))
{
var rateLimitResetDateTime = DateTimeOffset.FromUnixTimeSeconds(rateLimitResetValue);
var rateLimitResetTimeSpan = rateLimitResetDateTime.UtcDateTime - utcNow;
return rateLimitResetTimeSpan.Ticks > 0 ? rateLimitResetTimeSpan : null;
}
return null;
}
}
1 change: 1 addition & 0 deletions test/Client/ClientFactoryTest.cs
Expand Up @@ -38,6 +38,7 @@ public void CreateDefaultHandlers_Returns_Expected_Handlers()
var handlers = ClientFactory.CreateDefaultHandlers();
Assert.Contains(handlers, h => h is APIVersionHandler);
Assert.Contains(handlers, h => h is UserAgentHandler);
Assert.Contains(handlers, h => h is RateLimitHandler);
}

[Fact]
Expand Down