Skip to content

Commit 7f06294

Browse files
authored
Merge pull request #798 from DNNCommunity/copilot/improve-repostats-plugin-resilience
Improve RepoStats plugin resilience: graceful rate-limit handling in CI
2 parents 2f2ef08 + e485750 commit 7f06294

2 files changed

Lines changed: 120 additions & 35 deletions

File tree

plugins/DNNCommunity.DNNDocs.Plugins/Providers/GitHubApi.cs

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,76 +4,148 @@ namespace DNNCommunity.DNNDocs.Plugins.Providers
44
{
55
public class GitHubApi
66
{
7-
private readonly string token;
7+
private readonly string? token;
88
private readonly GitHubClient client;
99

10-
private const int RateLimitThreshold = 1000; // Starts throtling if less than x calls remaining for the next hour.
11-
private const int MaxDelayMs = 15000; // Maximum delay of 15 seconds.
10+
private const int RateLimitThreshold = 100; // Start warning if less than this many calls remain for the hour.
11+
private const int MaxDelayMs = 5000; // Maximum progressive-backoff delay of 5 seconds.
1212
private const double ExponentialBase = 1.5; // Exponential base for progressive backoff.
1313

1414
public GitHubApi()
1515
{
16-
DotNetEnv.Env.Load(); // Loads .env file if it exists, into environment varialbes.
16+
DotNetEnv.Env.Load(); // Loads .env file if it exists, into environment variables.
1717
client = new GitHubClient(new ProductHeaderValue("DNNDocsPlugin"));
18-
19-
// Optionally pull from env if not passed
18+
2019
var skip = Environment.GetEnvironmentVariable("SKIP_CONTRIBUTORS") == "true";
2120
if (!skip)
2221
{
23-
this.token = Environment.GetEnvironmentVariable("GithubToken") ?? string.Empty;
22+
// Prefer a PAT (ACCESS_TOKEN) for higher rate limits; fall back to GithubToken (GITHUB_TOKEN in CI).
23+
this.token = Environment.GetEnvironmentVariable("ACCESS_TOKEN");
24+
if (string.IsNullOrEmpty(this.token))
25+
{
26+
this.token = Environment.GetEnvironmentVariable("GithubToken");
27+
}
2428
}
2529

2630
if (!string.IsNullOrEmpty(this.token))
2731
{
2832
client.Credentials = new Credentials(this.token);
2933
}
3034

31-
var rateLimits = client.RateLimit.GetRateLimits().GetAwaiter().GetResult();
32-
Console.WriteLine($"Remaining: {rateLimits.Resources.Core.Remaining}, Resets at: {rateLimits.Resources.Core.Reset}");
35+
try
36+
{
37+
var rateLimits = client.RateLimit.GetRateLimits().GetAwaiter().GetResult();
38+
Console.WriteLine($"[RepoStats] GitHub API remaining: {rateLimits.Resources.Core.Remaining}/{rateLimits.Resources.Core.Limit}, resets at: {rateLimits.Resources.Core.Reset}");
39+
}
40+
catch (Exception ex)
41+
{
42+
Console.WriteLine($"[RepoStats] WARNING: Could not retrieve GitHub rate limit info: {ex.Message}");
43+
}
3344
}
3445

3546
public async Task<IReadOnlyList<Contributor>> GetContributorsAsync()
3647
{
3748
if (string.IsNullOrEmpty(this.token))
49+
{
50+
Console.WriteLine("[RepoStats] No GitHub token configured — skipping contributor stats.");
51+
return new List<Contributor>();
52+
}
53+
54+
if (!await this.ThrottleIfNeeded())
3855
{
3956
return new List<Contributor>();
4057
}
4158

42-
await this.ThrottleIfNeeded();
43-
return await client.Repository.Statistics.GetContributors("DNNCommunity", "DNNDocs");
59+
try
60+
{
61+
return await client.Repository.Statistics.GetContributors("DNNCommunity", "DNNDocs");
62+
}
63+
catch (RateLimitExceededException ex)
64+
{
65+
Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded while fetching contributors. Skipping stats. Reset at: {ex.Reset}");
66+
return new List<Contributor>();
67+
}
68+
catch (Exception ex)
69+
{
70+
Console.WriteLine($"[RepoStats] WARNING: Failed to fetch contributors: {ex.Message}. Skipping stats.");
71+
return new List<Contributor>();
72+
}
4473
}
4574

4675
public async Task<IReadOnlyList<GitHubCommit>> GetCommitsAsync(string path = "")
4776
{
4877
if (string.IsNullOrEmpty(this.token))
78+
{
79+
Console.WriteLine("[RepoStats] No GitHub token configured — skipping commit stats.");
80+
return new List<GitHubCommit>();
81+
}
82+
83+
if (!await this.ThrottleIfNeeded())
4984
{
5085
return new List<GitHubCommit>();
5186
}
5287

53-
var request = new CommitRequest();
54-
if (!string.IsNullOrEmpty(path))
55-
request.Path = path;
88+
try
89+
{
90+
var request = new CommitRequest();
91+
if (!string.IsNullOrEmpty(path))
92+
request.Path = path;
5693

57-
await this.ThrottleIfNeeded();
58-
return await client.Repository.Commit.GetAll("DNNCommunity", "DNNDocs", request);
94+
return await client.Repository.Commit.GetAll("DNNCommunity", "DNNDocs", request);
95+
}
96+
catch (RateLimitExceededException ex)
97+
{
98+
Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded while fetching commits. Skipping stats. Reset at: {ex.Reset}");
99+
return new List<GitHubCommit>();
100+
}
101+
catch (Exception ex)
102+
{
103+
Console.WriteLine($"[RepoStats] WARNING: Failed to fetch commits: {ex.Message}. Skipping stats.");
104+
return new List<GitHubCommit>();
105+
}
59106
}
60107

61-
private async Task ThrottleIfNeeded()
108+
/// <summary>
109+
/// Checks the current rate limit. Returns <c>true</c> if it is safe to proceed with an API call,
110+
/// or <c>false</c> if the rate limit is exhausted and callers should skip their request.
111+
/// When nearing (but not at) the limit, a short progressive backoff delay is applied.
112+
/// </summary>
113+
private async Task<bool> ThrottleIfNeeded()
62114
{
63-
var rateLimits = await client.RateLimit.GetRateLimits();
64-
var remaining = rateLimits.Resources.Core.Remaining;
65-
var limit = rateLimits.Resources.Core.Limit;
66-
var reset = rateLimits.Resources.Core.Reset;
115+
try
116+
{
117+
var rateLimits = await client.RateLimit.GetRateLimits();
118+
var remaining = rateLimits.Resources.Core.Remaining;
119+
var limit = rateLimits.Resources.Core.Limit;
120+
var reset = rateLimits.Resources.Core.Reset;
67121

122+
if (remaining == 0)
123+
{
124+
var waitSeconds = (int)(reset - DateTimeOffset.UtcNow).TotalSeconds + 5;
125+
Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit fully exhausted (0/{limit}). Resets at {reset} UTC (~{waitSeconds}s). Skipping stats to avoid blocking the build.");
126+
return false;
127+
}
68128

69-
if (remaining < RateLimitThreshold)
70-
{
71-
int delayFactor = RateLimitThreshold - remaining;
72-
int delayMs = (int)Math.Pow(ExponentialBase, delayFactor);
73-
delayMs = Math.Min(delayMs, MaxDelayMs);
129+
if (remaining < RateLimitThreshold)
130+
{
131+
int delayFactor = RateLimitThreshold - remaining;
132+
int delayMs = (int)Math.Min(Math.Pow(ExponentialBase, delayFactor), MaxDelayMs);
133+
134+
Console.WriteLine($"[RepoStats] Nearing GitHub API rate limit, applying backoff of {delayMs} ms. Remaining: {remaining}/{limit}, resets at {reset}.");
135+
await Task.Delay(delayMs);
136+
}
74137

75-
Console.WriteLine($"[Throttle] Nearing rate limit, delaying {delayMs} ms... GitHub API remaining: {remaining}/{limit}, resets at {reset}");
76-
await Task.Delay(delayMs);
138+
return true;
139+
}
140+
catch (RateLimitExceededException ex)
141+
{
142+
Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded during throttle check. Skipping stats. Reset at: {ex.Reset}");
143+
return false;
144+
}
145+
catch (Exception ex)
146+
{
147+
Console.WriteLine($"[RepoStats] WARNING: Could not check rate limit ({ex.Message}). Proceeding cautiously.");
148+
return true;
77149
}
78150
}
79151
}

plugins/DNNCommunity.DNNDocs.Plugins/RepoStatsBuildStep.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ public void Build(FileModel model, IHostService host)
2424
{
2525
}
2626

27+
// IDocumentBuildStep.Postbuild is a synchronous interface contract; bridge to the async implementation.
2728
public void Postbuild(ImmutableList<FileModel> models, IHostService host)
28-
{
29+
=> PostbuildAsync(models, host).GetAwaiter().GetResult();
2930

31+
private async Task PostbuildAsync(ImmutableList<FileModel> models, IHostService host)
32+
{
3033
if (models == null || models.Count == 0)
3134
{
3235
Console.WriteLine("[PLUGIN] No models to process.");
@@ -36,15 +39,25 @@ public void Postbuild(ImmutableList<FileModel> models, IHostService host)
3639
Console.WriteLine($"[PLUGIN] {nameof(RepoStatsBuildStep)} Processing Repo Stats for {models.Count()} models");
3740

3841
var gitHubApi = new GitHubApi();
39-
var contributors = gitHubApi.GetContributorsAsync().GetAwaiter().GetResult();
40-
Console.WriteLine($"Found {contributors.Count} contributors");
4142

42-
var commits = gitHubApi.GetCommitsAsync().GetAwaiter().GetResult();
43+
List<Octokit.Contributor> contributors;
44+
List<GitHubCommit> commits;
4345

44-
// Order the commits by date
45-
commits = commits.OrderByDescending(c => c.Commit.Author.Date).ToList();
46+
try
47+
{
48+
contributors = (await gitHubApi.GetContributorsAsync()).ToList();
49+
Console.WriteLine($"[RepoStats] Found {contributors.Count} contributors");
4650

47-
Console.WriteLine($"Found {commits.Count} commits");
51+
commits = (await gitHubApi.GetCommitsAsync()).ToList();
52+
// Order the commits by date
53+
commits = commits.OrderByDescending(c => c.Commit.Author.Date).ToList();
54+
Console.WriteLine($"[RepoStats] Found {commits.Count} commits");
55+
}
56+
catch (Exception ex)
57+
{
58+
Console.WriteLine($"[RepoStats] WARNING: Failed to retrieve GitHub stats ({ex.Message}). Skipping contributor data for this build.");
59+
return;
60+
}
4861

4962
if (contributors.Any() && commits.Any())
5063
{

0 commit comments

Comments
 (0)