Skip to content

Catch and retry rate limited responses in read-only GraphQL queries#1531

Open
synthead wants to merge 4 commits intomainfrom
handle-rate-limiting-in-graphql-queries
Open

Catch and retry rate limited responses in read-only GraphQL queries#1531
synthead wants to merge 4 commits intomainfrom
handle-rate-limiting-in-graphql-queries

Conversation

@synthead
Copy link
Collaborator

@synthead synthead commented Mar 21, 2026

Closes https://github.ghe.com/github/octoshift/issues/11503!

This PR makes GraphQL queries include both general retries and rate limiting! This makes read-only GraphQL queries have the same robustness as other GET calls in the GEI CLI.

Demo

Here is a migration in progress, handling rate limiting:

[2026-03-20 17:09:48] [INFO] Creating Migration Source...
[2026-03-20 17:09:49] [INFO] Importing Archive...
[2026-03-20 17:09:49] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:10:50] [WARNING] Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying...
[2026-03-20 17:11:50] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:12:50] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:13:50] [WARNING] Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying...
[2026-03-20 17:14:51] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:15:51] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:16:51] [WARNING] Secondary rate limit detected (attempt 1/3). Waiting 60 seconds before retrying...
[2026-03-20 17:17:51] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
[2026-03-20 17:18:51] [INFO] Migration in progress (ID: RM_kgDaACQ0M2E5ZGQzZS1kYTMwLTQ3MWItODU3Yi1mNTg5NmMyZmUyOGU). State: PENDING_VALIDATION. Waiting 60 seconds...
  • Did you write/update appropriate tests
  • Release notes updated (if appropriate)
  • Appropriate logging output
  • Issue linked
  • Docs updated (or issue created)
  • New package licenses are added to ThirdPartyNotices.txt (if applicable)

@synthead synthead self-assigned this Mar 21, 2026
@synthead synthead changed the title Handle rate limiting in graphql queries Catch and retry rate limited responses in read-only GraphQL queries Mar 21, 2026
@github-actions
Copy link

github-actions bot commented Mar 21, 2026

Unit Test Results

    1 files      1 suites   25s ⏱️
1 030 tests 1 030 ✅ 0 💤 0 ❌
1 031 runs  1 031 ✅ 0 💤 0 ❌

Results for commit e56e2ec.

♻️ This comment has been updated with latest results.

@gohana gohana assigned gohana and offbyone and unassigned gohana Mar 23, 2026
@offbyone offbyone force-pushed the handle-rate-limiting-in-graphql-queries branch from b2ffc2c to e56e2ec Compare March 26, 2026 21:07
@github-actions
Copy link

Code Coverage

Package Line Rate Branch Rate Complexity Health
ado2gh 71% 70% 737
bbs2gh 83% 78% 663
gei 81% 73% 608
Octoshift 83% 73% 1823
Summary 81% (7940 / 9847) 73% (1947 / 2661) 3831

@offbyone offbyone marked this pull request as ready for review March 26, 2026 21:19
Copilot AI review requested due to automatic review settings March 26, 2026 21:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves robustness of GEI’s read-only GraphQL polling by adding secondary rate-limit detection/retry handling to GraphQL POST calls, aligning it more closely with existing REST retry behavior.

Changes:

  • Added PostGraphQLWithRetryAsync to GithubClient to detect GraphQL secondary rate-limit responses and trigger the existing secondary-rate-limit retry path.
  • Updated GithubApi.GetMigration to use the new GraphQL-with-retry client method.
  • Updated unit tests/mocks to reflect the new client method and to reduce test runtime.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/Octoshift/Services/GithubClient.cs Adds GraphQL secondary rate-limit detection and a new GraphQL POST helper that integrates with retry logic.
src/Octoshift/Services/GithubApi.cs Switches migration state polling to use the new GraphQL-with-retry path.
src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs Updates mocks to expect PostGraphQLWithRetryAsync instead of PostGraphQLAsync.
src/OctoshiftCLI.Tests/Octoshift/Services/GithubClientTests.cs Adjusts tests to shorten delays by overriding the default backoff delay.
Comments suppressed due to low confidence (2)

src/Octoshift/Services/GithubClient.cs:377

  • HandleSecondaryRateLimit now uses _secondaryRateLimitMaxRetries for the stop condition, but the exception message and log message still print SECONDARY_RATE_LIMIT_MAX_RETRIES. If the max is overridden (tests or future config), the thrown/logged “(attempt x/3)” and “Maximum retries (3)” will be incorrect. Use the same variable for the messages as the check uses.
        if (retryCount >= _secondaryRateLimitMaxRetries)
        {
            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...");

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

  • Same issue here: setting _secondaryRateLimitDefaultDelay to 0 makes the test assertions stop exercising the exponential backoff calculation across retries. A no-op delay hook (or scaled-down delays) would allow validating the backoff values without slowing the test suite.
        var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN)
        {
            // Set short default delay to speed up the test. This is reflected in the logs below.
            _secondaryRateLimitDefaultDelay = 0
        };

        // 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 0 seconds before retrying..."), Times.Once);
        _mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 0 seconds before retrying..."), Times.Once);
        _mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 3/3). Waiting 0 seconds before retrying..."), Times.Once);

