Skip to content

Commit 2086139

Browse files
committed
Account for better secondary retry policy
1 parent c134a03 commit 2086139

File tree

2 files changed

+94
-16
lines changed

2 files changed

+94
-16
lines changed

src/Octoshift/RetryPolicy.cs

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Linq;
3+
using System.Net;
24
using System.Net.Http;
35
using System.Threading.Tasks;
46
using OctoshiftCLI.Services;
@@ -18,6 +20,82 @@ public RetryPolicy(OctoLogger log)
1820
_log = log;
1921
}
2022

23+
/// <summary>
24+
/// NEW: Minimal overload for HTTP calls that returns HttpResponseMessage so we can
25+
/// honor Retry-After and X-RateLimit-* headers for secondary rate limits.
26+
/// Usage: var resp = await _retryPolicy.HttpRetry(() => _httpClient.SendAsync(request));
27+
/// </summary>
28+
public async Task<HttpResponseMessage> HttpRetry(Func<Task<HttpResponseMessage>> func)
29+
{
30+
var policy = Policy
31+
.HandleResult<HttpResponseMessage>(r =>
32+
{
33+
var sc = (int)r.StatusCode;
34+
if (sc != 403 && sc != 429) return false;
35+
36+
// Treat Retry-After or X-RateLimit-Remaining: 0 as secondary-rate limiting signals
37+
if (r.Headers.RetryAfter != null) return true;
38+
if (r.Headers.TryGetValues("X-RateLimit-Remaining", out var remain) &&
39+
remain?.FirstOrDefault() == "0") return true;
40+
41+
// Fallback: any 403/429 without headers still gets backoff per docs
42+
return true;
43+
})
44+
.WaitAndRetryAsync(
45+
retryCount: 5,
46+
sleepDurationProvider: async (attempt, outcome, ctx) =>
47+
{
48+
var r = outcome.Result;
49+
50+
// 1) Honor Retry-After header
51+
var ra = r.Headers.RetryAfter?.Delta;
52+
if (ra.HasValue) return ra.Value;
53+
54+
// 2) If remaining == 0, wait until reset
55+
if (r.Headers.TryGetValues("X-RateLimit-Remaining", out var remainVals) &&
56+
remainVals?.FirstOrDefault() == "0" &&
57+
r.Headers.TryGetValues("X-RateLimit-Reset", out var resetVals) &&
58+
long.TryParse(resetVals?.FirstOrDefault(), out var resetEpoch))
59+
{
60+
var resetAt = DateTimeOffset.FromUnixTimeSeconds(resetEpoch);
61+
var wait = resetAt - DateTimeOffset.UtcNow;
62+
if (wait > TimeSpan.Zero) return wait;
63+
}
64+
65+
// 3) Otherwise: at least 1 minute, exponential thereafter (1,2,4,8,...)
66+
var minutes = Math.Max(1, Math.Pow(2, attempt - 1));
67+
return TimeSpan.FromMinutes(minutes);
68+
},
69+
onRetryAsync: async (outcome, wait, attempt, ctx) =>
70+
{
71+
var r = outcome.Result;
72+
string body = null;
73+
try { body = r.Content != null ? await r.Content.ReadAsStringAsync() : null; } catch { /* ignore */ }
74+
75+
_log?.LogVerbose(
76+
$"Secondary rate limit (HTTP {(int)r.StatusCode}). " +
77+
$"Retrying in {wait.TotalSeconds:N0}s (attempt {attempt}). " +
78+
$"Retry-After={r.Headers.RetryAfter?.Delta?.TotalSeconds}, " +
79+
$"X-RateLimit-Remaining={TryHeader(r, "X-RateLimit-Remaining")}, " +
80+
$"X-RateLimit-Reset={TryHeader(r, "X-RateLimit-Reset")}. " +
81+
$"Body={(body?.Length > 200 ? body.Substring(0, 200) + "…" : body)}");
82+
});
83+
84+
var resp = await policy.ExecuteAsync(func);
85+
86+
if (!resp.IsSuccessStatusCode)
87+
{
88+
var finalBody = (resp.Content != null) ? await resp.Content.ReadAsStringAsync() : null;
89+
throw new OctoshiftCliException($"HTTP {(int)resp.StatusCode} after retries. Body={finalBody}");
90+
}
91+
92+
return resp;
93+
94+
static string TryHeader(HttpResponseMessage r, string name)
95+
=> r.Headers.TryGetValues(name, out var vals) ? vals?.FirstOrDefault() : null;
96+
}
97+
98+
// Existing overload preserved (exception-based)
2199
public async Task<T> HttpRetry<T>(Func<Task<T>> func, Func<HttpRequestException, bool> filter)
22100
{
23101
var policy = Policy.Handle(filter)
@@ -45,23 +123,23 @@ public async Task<PolicyResult<T>> RetryOnResult<T>(Func<Task<T>> func, Func<T,
45123
public async Task<T> Retry<T>(Func<Task<T>> func) => await CreateRetryPolicyForException<Exception>().ExecuteAsync(func);
46124

47125
private AsyncRetryPolicy CreateRetryPolicyForException<TException>() where TException : Exception => Policy
48-
.Handle<TException>()
49-
.WaitAndRetryAsync(5, retry => retry * TimeSpan.FromMilliseconds(_retryInterval), (Exception ex, TimeSpan ts, Context ctx) =>
126+
.Handle<TException>()
127+
.WaitAndRetryAsync(5, retry => retry * TimeSpan.FromMilliseconds(_retryInterval), (Exception ex, TimeSpan ts, Context ctx) =>
128+
{
129+
if (ex is HttpRequestException httpEx)
50130
{
51-
if (ex is HttpRequestException httpEx)
131+
_log?.LogVerbose($"[HTTP ERROR {(int?)httpEx.StatusCode}] {ex}");
132+
if (httpEx.StatusCode == HttpStatusCode.Unauthorized)
52133
{
53-
_log?.LogVerbose($"[HTTP ERROR {(int?)httpEx.StatusCode}] {ex}");
54-
if (httpEx.StatusCode == System.Net.HttpStatusCode.Unauthorized)
55-
{
56-
// We should not retry on an unathorized error; instead, log and bubble up the error
57-
throw new OctoshiftCliException($"Unauthorized. Please check your token and try again", ex);
58-
};
134+
// We should not retry on an unauthorized error; instead, log and bubble up the error
135+
throw new OctoshiftCliException("Unauthorized. Please check your token and try again", ex);
59136
}
60-
else
61-
{
62-
_log?.LogVerbose(ex.ToString());
63-
}
64-
_log?.LogVerbose("Retrying...");
65-
});
137+
}
138+
else
139+
{
140+
_log?.LogVerbose(ex.ToString());
141+
}
142+
_log?.LogVerbose("Retrying...");
143+
});
66144
}
67145
}

src/Octoshift/Services/GithubClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ public virtual async Task<string> PatchAsync(string url, object body, Dictionary
181181
}
182182
}
183183

184-
using var response = await _httpClient.SendAsync(request);
184+
using var response = await _retryPolicy.HttpRetry(() => _httpClient.SendAsync(request));
185185

186186
_log.LogVerbose($"GITHUB REQUEST ID: {ExtractHeaderValue("X-GitHub-Request-Id", response.Headers)}");
187187
var content = await response.Content.ReadAsStringAsync();

0 commit comments

Comments
 (0)