Skip to content

Commit 27cf3c3

Browse files
authored
Merge pull request #1531 from github/handle-rate-limiting-in-graphql-queries
Catch and retry rate limited responses in read-only GraphQL queries
2 parents 4cf863e + a7cecf5 commit 27cf3c3

4 files changed

Lines changed: 83 additions & 19 deletions

File tree

src/Octoshift/Services/GithubApi.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -561,14 +561,14 @@ ... on Migration {
561561
{
562562
return await _retryPolicy.Retry(async () =>
563563
{
564-
var data = await _client.PostGraphQLAsync(url, payload);
564+
var data = await _client.PostGraphQLWithRetryAsync(url, payload);
565565

566566
return (
567-
State: (string)data["data"]["node"]["state"],
568-
RepositoryName: (string)data["data"]["node"]["repositoryName"],
569-
WarningsCount: (int)data["data"]["node"]["warningsCount"],
570-
FailureReason: (string)data["data"]["node"]["failureReason"],
571-
MigrationLogUrl: (string)data["data"]["node"]["migrationLogUrl"]);
567+
State: (string)data["data"]["node"]["state"],
568+
RepositoryName: (string)data["data"]["node"]["repositoryName"],
569+
WarningsCount: (int)data["data"]["node"]["warningsCount"],
570+
FailureReason: (string)data["data"]["node"]["failureReason"],
571+
MigrationLogUrl: (string)data["data"]["node"]["migrationLogUrl"]);
572572
});
573573
}
574574
catch (Exception ex)

src/Octoshift/Services/GithubClient.cs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public class GithubClient
2525
private const int SECONDARY_RATE_LIMIT_MAX_RETRIES = 3;
2626
private const int SECONDARY_RATE_LIMIT_DEFAULT_DELAY = 60; // 60 seconds default delay
2727

28+
internal int _secondaryRateLimitMaxRetries = SECONDARY_RATE_LIMIT_MAX_RETRIES; // Exposed for testing purposes
29+
internal int _secondaryRateLimitDefaultDelay = SECONDARY_RATE_LIMIT_DEFAULT_DELAY; // Exposed for testing purposes
30+
2831
public GithubClient(OctoLogger log, HttpClient httpClient, IVersionProvider versionProvider, RetryPolicy retryPolicy, DateTimeProvider dateTimeProvider, string personalAccessToken)
2932
{
3033
_log = log;
@@ -93,6 +96,36 @@ public virtual async Task<JToken> PostGraphQLAsync(
9396
return data;
9497
}
9598

99+
public virtual async Task<JToken> PostGraphQLWithRetryAsync(
100+
string url,
101+
object body,
102+
Dictionary<string, string> customHeaders = null,
103+
int retryCount = 0)
104+
{
105+
var currentRetryCount = retryCount;
106+
107+
while (true)
108+
{
109+
var (response, headers) = await PostWithRetry(url, body, customHeaders);
110+
111+
if (IsGraphQLSecondaryRateLimit(response))
112+
{
113+
(response, _) = await HandleSecondaryRateLimit(HttpMethod.Post, url, body, HttpStatusCode.OK, customHeaders, headers, currentRetryCount);
114+
115+
if (IsGraphQLSecondaryRateLimit(response))
116+
{
117+
currentRetryCount++;
118+
continue;
119+
}
120+
}
121+
122+
var data = JObject.Parse(response);
123+
EnsureSuccessGraphQLResponse(data);
124+
125+
return data;
126+
}
127+
}
128+
96129
public virtual async IAsyncEnumerable<JToken> PostGraphQLWithPaginationAsync(
97130
string url,
98131
object body,
@@ -156,6 +189,13 @@ public virtual async Task<string> PatchAsync(string url, object body, Dictionary
156189
HttpStatusCode expectedStatus = HttpStatusCode.OK) =>
157190
await _retryPolicy.Retry(async () => await SendAsync(HttpMethod.Get, url, customHeaders: customHeaders, expectedStatus: expectedStatus));
158191

192+
private async Task<(string Content, KeyValuePair<string, IEnumerable<string>>[] ResponseHeaders)> PostWithRetry(
193+
string url,
194+
object body,
195+
Dictionary<string, string> customHeaders = null,
196+
HttpStatusCode expectedStatus = HttpStatusCode.OK) =>
197+
await _retryPolicy.Retry(async () => await SendAsync(HttpMethod.Post, url, body, customHeaders: customHeaders, expectedStatus: expectedStatus));
198+
159199
private async Task<(string Content, KeyValuePair<string, IEnumerable<string>>[] ResponseHeaders)> SendAsync(
160200
HttpMethod httpMethod,
161201
string url,
@@ -312,6 +352,22 @@ private bool IsSecondaryRateLimit(HttpStatusCode statusCode, string content)
312352
statusCode == HttpStatusCode.TooManyRequests;
313353
}
314354

355+
private bool IsGraphQLSecondaryRateLimit(string content)
356+
{
357+
var response = JObject.Parse(content);
358+
359+
if (response.TryGetValue("errors", out var jErrors) && jErrors is JArray { Count: > 0 } errors)
360+
{
361+
var error = (JObject)errors[0];
362+
if (error.TryGetValue("type", out var jType) && jType.Type == JTokenType.String)
363+
{
364+
return ((string)jType) == "RATE_LIMIT";
365+
}
366+
}
367+
368+
return false;
369+
}
370+
315371
private async Task<(string Content, KeyValuePair<string, IEnumerable<string>>[] ResponseHeaders)> HandleSecondaryRateLimit(
316372
HttpMethod httpMethod,
317373
string url,
@@ -321,7 +377,7 @@ private bool IsSecondaryRateLimit(HttpStatusCode statusCode, string content)
321377
KeyValuePair<string, IEnumerable<string>>[] headers,
322378
int retryCount = 0)
323379
{
324-
if (retryCount >= SECONDARY_RATE_LIMIT_MAX_RETRIES)
380+
if (retryCount >= _secondaryRateLimitMaxRetries)
325381
{
326382
throw new OctoshiftCliException($"Secondary rate limit exceeded. Maximum retries ({SECONDARY_RATE_LIMIT_MAX_RETRIES}) reached. Please wait before retrying your request.");
327383
}
@@ -359,6 +415,6 @@ private int GetSecondaryRateLimitDelay(KeyValuePair<string, IEnumerable<string>>
359415
}
360416

361417
// Otherwise use exponential backoff: 1m → 2m → 4m
362-
return SECONDARY_RATE_LIMIT_DEFAULT_DELAY * (int)Math.Pow(2, retryCount);
418+
return _secondaryRateLimitDefaultDelay * (int)Math.Pow(2, retryCount);
363419
}
364420
}

src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,7 +1282,7 @@ ... on Migration {
12821282
}}");
12831283

