Skip to content

Commit e042513

Browse files
arreyderclaude
andcommitted
test+fix(connector): retry-loop coverage + ctx attribution + classifier hardening
Address code review findings on OPS-1676: - Add sleepFn seam so retry tests don't burn wall time. - Surface ctx.Err() as primary signal on mid-backoff cancellation (errors.Join with the underlying transient err for log attribution), so cancellation isn't misclassified as an HTTP/2 timeout downstream. - New TestListCollaboratorsWithRetry: success-first, retry-then-success, exhaust-all-3, non-retryable short-circuit (1 call), and ctx-cancel mid-backoff (1 call, no spurious second attempt). Uses a scripted http.RoundTripper that fails loudly on over-retry. - Expand TestIsRetryableHTTP2Error: wrapped errors via fmt.Errorf %w, raw GOAWAY inner string, near-miss negatives ("http2: timeout" without "awaiting response headers", generic stream errors), context.Canceled short-circuit (alongside the existing DeadlineExceeded case). - Switch to testify/require for style consistency with the rest of the connector package. - Strengthen TestListCollaboratorsBackoffsBudget: assert 3-attempt invariant + sub-second first delay + 10s total-budget ceiling. - fakeTimeoutErr.Temporary now returns false with a comment noting it's only present to satisfy net.Error and is deprecated/unused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 78ab69a commit e042513

2 files changed

Lines changed: 213 additions & 21 deletions

File tree

pkg/connector/repository.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package connector
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"math/rand/v2"
78
"net/http"
@@ -548,13 +549,17 @@ var listCollaboratorsBackoffs = []time.Duration{
548549
3 * time.Second,
549550
}
550551

552+
// sleepFn is the indirection used by listCollaboratorsWithRetry to wait between
553+
// retries. Tests replace it with an immediate-fire channel to avoid real wall
554+
// time. Reassignment is not safe under t.Parallel.
555+
var sleepFn = time.After
556+
551557
func listCollaboratorsWithRetry(
552558
ctx context.Context,
553559
client *github.Client,
554560
org, repo string,
555561
opts *github.ListCollaboratorsOptions,
556562
) ([]*github.User, *github.Response, error) {
557-
l := ctxzap.Extract(ctx)
558563
var (
559564
users []*github.User
560565
resp *github.Response
@@ -570,7 +575,7 @@ func listCollaboratorsWithRetry(
570575
}
571576
base := listCollaboratorsBackoffs[attempt]
572577
jitter := time.Duration(rand.Int64N(int64(base/2))) - base/4
573-
l.Debug("retrying ListCollaborators after transient HTTP/2 error",
578+
ctxzap.Extract(ctx).Debug("retrying ListCollaborators after transient HTTP/2 error",
574579
zap.String("org", org),
575580
zap.String("repo", repo),
576581
zap.Int("attempt", attempt+1),
@@ -579,8 +584,10 @@ func listCollaboratorsWithRetry(
579584
)
580585
select {
581586
case <-ctx.Done():
582-
return users, resp, err
583-
case <-time.After(base + jitter):
587+
// Surface cancellation as the primary signal but preserve the
588+
// transient error for log attribution.
589+
return nil, nil, errors.Join(ctx.Err(), err)
590+
case <-sleepFn(base + jitter):
584591
}
585592
}
586593
return users, resp, err

pkg/connector/retry_test.go

Lines changed: 202 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,31 @@ package connector
33
import (
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

1018
type 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

1632
func 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

Comments
 (0)