1
1
using System ;
2
+ using System . Linq ;
3
+ using System . Net ;
2
4
using System . Net . Http ;
3
5
using System . Threading . Tasks ;
4
6
using OctoshiftCLI . Services ;
@@ -18,6 +20,82 @@ public RetryPolicy(OctoLogger log)
18
20
_log = log ;
19
21
}
20
22
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)
21
99
public async Task < T > HttpRetry < T > ( Func < Task < T > > func , Func < HttpRequestException , bool > filter )
22
100
{
23
101
var policy = Policy . Handle ( filter )
@@ -45,23 +123,23 @@ public async Task<PolicyResult<T>> RetryOnResult<T>(Func<Task<T>> func, Func<T,
45
123
public async Task < T > Retry < T > ( Func < Task < T > > func ) => await CreateRetryPolicyForException < Exception > ( ) . ExecuteAsync ( func ) ;
46
124
47
125
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 )
50
130
{
51
- if ( ex is HttpRequestException httpEx )
131
+ _log ? . LogVerbose ( $ "[HTTP ERROR { ( int ? ) httpEx . StatusCode } ] { ex } ") ;
132
+ if ( httpEx . StatusCode == HttpStatusCode . Unauthorized )
52
133
{
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 ) ;
59
136
}
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
+ } ) ;
66
144
}
67
145
}
0 commit comments