Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
FEAT: Initial design of the rate limit handler (#51)
* initial design of the ratelimit handler * updates rate limit handler to better accomidate the patterns from the Go implementation * Run formatting to fix build errors * Add CLI for hammering the github/github API in test environment * Refactor RateLimitHandler slightly for ease of debugging * Intellisense now working for me locally * adds launch, reworks test harness to call the zen API and modifies the rate limit handler to encapsulate the error and return a meaningful message on primary failures * Primary rate limiting working * Secondary rate limiting triggering successfully * Simplify example CLI * Log additional primary rate limiting information * Program.cs switches to serial requests, is formatted * Add explicit restore step for better error message * Build main project before restoring * Fix primary rate limiting logic from throwing 403s * CLI tweaks for triggering secondary rate limiting * Add additional rate limit testing for IsRateLimited method * Refactor RateLimitHandler so ParseRateLimit and child functions take in time * Add ParseRateLimit unit tests * Add ParseRetryAfterHeader and ParseXRateLimitReset tests * Add mocks and pipeline tests for RateLimitHandlerTests * Strip out test CLI from PR * Remove unnecessary test and shorten test time * Remove empty line in solution file * Small PR spacing and comment tweaks * Run dotnet format --------- Co-authored-by: Keegan Campbell <me@kfcampbell.com>
- Loading branch information
1 parent
7308bfd
commit ad2f59e
Showing
6 changed files
with
813 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.