Skip to content
Closed
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
156 changes: 156 additions & 0 deletions src/Octoshift/Services/SecondaryRateLimitHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// src/Octoshift/Services/SecondaryRateLimitHandler.cs
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;

namespace OctoshiftCLI.Services
{
/// <summary>
/// Handles GitHub secondary rate-limit / abuse-detection 403s by
/// respecting Retry-After (when present) and otherwise using
/// exponential backoff with jitter. Clones the request to safely
/// resend POSTs with content.
/// </summary>
public sealed class SecondaryRateLimitHandler : DelegatingHandler
{
private readonly int _maxAttempts;
private readonly TimeSpan _initialBackoff;
private readonly TimeSpan _maxBackoff;

public SecondaryRateLimitHandler(
HttpMessageHandler innerHandler,
int maxAttempts = 6,
int initialBackoffSeconds = 15,
int maxBackoffSeconds = 900)
: base(innerHandler)
{
_maxAttempts = maxAttempts;
_initialBackoff = TimeSpan.FromSeconds(initialBackoffSeconds);
_maxBackoff = TimeSpan.FromSeconds(maxBackoffSeconds);
}

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}

var attempt = 0;
var delay = _initialBackoff;

// Buffer original content (if any) so we can safely clone the request for retries.
byte[] bufferedContent = null;
string contentType = null;

if (request.Content != null)
{
bufferedContent = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
contentType = request.Content.Headers.ContentType?.ToString();
}

while (true)
{
cancellationToken.ThrowIfCancellationRequested();

using var cloned = CloneRequest(request, bufferedContent, contentType);
var response = await base.SendAsync(cloned, cancellationToken).ConfigureAwait(false);

if (response.StatusCode != HttpStatusCode.Forbidden)
{
return response;
}

// Look for known secondary rate limit / abuse messages in body.
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var looksLikeSecondary =
body.Contains("secondary rate limit", StringComparison.OrdinalIgnoreCase) ||
body.Contains("abuse detection", StringComparison.OrdinalIgnoreCase) ||
body.Contains("rate limit", StringComparison.OrdinalIgnoreCase);

if (!looksLikeSecondary)
{
return response; // Some other 403 — don't loop.
}

attempt++;
if (attempt >= _maxAttempts)
{
return response; // Give up; caller/policy will surface it.
}

// Prefer server-provided delay if present.
if (response.Headers.TryGetValues("Retry-After", out var values) &&
int.TryParse(values.FirstOrDefault(), out var secs) &&
secs > 0)
{
delay = TimeSpan.FromSeconds(secs);
}

// Add secure jitter (0–1000ms) to spread retries.
var jitterMs = RandomNumberGenerator.GetInt32(0, 1000);
var totalDelay = delay + TimeSpan.FromMilliseconds(jitterMs);

await Task.Delay(totalDelay, cancellationToken).ConfigureAwait(false);

// Exponential backoff, capped.
var nextSeconds = Math.Min(delay.TotalSeconds * 2, _maxBackoff.TotalSeconds);
delay = TimeSpan.FromSeconds(nextSeconds);
// Loop and retry with a freshly cloned request.
}
}

private static HttpRequestMessage CloneRequest(HttpRequestMessage original, byte[] bufferedContent, string contentType)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri)
{
Version = original.Version,
VersionPolicy = original.VersionPolicy
};

// Copy headers
foreach (var header in original.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}