12841284
_githubClientMock
1285-
.Setup(m => m.PostGraphQLAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null))
1285+
.Setup(m => m.PostGraphQLWithRetryAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null, 0))
12861286
.ReturnsAsync(response);
12871287

12881288
// Act
@@ -1341,7 +1341,7 @@ ... on Migration {
13411341
}}");
13421342

13431343
_githubClientMock
1344-
.SetupSequence(m => m.PostGraphQLAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null))
1344+
.SetupSequence(m => m.PostGraphQLWithRetryAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null, 0))
13451345
.Throws(new HttpRequestException(null, null, statusCode: HttpStatusCode.BadGateway))
13461346
.Throws(new HttpRequestException(null, null, statusCode: HttpStatusCode.BadGateway))
13471347
.ReturnsAsync(response);
@@ -1399,7 +1399,7 @@ ... on Migration {
13991399
}}");
14001400

14011401
_githubClientMock
1402-
.SetupSequence(m => m.PostGraphQLAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null))
1402+
.SetupSequence(m => m.PostGraphQLWithRetryAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null, 0))
14031403
.ReturnsAsync(GQL_ERROR_RESPONSE)
14041404
.ReturnsAsync(GQL_ERROR_RESPONSE)
14051405
.ReturnsAsync(response);
@@ -1457,7 +1457,7 @@ ... on Migration {
14571457
}}");
14581458

14591459
_githubClientMock
1460-
.Setup(m => m.PostGraphQLAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null))
1460+
.Setup(m => m.PostGraphQLWithRetryAsync(url, It.Is<object>(x => x.ToJson() == payload.ToJson()), null, 0))
14611461
.ReturnsAsync(response);
14621462

14631463
// Act

src/OctoshiftCLI.Tests/Octoshift/Services/GithubClientTests.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,15 +2160,19 @@ public async Task SendAsync_Uses_Exponential_Backoff_When_No_Retry_Headers()
21602160
.ReturnsAsync(CreateHttpResponseFactory(content: "SUCCESS_RESPONSE")());
21612161

21622162
using var httpClient = new HttpClient(handlerMock.Object);
2163-
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);
2163+
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN)
2164+
{
2165+
// Set short default delay to speed up the test. This is reflected in the logs below.
2166+
_secondaryRateLimitDefaultDelay = 0
2167+
};
21642168

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

21682172
// Assert
21692173
result.Should().Be("SUCCESS_RESPONSE");
2170-
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying..."), Times.Once);
2171-
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 120 seconds before retrying..."), Times.Once);
2174+
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 0 seconds before retrying..."), Times.Once);
2175+
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 0 seconds before retrying..."), Times.Once);
21722176
}
21732177

21742178
[Fact]
@@ -2187,7 +2191,11 @@ public async Task SendAsync_Throws_Exception_After_Max_Secondary_Rate_Limit_Retr
21872191
content: "Too many requests")());
21882192

21892193
using var httpClient = new HttpClient(handlerMock.Object);
2190-
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN);
2194+
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN)
2195+
{
2196+
// Set short default delay to speed up the test. This is reflected in the logs below.
2197+
_secondaryRateLimitDefaultDelay = 0
2198+
};
21912199

21922200
// Act & Assert
21932201
await FluentActions
@@ -2197,9 +2205,9 @@ await FluentActions
21972205
.WithMessage("Secondary rate limit exceeded. Maximum retries (3) reached. Please wait before retrying your request.");
21982206

21992207
// Verify all retry attempts were logged
2200-
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying..."), Times.Once);
2201-
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 120 seconds before retrying..."), Times.Once);
2202-
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 3/3). Waiting 240 seconds before retrying..."), Times.Once);
2208+
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 0 seconds before retrying..."), Times.Once);
2209+
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 0 seconds before retrying..."), Times.Once);
2210+
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 3/3). Waiting 0 seconds before retrying..."), Times.Once);
22032211
}
22042212

22052213
private object CreateRepositoryMigration(string migrationId = null, string state = RepositoryMigrationStatus.Succeeded) => new

0 commit comments

Comments
 (0)