Skip to content

Commit d657a4f

Browse files
authored
Merge pull request #2 from two-inc/brtkwr-two/inf-1671-twoctl-clamp-and-floor-retry-after
INF-1671/fix: clamp and floor Retry-After in twoctl backoffDelay
2 parents 1096d29 + 74a0c53 commit d657a4f

2 files changed

Lines changed: 69 additions & 8 deletions

File tree

internal/httpx/backoff_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,34 @@ func TestBackoffDelayBadRetryAfterFallsBack(t *testing.T) {
3434
t.Errorf("garbage Retry-After should fall back to backoff, got %v", d)
3535
}
3636
}
37+
38+
func TestBackoffDelayClampsLargeRetryAfter(t *testing.T) {
39+
// A hostile or misconfigured upstream must not be able to park the CLI for
40+
// days: the hint is clamped to maxBackoff.
41+
resp := &http.Response{Header: http.Header{"Retry-After": []string{"999999"}}}
42+
if d := backoffDelay(0, resp); d != maxBackoff {
43+
t.Errorf("Retry-After: 999999 → %v, want clamp to %v", d, maxBackoff)
44+
}
45+
}
46+
47+
func TestBackoffDelayFloorsZeroRetryAfter(t *testing.T) {
48+
// Retry-After: 0 (e.g. a fleet-wide rate-limit reset) must not collapse
49+
// into a zero-delay retry storm; it floors at the jittered backoff.
50+
resp := &http.Response{Header: http.Header{"Retry-After": []string{"0"}}}
51+
d := backoffDelay(0, resp)
52+
if d < baseBackoff {
53+
t.Errorf("Retry-After: 0 → %v, want at least baseBackoff %v", d, baseBackoff)
54+
}
55+
if d > maxBackoff+maxBackoff/4 {
56+
t.Errorf("Retry-After: 0 → %v exceeds cap+jitter window", d)
57+
}
58+
}
59+
60+
func TestBackoffDelayRetryAfterIsLowerBoundNotCeiling(t *testing.T) {
61+
// At a high attempt our own backoff already sits at the cap; a small
62+
// Retry-After must not shrink it below that.
63+
resp := &http.Response{Header: http.Header{"Retry-After": []string{"1"}}}
64+
if d := backoffDelay(6, resp); d < maxBackoff {
65+
t.Errorf("backoff at attempt 6 should be >= %v despite Retry-After: 1, got %v", maxBackoff, d)
66+
}
67+
}

internal/httpx/client.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,24 @@ func shouldRetry(resp *http.Response, err error) bool {
183183
return false
184184
}
185185

186-
// backoffDelay honours `Retry-After` when set, otherwise applies exponential
187-
// backoff with jitter so a thundering herd doesn't synchronise.
186+
// backoffDelay applies exponential backoff with jitter so a thundering herd
187+
// doesn't synchronise, and treats `Retry-After` as a lower bound rather than an
188+
// override: the server hint is honoured only when it exceeds our own backoff,
189+
// and is clamped to maxBackoff. This keeps `Retry-After: 0` (e.g. a fleet-wide
190+
// rate-limit reset) from collapsing into a zero-delay retry storm, and stops a
191+
// hostile or misconfigured `Retry-After: 999999` from parking the CLI for days.
192+
// Mirrors twoadm-cli internal/httpx (see INF-1314).
188193
func backoffDelay(attempt int, resp *http.Response) time.Duration {
189-
if resp != nil {
190-
if v := resp.Header.Get("Retry-After"); v != "" {
191-
if secs, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && secs >= 0 {
192-
return time.Duration(secs) * time.Second
193-
}
194-
}
194+
d := jitteredBackoff(attempt)
195+
if hint := retryAfter(resp); hint > d {
196+
d = hint
195197
}
198+
return d
199+
}
200+
201+
// jitteredBackoff returns the exponential backoff for `attempt` (0-based) with
202+
// [0, 25%) additive jitter, capped at maxBackoff.
203+
func jitteredBackoff(attempt int) time.Duration {
196204
// Clamp attempt so the shift below can't overflow time.Duration on
197205
// pathological MaxRetries values. baseBackoff << 6 = 16s, already
198206
// past the cap, so any attempt ≥ 6 lands on maxBackoff anyway.
@@ -209,3 +217,25 @@ func backoffDelay(attempt int, resp *http.Response) time.Duration {
209217
}
210218
return d + time.Duration(rand.Int64N(jitter))
211219
}
220+
221+
// retryAfter parses the `Retry-After` header (seconds form), clamped to
222+
// maxBackoff. Returns 0 when the header is absent, negative, or unparseable so
223+
// the caller falls back to jittered exponential backoff.
224+
func retryAfter(resp *http.Response) time.Duration {
225+
if resp == nil {
226+
return 0
227+
}
228+
v := strings.TrimSpace(resp.Header.Get("Retry-After"))
229+
if v == "" {
230+
return 0
231+
}
232+
secs, err := strconv.Atoi(v)
233+
if err != nil || secs < 0 {
234+
return 0
235+
}
236+
d := time.Duration(secs) * time.Second
237+
if d > maxBackoff {
238+
return maxBackoff
239+
}
240+
return d
241+
}

0 commit comments

Comments
 (0)