// Copy content
if (bufferedContent != null)
{
var content = new ByteArrayContent(bufferedContent);
if (!string.IsNullOrEmpty(contentType))
{
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
}

if (original.Content != null)
{
foreach (var h in original.Content.Headers)
{
if (!string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
{
content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
}
Comment on lines +134 to +140

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

Copilot Autofix

AI 4 months ago

The best way to fix the problem is to use the .Where(...) method from LINQ to filter out any headers with key "Content-Type" before entering the loop. This means replacing the block:

foreach (var h in original.Content.Headers)
{
    if (!string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
    {
        content.Headers.TryAddWithoutValidation(h.Key, h.Value);
    }
}

with:

foreach (var h in original.Content.Headers.Where(h => !string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)))
{
    content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}

No new methods, definitions, or imports are needed, as System.Linq is already imported and the method is used in a local scope.

Edit only the lines covering the above code block in src/Octoshift/Services/SecondaryRateLimitHandler.cs, replacing the loop as described.


Suggested changeset 1
src/Octoshift/Services/SecondaryRateLimitHandler.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Octoshift/Services/SecondaryRateLimitHandler.cs b/src/Octoshift/Services/SecondaryRateLimitHandler.cs
--- a/src/Octoshift/Services/SecondaryRateLimitHandler.cs
+++ b/src/Octoshift/Services/SecondaryRateLimitHandler.cs
@@ -131,12 +131,9 @@
 
                 if (original.Content != null)
                 {
-                    foreach (var h in original.Content.Headers)
+                    foreach (var h in original.Content.Headers.Where(h => !string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)))
                     {
-                        if (!string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
-                        {
-                            content.Headers.TryAddWithoutValidation(h.Key, h.Value);
-                        }
+                        content.Headers.TryAddWithoutValidation(h.Key, h.Value);
                     }
                 }
 
EOF
@@ -131,12 +131,9 @@

if (original.Content != null)
{
foreach (var h in original.Content.Headers)
foreach (var h in original.Content.Headers.Where(h => !string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)))
{
if (!string.Equals(h.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
{
content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
content.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
}

Copilot is powered by AI and may make mistakes. Always verify output.
}

clone.Content = content;
}

#if NET6_0_OR_GREATER
foreach (var opt in original.Options)
{
clone.Options.Set(new(opt.Key), opt.Value);
}
#endif

return clone;
}
}
}
3 changes: 3 additions & 0 deletions src/OctoshiftCLI.IntegrationTests/AdoBasicToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ await retryPolicy.Retry(async () =>
var commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject1, adoRepo1);
await Helper.CreatePipeline(adoOrg, teamProject1, adoRepo1, pipeline1, commitId);

// tiny pause to smooth bursts
await Task.Delay(500);

await Helper.CreateTeamProject(adoOrg, teamProject2);
commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject2, adoRepo2);
await Helper.CreatePipeline(adoOrg, teamProject2, adoRepo2, pipeline2, commitId);
Expand Down
3 changes: 3 additions & 0 deletions src/OctoshiftCLI.IntegrationTests/AdoCsvToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ await retryPolicy.Retry(async () =>
var commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject1, adoRepo1);
await Helper.CreatePipeline(adoOrg, teamProject1, adoRepo1, pipeline1, commitId);

// tiny pause to smooth bursts
await Task.Delay(500);

await Helper.CreateTeamProject(adoOrg, teamProject2);
commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject2, adoRepo2);
await Helper.CreatePipeline(adoOrg, teamProject2, adoRepo2, pipeline2, commitId);
Expand Down
6 changes: 5 additions & 1 deletion src/OctoshiftCLI.IntegrationTests/AdoServerToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public class AdoServerToGithub : AdoToGithub
{
private const string ADO_SERVER_URL = "http://octoshift-ado-server-2022.eastus.cloudapp.azure.com/";

public AdoServerToGithub(ITestOutputHelper output) : base(output, ADO_SERVER_URL, "ADO_SERVER_PAT")
public AdoServerToGithub(ITestOutputHelper output)
: base(output, adoServerUrl: ADO_SERVER_URL, adoPatEnvVar: "ADO_SERVER_PAT")
{
}

Expand All @@ -36,6 +37,9 @@ await retryPolicy.Retry(async () =>
var commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject1, adoRepo1, ADO_SERVER_URL);
await Helper.CreatePipeline(adoOrg, teamProject1, adoRepo1, pipeline1, commitId, ADO_SERVER_URL);

// tiny pause to smooth bursts
await Task.Delay(500);

await Helper.CreateTeamProject(adoOrg, teamProject2, ADO_SERVER_URL);
commitId = await Helper.InitializeAdoRepo(adoOrg, teamProject2, adoRepo2, ADO_SERVER_URL);
await Helper.CreatePipeline(adoOrg, teamProject2, adoRepo2, pipeline2, commitId, ADO_SERVER_URL);
Expand Down
14 changes: 10 additions & 4 deletions src/OctoshiftCLI.IntegrationTests/AdoToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,24 @@ public abstract class AdoToGithub : IDisposable
protected Dictionary<string, string> Tokens { get; }
protected DateTime StartTime { get; }

protected AdoToGithub(ITestOutputHelper output, string adoServerUrl = "https://dev.azure.com", string adoPatEnvVar = "ADO_PAT")

protected AdoToGithub(ITestOutputHelper output, HttpClient githubHttpClient, string adoServerUrl = "https://dev.azure.com", string adoPatEnvVar = "ADO_PAT")
{
StartTime = DateTime.Now;
_output = output;

var logger = new OctoLogger(x => { }, x => _output.WriteLine(x), x => { }, x => { });

_versionClient = new HttpClient();
_versionClient = HttpClientFactory.CreateSrlClient();
var adoToken = Environment.GetEnvironmentVariable(adoPatEnvVar);
_adoHttpClient = new HttpClient();
var retryPolicy = new RetryPolicy(logger);
var adoClient = new AdoClient(logger, _adoHttpClient, new VersionChecker(_versionClient, logger), retryPolicy, adoToken);
var adoApi = new AdoApi(adoClient, adoServerUrl, logger);

var githubToken = Environment.GetEnvironmentVariable("GHEC_PAT");
_githubHttpClient = new HttpClient();
_githubHttpClient = githubHttpClient ?? HttpClientFactory.CreateSrlClient();

var githubClient = new GithubClient(logger, _githubHttpClient, new VersionChecker(_versionClient, logger), new RetryPolicy(logger), new DateTimeProvider(), githubToken);
var githubApi = new GithubApi(githubClient, "https://api.github.com", new RetryPolicy(logger), null);

Expand All @@ -46,6 +48,11 @@ protected AdoToGithub(ITestOutputHelper output, string adoServerUrl = "https://d
Helper = new TestHelper(_output, adoApi, githubApi, adoClient, githubClient);
}

protected AdoToGithub(ITestOutputHelper output, string adoServerUrl = "https://dev.azure.com", string adoPatEnvVar = "ADO_PAT")
: this(output, githubHttpClient: null, adoServerUrl: adoServerUrl, adoPatEnvVar: adoPatEnvVar)
{
}

protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
Expand All @@ -63,7 +70,6 @@ protected virtual void Dispose(bool disposing)

public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
Expand Down
9 changes: 6 additions & 3 deletions src/OctoshiftCLI.IntegrationTests/BbsToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ public BbsToGithub(ITestOutputHelper output)
["GH_PAT"] = targetGithubToken
};

_versionClient = new HttpClient();
_versionClient = HttpClientFactory.CreateSrlClient();

_sourceBbsHttpClient = new HttpClient();
_sourceBbsClient = new BbsClient(_logger, _sourceBbsHttpClient, new VersionChecker(_versionClient, _logger), new RetryPolicy(_logger), sourceBbsUsername, sourceBbsPassword);

_targetGithubHttpClient = new HttpClient();
_targetGithubHttpClient = HttpClientFactory.CreateSrlClient();
_targetGithubClient = new GithubClient(_logger, _targetGithubHttpClient, new VersionChecker(_versionClient, _logger), new RetryPolicy(_logger), new DateTimeProvider(), targetGithubToken);
var retryPolicy = new RetryPolicy(_logger);
_archiveUploader = new ArchiveUploader(_targetGithubClient, UPLOADS_URL, _logger, retryPolicy);
Expand Down Expand Up @@ -97,6 +97,10 @@ await retryPolicy.Retry(async () =>
await sourceHelper.CreateBbsProject(bbsProjectKey);
await sourceHelper.CreateBbsRepo(bbsProjectKey, repo1);
await sourceHelper.InitializeBbsRepo(bbsProjectKey, repo1);

// tiny pause to smooth bursts
await Task.Delay(500);

await sourceHelper.CreateBbsRepo(bbsProjectKey, repo2);
await sourceHelper.InitializeBbsRepo(bbsProjectKey, repo2);
});
Expand Down Expand Up @@ -154,7 +158,6 @@ public async Task MigrateRepo_MultipartUpload()
var sshKey = Environment.GetEnvironmentVariable(GetSshKeyName(bbsServer));
await File.WriteAllTextAsync(Path.Join(TestHelper.GetOsDistPath(), SSH_KEY_FILE), sshKey);


