@@ -3,15 +3,31 @@ package connector
33import (
44 "context"
55 "errors"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "sync"
611 "testing"
712 "time"
13+
14+ "github.com/google/go-github/v69/github"
15+ "github.com/stretchr/testify/require"
816)
917
1018type fakeTimeoutErr struct {}
1119
12- func (fakeTimeoutErr ) Error () string { return "i/o timeout" }
13- func (fakeTimeoutErr ) Timeout () bool { return true }
14- func (fakeTimeoutErr ) Temporary () bool { return true }
20+ func (fakeTimeoutErr ) Error () string { return "i/o timeout" }
21+ func (fakeTimeoutErr ) Timeout () bool { return true }
22+
23+ // Temporary satisfies net.Error; deprecated and unused by isRetryableHTTP2Error.
24+ func (fakeTimeoutErr ) Temporary () bool { return false }
25+
26+ const (
27+ hdrTimeoutMsg = "http2: timeout awaiting response headers"
28+ goawayInner = "http2: Transport received Server's graceful shutdown GOAWAY"
29+ goawayWrapped = "http2: Transport: cannot retry err [http2: Transport received Server's graceful shutdown GOAWAY] after Request.Body was written; define Request.GetBody to avoid this error"
30+ )
1531
1632func TestIsRetryableHTTP2Error (t * testing.T ) {
1733 cases := []struct {
@@ -21,30 +37,199 @@ func TestIsRetryableHTTP2Error(t *testing.T) {
2137 }{
2238 {"nil" , nil , false },
2339 {"unrelated" , errors .New ("boom" ), false },
24- {"response-header timeout" , errors .New ("http2: timeout awaiting response headers" ), true },
25- {"goaway no GetBody" , errors .New ("http2: Transport: cannot retry err [http2: Transport received Server's graceful shutdown GOAWAY] after Request.Body was written; define Request.GetBody to avoid this error" ), true },
40+
41+ // Positive: bare error strings from x/net/http2.
42+ {"response-header timeout" , errors .New (hdrTimeoutMsg ), true },
43+ {"goaway raw inner string" , errors .New (goawayInner ), true },
44+ {"goaway wrapped no-GetBody" , errors .New (goawayWrapped ), true },
45+
46+ // Positive: net.Error.Timeout().
2647 {"net.Error timeout" , fakeTimeoutErr {}, true },
27- {"context deadline exceeded (not wrapped as net.Error)" , context .DeadlineExceeded , false },
48+
49+ // Positive: errors wrapped via %w — the substring check still matches
50+ // because err.Error() walks the wrap chain.
51+ {"wrapped response-header timeout" , fmt .Errorf ("ListCollaborators: %w" , errors .New (hdrTimeoutMsg )), true },
52+ {"wrapped net.Error timeout" , fmt .Errorf ("dial: %w" , fakeTimeoutErr {}), true },
53+
54+ // Negative: context errors must short-circuit before the net.Error
55+ // check (context.DeadlineExceeded satisfies net.Error.Timeout()=true).
56+ {"context.DeadlineExceeded short-circuited" , context .DeadlineExceeded , false },
57+ {"context.Canceled short-circuited" , context .Canceled , false },
58+ {"wrapped context.Canceled" , fmt .Errorf ("op cancelled: %w" , context .Canceled ), false },
59+
60+ // Negative: near-miss substrings that must not over-retry.
61+ {"truncated hdr-timeout substring" , errors .New ("http2: timeout" ), false },
62+ {"generic http2 stream error" , errors .New ("http2: stream error" ), false },
2863 }
2964 for _ , c := range cases {
3065 t .Run (c .name , func (t * testing.T ) {
31- got := isRetryableHTTP2Error (c .err )
32- if got != c .want {
33- t .Fatalf ("isRetryableHTTP2Error(%v) = %v, want %v" , c .err , got , c .want )
34- }
66+ require .Equal (t , c .want , isRetryableHTTP2Error (c .err ),
67+ "isRetryableHTTP2Error(%v) classification" , c .err )
3568 })
3669 }
3770}
3871
39- func TestListCollaboratorsBackoffsShape (t * testing.T ) {
40- if len (listCollaboratorsBackoffs ) != 3 {
41- t .Fatalf ("expected 3 backoff slots, got %d" , len (listCollaboratorsBackoffs ))
42- }
72+ // TestListCollaboratorsBackoffsBudget enforces both the 3-attempt invariant
73+ // and a total-budget ceiling so a future tuning regression that, e.g., bumps
74+ // the last slot to 30s will fail loudly here.
75+ func TestListCollaboratorsBackoffsBudget (t * testing.T ) {
76+ require .Len (t , listCollaboratorsBackoffs , 3 , "three-attempt invariant" )
77+ require .Less (t , listCollaboratorsBackoffs [0 ], time .Second , "first delay must be sub-second" )
78+
79+ var total time.Duration
4380 prev := time .Duration (0 )
4481 for i , d := range listCollaboratorsBackoffs {
45- if d <= prev {
46- t .Fatalf ("backoff[%d]=%s is not strictly increasing from %s" , i , d , prev )
47- }
82+ require .Greater (t , d , prev , "backoff[%d] must be strictly increasing" , i )
4883 prev = d
84+ total += d
85+ }
86+ require .LessOrEqual (t , total , 10 * time .Second , "total backoff budget exceeded" )
87+ }
88+
89+ // scriptedRT returns a scripted error or response per RoundTrip call. It panics
90+ // the test on extra calls so over-retry is caught loudly.
91+ type scriptedRT struct {
92+ mu sync.Mutex
93+ t * testing.T
94+ calls int
95+ steps []scriptedStep
96+ }
97+
98+ type scriptedStep struct {
99+ err error // if non-nil, returned as the RoundTrip error
100+ resp * http.Response // otherwise this response is returned
101+ }
102+
103+ func (r * scriptedRT ) RoundTrip (req * http.Request ) (* http.Response , error ) {
104+ r .mu .Lock ()
105+ defer r .mu .Unlock ()
106+ require .Less (r .t , r .calls , len (r .steps ), "unexpected extra RoundTrip call (call #%d)" , r .calls + 1 )
107+ step := r .steps [r .calls ]
108+ r .calls ++
109+ if step .err != nil {
110+ return nil , step .err
111+ }
112+ return step .resp , nil
113+ }
114+
115+ func (r * scriptedRT ) callCount () int {
116+ r .mu .Lock ()
117+ defer r .mu .Unlock ()
118+ return r .calls
119+ }
120+
121+ func okEmptyResponse (req * http.Request ) * http.Response {
122+ return & http.Response {
123+ StatusCode : http .StatusOK ,
124+ Body : io .NopCloser (strings .NewReader (`[]` )),
125+ Header : make (http.Header ),
126+ Request : req ,
127+ }
128+ }
129+
130+ // installImmediateSleep swaps sleepFn so retry backoffs don't burn wall time.
131+ // Not safe under t.Parallel; callers must avoid it.
132+ func installImmediateSleep (t * testing.T ) {
133+ t .Helper ()
134+ orig := sleepFn
135+ sleepFn = func (time.Duration ) <- chan time.Time {
136+ ch := make (chan time.Time , 1 )
137+ ch <- time .Now ()
138+ return ch
139+ }
140+ t .Cleanup (func () { sleepFn = orig })
141+ }
142+
143+ func newScriptedClient (t * testing.T , steps ... scriptedStep ) (* github.Client , * scriptedRT ) {
144+ t .Helper ()
145+ rt := & scriptedRT {t : t , steps : steps }
146+ return github .NewClient (& http.Client {Transport : rt }), rt
147+ }
148+
149+ func TestListCollaboratorsWithRetry_SuccessFirstAttempt (t * testing.T ) {
150+ installImmediateSleep (t )
151+ client , rt := newScriptedClient (t , scriptedStep {resp : okEmptyResponse (& http.Request {})})
152+
153+ _ , _ , err := listCollaboratorsWithRetry (t .Context (), client , "org" , "repo" , & github.ListCollaboratorsOptions {})
154+ require .NoError (t , err )
155+ require .Equal (t , 1 , rt .callCount (), "no retry on success" )
156+ }
157+
158+ func TestListCollaboratorsWithRetry_RetriesThenSucceeds (t * testing.T ) {
159+ installImmediateSleep (t )
160+ client , rt := newScriptedClient (t ,
161+ scriptedStep {err : errors .New (hdrTimeoutMsg )},
162+ scriptedStep {resp : okEmptyResponse (& http.Request {})},
163+ )
164+
165+ _ , _ , err := listCollaboratorsWithRetry (t .Context (), client , "org" , "repo" , & github.ListCollaboratorsOptions {})
166+ require .NoError (t , err )
167+ require .Equal (t , 2 , rt .callCount (), "exactly one retry before success" )
168+ }
169+
170+ func TestListCollaboratorsWithRetry_ExhaustsAllAttempts (t * testing.T ) {
171+ installImmediateSleep (t )
172+ client , rt := newScriptedClient (t ,
173+ scriptedStep {err : errors .New (hdrTimeoutMsg )},
174+ scriptedStep {err : errors .New (hdrTimeoutMsg )},
175+ scriptedStep {err : errors .New (goawayWrapped )},
176+ )
177+
178+ _ , _ , err := listCollaboratorsWithRetry (t .Context (), client , "org" , "repo" , & github.ListCollaboratorsOptions {})
179+ require .Error (t , err )
180+ require .Equal (t , 3 , rt .callCount (), "exactly three attempts on full failure" )
181+ require .Contains (t , err .Error (), "GOAWAY" , "final attempt's error is surfaced" )
182+ }
183+
184+ func TestListCollaboratorsWithRetry_NonRetryableShortCircuits (t * testing.T ) {
185+ installImmediateSleep (t )
186+ // A non-classified error must abort after the first attempt.
187+ client , rt := newScriptedClient (t , scriptedStep {err : errors .New ("some random non-retryable error" )})
188+
189+ _ , _ , err := listCollaboratorsWithRetry (t .Context (), client , "org" , "repo" , & github.ListCollaboratorsOptions {})
190+ require .Error (t , err )
191+ require .Equal (t , 1 , rt .callCount (), "non-retryable error must not retry" )
192+ }
193+
194+ func TestListCollaboratorsWithRetry_ContextCancelDuringBackoff (t * testing.T ) {
195+ // Replace sleepFn with one that blocks until we explicitly release it,
196+ // so we can cancel the ctx mid-backoff and assert the select branch.
197+ orig := sleepFn
198+ sleepStarted := make (chan struct {}, len (listCollaboratorsBackoffs ))
199+ hold := make (chan time.Time ) // never sent on
200+ sleepFn = func (time.Duration ) <- chan time.Time {
201+ sleepStarted <- struct {}{}
202+ return hold
203+ }
204+ t .Cleanup (func () { sleepFn = orig })
205+
206+ client , rt := newScriptedClient (t ,
207+ scriptedStep {err : errors .New (hdrTimeoutMsg )},
208+ // Second step is unreachable because ctx will be cancelled in backoff.
209+ scriptedStep {err : errors .New ("UNREACHABLE" )},
210+ )
211+ ctx , cancel := context .WithCancel (context .Background ())
212+
213+ errCh := make (chan error , 1 )
214+ go func () {
215+ _ , _ , err := listCollaboratorsWithRetry (ctx , client , "org" , "repo" , & github.ListCollaboratorsOptions {})
216+ errCh <- err
217+ }()
218+
219+ select {
220+ case <- sleepStarted :
221+ case <- time .After (2 * time .Second ):
222+ t .Fatal ("retry never entered backoff" )
223+ }
224+ cancel ()
225+
226+ select {
227+ case err := <- errCh :
228+ require .Error (t , err )
229+ require .ErrorIs (t , err , context .Canceled , "ctx.Err() must be the primary signal" )
230+ require .Contains (t , err .Error (), hdrTimeoutMsg , "underlying transient err preserved in joined error" )
231+ case <- time .After (2 * time .Second ):
232+ t .Fatal ("listCollaboratorsWithRetry did not return after ctx cancel" )
49233 }
234+ require .Equal (t , 1 , rt .callCount (), "must not start the second attempt after ctx cancel" )
50235}
0 commit comments