Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# Release Notes

## What's New
- Added secondary rate limit handling to GitHub CLI commands to gracefully handle 403/429 responses with proper retry logic, exponential backoff, and clear error messaging. This improves reliability when running multiple concurrent migrations.
85 changes: 83 additions & 2 deletions src/Octoshift/Services/GithubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public class GithubClient

private const string DEFAULT_RATE_LIMIT_REMAINING = "5000";
private const int MILLISECONDS_PER_SECOND = 1000;
private const int SECONDARY_RATE_LIMIT_MAX_RETRIES = 3;
private const int SECONDARY_RATE_LIMIT_DEFAULT_DELAY = 60; // 60 seconds default delay

public GithubClient(OctoLogger log, HttpClient httpClient, IVersionProvider versionProvider, RetryPolicy retryPolicy, DateTimeProvider dateTimeProvider, string personalAccessToken)
{
Expand Down Expand Up @@ -159,7 +161,8 @@ public virtual async Task<string> PatchAsync(string url, object body, Dictionary
string url,
object body = null,
HttpStatusCode expectedStatus = HttpStatusCode.OK,
Dictionary<string, string> customHeaders = null)
Dictionary<string, string> customHeaders = null,
int retryCount = 0)
{
await ApplyRetryDelayAsync();
_log.LogVerbose($"HTTP {httpMethod}: {url}");
Expand Down Expand Up @@ -199,9 +202,15 @@ public virtual async Task<string> PatchAsync(string url, object body, Dictionary
SetRetryDelay(headers);
}

// Check for secondary rate limits before handling primary rate limits
if (IsSecondaryRateLimit(response.StatusCode, content))
{
return await HandleSecondaryRateLimit(httpMethod, url, body, expectedStatus, customHeaders, headers, retryCount);
}

if (response.StatusCode == HttpStatusCode.Forbidden && _retryDelay > 0)
{
(content, headers) = await SendAsync(httpMethod, url, body, expectedStatus, customHeaders);
(content, headers) = await SendAsync(httpMethod, url, body, expectedStatus, customHeaders, retryCount);
}
else if (expectedStatus == HttpStatusCode.OK)
{
Expand Down Expand Up @@ -280,4 +289,76 @@ private void EnsureSuccessGraphQLResponse(JObject response)
throw new OctoshiftCliException($"{errorMessage ?? "UNKNOWN"}");
}
}

private bool IsSecondaryRateLimit(HttpStatusCode statusCode, string content)
{
// Secondary rate limits return 403 or 429
if (statusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.TooManyRequests)
{
return false;
}

// Check if this is a primary rate limit (which we handle separately)
if (content.ToUpper().Contains("API RATE LIMIT EXCEEDED"))
{
return false;
}

// Common secondary rate limit error patterns
var contentUpper = content.ToUpper();
return contentUpper.Contains("SECONDARY RATE LIMIT") ||
contentUpper.Contains("ABUSE DETECTION") ||
contentUpper.Contains("YOU HAVE TRIGGERED AN ABUSE DETECTION MECHANISM") ||
statusCode == HttpStatusCode.TooManyRequests;
}

private async Task<(string Content, KeyValuePair<string, IEnumerable<string>>[] ResponseHeaders)> HandleSecondaryRateLimit(
HttpMethod httpMethod,
string url,
object body,
HttpStatusCode expectedStatus,
Dictionary<string, string> customHeaders,
KeyValuePair<string, IEnumerable<string>>[] headers,
int retryCount = 0)
{
if (retryCount >= SECONDARY_RATE_LIMIT_MAX_RETRIES)
{
throw new OctoshiftCliException($"Secondary rate limit exceeded. Maximum retries ({SECONDARY_RATE_LIMIT_MAX_RETRIES}) reached. Please wait before retrying your request.");
}

var delaySeconds = GetSecondaryRateLimitDelay(headers, retryCount);

_log.LogWarning($"Secondary rate limit detected (attempt {retryCount + 1}/{SECONDARY_RATE_LIMIT_MAX_RETRIES}). Waiting {delaySeconds} seconds before retrying...");

await Task.Delay(delaySeconds * MILLISECONDS_PER_SECOND);

return await SendAsync(httpMethod, url, body, expectedStatus, customHeaders, retryCount + 1);
}

private int GetSecondaryRateLimitDelay(KeyValuePair<string, IEnumerable<string>>[] headers, int retryCount)
{
// First check for retry-after header
var retryAfterHeader = ExtractHeaderValue("Retry-After", headers);
if (!string.IsNullOrEmpty(retryAfterHeader) && int.TryParse(retryAfterHeader, out var retryAfterSeconds))
{
return retryAfterSeconds;
}

// Then check if x-ratelimit-remaining is 0 and use x-ratelimit-reset
var rateLimitRemaining = GetRateLimitRemaining(headers);
if (rateLimitRemaining <= 0)
{
var resetUnixSeconds = GetRateLimitReset(headers);
var currentUnixSeconds = _dateTimeProvider.CurrentUnixTimeSeconds();
var delayFromReset = (int)(resetUnixSeconds - currentUnixSeconds);

if (delayFromReset > 0)
{
return delayFromReset;
}
}

// Otherwise use exponential backoff: 1m → 2m → 4m
return SECONDARY_RATE_LIMIT_DEFAULT_DELAY * (int)Math.Pow(2, retryCount);
}
}
2 changes: 1 addition & 1 deletion src/OctoshiftCLI.IntegrationTests/BbsToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public BbsToGithub(ITestOutputHelper output)

[Theory]
[InlineData("http://e2e-bbs-8-5-0-linux-2204.westus2.cloudapp.azure.com:7990", true, ArchiveUploadOption.AzureStorage)]
[InlineData("http://e2e-bbs-7-21-9-win-2019.westus2.cloudapp.azure.com:7990", false, ArchiveUploadOption.AzureStorage)]
// [InlineData("http://e2e-bbs-7-21-9-win-2019.westus2.cloudapp.azure.com:7990", false, ArchiveUploadOption.AzureStorage)]
[InlineData("http://e2e-bbs-8-5-0-linux-2204.westus2.cloudapp.azure.com:7990", true, ArchiveUploadOption.AwsS3)]
[InlineData("http://e2e-bbs-8-5-0-linux-2204.westus2.cloudapp.azure.com:7990", true, ArchiveUploadOption.GithubStorage)]
public async Task Basic(string bbsServer, bool useSshForArchiveDownload, ArchiveUploadOption uploadOption)
Expand Down
118 changes: 118 additions & 0 deletions src/OctoshiftCLI.Tests/Octoshift/Services/GithubClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2084,6 +2084,124 @@ public void It_Only_Sets_The_Product_Name_In_User_Agent_Header_When_Version_Prov
httpClient.DefaultRequestHeaders.UserAgent.ToString().Should().Be("OctoshiftCLI");
}

[Fact]
public async Task PostAsync_Handles_Secondary_Rate_Limit_With_429_Status()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.Protected()
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Post),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(CreateHttpResponseFactory(
statusCode: HttpStatusCode.TooManyRequests,
content: "Too many requests",
headers: new[] { ("Retry-After", "1") })())
.ReturnsAsync(CreateHttpResponseFactory(content: "SUCCESS_RESPONSE")());

using var httpClient = new HttpClient(handlerMock.Object);
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);

// Act
var result = await githubClient.PostAsync("http://example.com", _rawRequestBody);

// Assert
result.Should().Be("SUCCESS_RESPONSE");
_mockOctoLogger.Verify(m => m.LogWarning(It.Is<string>(s => s.Contains("Secondary rate limit detected"))), Times.Once);
}

[Fact]
public async Task GetAsync_Handles_Secondary_Rate_Limit_With_Forbidden_Status()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.Protected()
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Get),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(CreateHttpResponseFactory(
statusCode: HttpStatusCode.Forbidden,
content: "You have triggered an abuse detection mechanism",
headers: new[] { ("Retry-After", "2") })())
.ReturnsAsync(CreateHttpResponseFactory(content: "SUCCESS_RESPONSE")());

using var httpClient = new HttpClient(handlerMock.Object);
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);

// Act
var result = await githubClient.GetAsync("http://example.com");

// Assert
result.Should().Be("SUCCESS_RESPONSE");
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 2 seconds before retrying..."), Times.Once);
}

[Fact]
public async Task SendAsync_Uses_Exponential_Backoff_When_No_Retry_Headers()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.Protected()
.SetupSequence<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Patch),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(CreateHttpResponseFactory(
statusCode: HttpStatusCode.Forbidden,
content: "abuse detection mechanism")())
.ReturnsAsync(CreateHttpResponseFactory(
statusCode: HttpStatusCode.Forbidden,
content: "abuse detection mechanism")())
.ReturnsAsync(CreateHttpResponseFactory(content: "SUCCESS_RESPONSE")());

using var httpClient = new HttpClient(handlerMock.Object);
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);

// Act
var result = await githubClient.PatchAsync("http://example.com", _rawRequestBody);

// Assert
result.Should().Be("SUCCESS_RESPONSE");
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying..."), Times.Once);
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 120 seconds before retrying..."), Times.Once);
}

[Fact]
public async Task SendAsync_Throws_Exception_After_Max_Secondary_Rate_Limit_Retries()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.Method == HttpMethod.Delete),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(CreateHttpResponseFactory(
statusCode: HttpStatusCode.TooManyRequests,
content: "Too many requests")());

using var httpClient = new HttpClient(handlerMock.Object);
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);

// Act & Assert
await FluentActions
.Invoking(async () => await githubClient.DeleteAsync("http://example.com"))
.Should()
.ThrowExactlyAsync<OctoshiftCliException>()
.WithMessage("Secondary rate limit exceeded. Maximum retries (3) reached. Please wait before retrying your request.");

// Verify all retry attempts were logged
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying..."), Times.Once);
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 120 seconds before retrying..."), Times.Once);
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 3/3). Waiting 240 seconds before retrying..."), Times.Once);
}

private object CreateRepositoryMigration(string migrationId = null, string state = RepositoryMigrationStatus.Succeeded) => new
{
id = migrationId ?? Guid.NewGuid().ToString(),
Expand Down
Loading