var retryPolicy = new RetryPolicy(null);
await retryPolicy.Retry(async () =>
{
Expand Down
14 changes: 10 additions & 4 deletions src/OctoshiftCLI.IntegrationTests/GhesToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,18 @@ public GhesToGithub(ITestOutputHelper output)
["GH_PAT"] = targetGithubToken,
};

_versionClient = new HttpClient();
_versionClient = HttpClientFactory.CreateSrlClient();
var retryPolicy = new RetryPolicy(logger);
_archiveUploader = new ArchiveUploader(_targetGithubClient, UPLOADS_URL, logger, retryPolicy);

_sourceGithubHttpClient = new HttpClient();
// Source GitHub client (GHES)
_sourceGithubHttpClient = HttpClientFactory.CreateSrlClient();
_sourceGithubClient = new GithubClient(logger, _sourceGithubHttpClient, new VersionChecker(_versionClient, logger), new RetryPolicy(logger), new DateTimeProvider(), sourceGithubToken);
_archiveUploader = new ArchiveUploader(_sourceGithubClient, UPLOADS_URL, logger, retryPolicy);
_sourceGithubApi = new GithubApi(_sourceGithubClient, GHES_API_URL, new RetryPolicy(logger), _archiveUploader);

_targetGithubHttpClient = new HttpClient();
// Target GitHub client (GHEC)
_targetGithubHttpClient = HttpClientFactory.CreateSrlClient();
_targetGithubClient = new GithubClient(logger, _targetGithubHttpClient, new VersionChecker(_versionClient, logger), new RetryPolicy(logger), new DateTimeProvider(), targetGithubToken);
_targetGithubApi = new GithubApi(_targetGithubClient, "https://api.github.com", new RetryPolicy(logger), _archiveUploader);

Expand Down Expand Up @@ -93,10 +96,13 @@ await retryPolicy.Retry(async () =>
await _targetHelper.ResetGithubTestEnvironment(githubTargetOrg);

await _sourceHelper.CreateGithubRepo(githubSourceOrg, repo1);

// tiny pause to smooth bursts
await Task.Delay(500);

await _sourceHelper.CreateGithubRepo(githubSourceOrg, repo2);
});