Comment on lines +105 to +115
var (response, headers) = await PostWithRetry(url, body, customHeaders);

if (IsGraphQLSecondaryRateLimit(response))
{
(response, _) = await HandleSecondaryRateLimit(HttpMethod.Post, url, body, HttpStatusCode.OK, customHeaders, headers, retryCount);
}

var data = JObject.Parse(response);
EnsureSuccessGraphQLResponse(data);

return data;
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostGraphQLWithRetryAsync only checks for GraphQL secondary rate limiting once. HandleSecondaryRateLimit retries via SendAsync, but SendAsync won’t detect GraphQL rate-limit responses (HTTP 200 with errors[].type == RATE_LIMIT), so a second rate-limited response will fall through to EnsureSuccessGraphQLResponse and throw instead of continuing to retry up to the max. Consider looping/re-invoking the GraphQL rate-limit check on every response (and using PostWithRetry for the subsequent attempts so transient exceptions still get retried).

Suggested change
var (response, headers) = await PostWithRetry(url, body, customHeaders);
if (IsGraphQLSecondaryRateLimit(response))
{
(response, _) = await HandleSecondaryRateLimit(HttpMethod.Post, url, body, HttpStatusCode.OK, customHeaders, headers, retryCount);
}
var data = JObject.Parse(response);
EnsureSuccessGraphQLResponse(data);
return data;
var currentRetryCount = retryCount;
while (true)
{
var (response, headers) = await PostWithRetry(url, body, customHeaders);
if (IsGraphQLSecondaryRateLimit(response))
{
(response, headers) = await HandleSecondaryRateLimit(HttpMethod.Post, url, body, HttpStatusCode.OK, customHeaders, headers, currentRetryCount);
if (IsGraphQLSecondaryRateLimit(response))
{
currentRetryCount++;
continue;
}
}
var data = JObject.Parse(response);
EnsureSuccessGraphQLResponse(data);
return data;
}

Copilot uses AI. Check for mistakes.
Comment on lines 562 to 565
return await _retryPolicy.Retry(async () =>
{
var data = await _client.PostGraphQLAsync(url, payload);
var data = await _client.PostGraphQLWithRetryAsync(url, payload);

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetMigration now wraps _retryPolicy.Retry around PostGraphQLWithRetryAsync, but PostGraphQLWithRetryAsync already retries via PostWithRetry/_retryPolicy. This can multiply retries and backoff delays (and increase load) in production. Consider removing the outer Retry here (or switching to PostGraphQLAsync if you want the retry to live only at the GithubApi layer).

See below for a potential fix:

            var data = await _client.PostGraphQLWithRetryAsync(url, payload);

            return (
                State: (string)data["data"]["node"]["state"],
                RepositoryName: (string)data["data"]["node"]["repositoryName"],
                WarningsCount: (int)data["data"]["node"]["warningsCount"],
                FailureReason: (string)data["data"]["node"]["failureReason"],
                MigrationLogUrl: (string)data["data"]["node"]["migrationLogUrl"]);

Copilot uses AI. Check for mistakes.
Comment on lines +2163 to +2175
var githubClient = new GithubClient(_mockOctoLogger.Object, httpClient, null, _retryPolicy, _dateTimeProvider.Object, PERSONAL_ACCESS_TOKEN)
{
// Set short default delay to speed up the test. This is reflected in the logs below.
_secondaryRateLimitDefaultDelay = 0
};

// 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);
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 1/3). Waiting 0 seconds before retrying..."), Times.Once);
_mockOctoLogger.Verify(m => m.LogWarning("Secondary rate limit detected (attempt 2/3). Waiting 0 seconds before retrying..."), Times.Once);
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests set _secondaryRateLimitDefaultDelay to 0 to avoid waiting, but that also stops validating the exponential backoff behavior (the assertions now expect 0 seconds for every attempt). Consider keeping the test fast while still asserting the computed backoff (e.g., inject/override the delay mechanism so the code doesn’t actually sleep, while logging/asserting 60/120/etc or scaled-down values like 1/2/4).

This issue also appears on line 2194 of the same file.

Copilot generated this review using guidance from repository custom instructions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants