Skip to content

Commit

Permalink
FEAT: Initial design of the rate limit handler (#51)
Browse files Browse the repository at this point in the history
* 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
nickfloyd and kfcampbell committed Apr 24, 2024
1 parent 7308bfd commit ad2f59e
Show file tree
Hide file tree
Showing 6 changed files with 813 additions and 39 deletions.
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

0 comments on commit ad2f59e

Please sign in to comment.