// Build the command with conditional option
var command = $"generate-script --github-source-org {githubSourceOrg} --github-target-org {githubTargetOrg} --ghes-api-url {GHES_API_URL} --download-migration-logs";
if (useGithubStorage)
{
Expand Down
22 changes: 17 additions & 5 deletions src/OctoshiftCLI.IntegrationTests/GithubToGithub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ public GithubToGithub(ITestOutputHelper output)
var githubToken = Environment.GetEnvironmentVariable("GHEC_PAT");
_tokens = new Dictionary<string, string> { ["GH_PAT"] = githubToken };

_githubHttpClient = new HttpClient();
_versionClient = new HttpClient();
_githubClient = new GithubClient(logger, _githubHttpClient, new VersionChecker(_versionClient, logger), new RetryPolicy(logger), new DateTimeProvider(), githubToken);
_githubHttpClient = HttpClientFactory.CreateSrlClient();
_versionClient = HttpClientFactory.CreateSrlClient();
_githubClient = new GithubClient(
logger,
_githubHttpClient,
new VersionChecker(_versionClient, logger),
new RetryPolicy(logger),
new DateTimeProvider(),
githubToken);

_githubApi = new GithubApi(_githubClient, "https://api.github.com", new RetryPolicy(logger), null);

_helper = new TestHelper(_output, _githubApi, _githubClient);
Expand All @@ -55,10 +62,16 @@ await retryPolicy.Retry(async () =>
await _helper.ResetGithubTestEnvironment(githubTargetOrg);

await _helper.CreateGithubRepo(githubSourceOrg, repo1);

// tiny pause to smooth bursts
await Task.Delay(500);

await _helper.CreateGithubRepo(githubSourceOrg, repo2);
});

await _helper.RunGeiCliMigration($"generate-script --github-source-org {githubSourceOrg} --github-target-org {githubTargetOrg} --download-migration-logs", _tokens);
await _helper.RunGeiCliMigration(
$"generate-script --github-source-org {githubSourceOrg} --github-target-org {githubTargetOrg} --download-migration-logs",
_tokens);

_helper.AssertNoErrorInLogs(_startTime);

Expand Down Expand Up @@ -87,7 +100,6 @@ protected virtual void Dispose(bool disposing)

public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
Expand Down
Loading
Loading