Skip to content

Commit e736f55

Browse files
authored
Add preview of changes for standard retry mode behind flag (#3400)
* Add changes for new retries 2026 behind a flag * Add changelog * Make new jitter options private and instead expose BaseDelay for DDB use case; Remove LongPoll from retry options * Add STS IDPCommunicationError as a retryable error * Update long polling codegen * Add new test for exhausted quota on long-polling operations
1 parent ba08dc9 commit e736f55

23 files changed

Lines changed: 1335 additions & 27 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"id": "cffa1fca-5828-44fb-947b-ae754cd08bae",
3+
"type": "feature",
4+
"description": "Add preview of standard retry changes behind AWS_NEW_RETRIES_2026 flag",
5+
"modules": [
6+
".",
7+
"service/dynamodb",
8+
"service/dynamodbstreams",
9+
"service/sfn",
10+
"service/sqs",
11+
"service/swf"
12+
]
13+
}

aws/retry/jitter_backoff.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"math"
55
"time"
66

7+
"github.com/aws/aws-sdk-go-v2/aws"
78
"github.com/aws/aws-sdk-go-v2/internal/rand"
89
"github.com/aws/aws-sdk-go-v2/internal/timeconv"
910
)
@@ -12,9 +13,20 @@ import (
1213
// number of attempts.
1314
type ExponentialJitterBackoff struct {
1415
maxBackoff time.Duration
15-
// precomputed number of attempts needed to reach max backoff.
16+
// precomputed number of attempts needed to reach max backoff (legacy mode).
1617
maxBackoffAttempts float64
1718

19+
// Base delay for non-throttle errors (x in the formula t_i = b * min(x * r^i, MAX_BACKOFF)).
20+
baseDelay time.Duration
21+
22+
// Throttle error checker. When set and the error is a throttle, the base
23+
// delay is 1s regardless of the configured baseDelay.
24+
throttle IsErrorThrottle
25+
26+
// When true, applies MAX_BACKOFF before jitter and uses throttle-aware
27+
// base delay.
28+
retries2026 bool
29+
1830
randFloat64 func() (float64, error)
1931
}
2032

@@ -25,13 +37,53 @@ func NewExponentialJitterBackoff(maxBackoff time.Duration) *ExponentialJitterBac
2537
maxBackoff: maxBackoff,
2638
maxBackoffAttempts: math.Log2(
2739
float64(maxBackoff) / float64(time.Second)),
40+
baseDelay: time.Second,
2841
randFloat64: rand.CryptoRandFloat64,
2942
}
3043
}
3144

45+
// exponentialJitterBackoffOption is a functional option for ExponentialJitterBackoff.
46+
type exponentialJitterBackoffOption func(*ExponentialJitterBackoff)
47+
48+
// withBaseDelay sets the base delay for non-throttle errors.
49+
func withBaseDelay(d time.Duration) exponentialJitterBackoffOption {
50+
return func(j *ExponentialJitterBackoff) {
51+
j.baseDelay = d
52+
}
53+
}
54+
55+
// withThrottleCheck sets the throttle error checker used to determine if the
56+
// backoff should use the throttle base delay (1s) instead of the configured
57+
// base delay.
58+
func withThrottleCheck(t IsErrorThrottle) exponentialJitterBackoffOption {
59+
return func(j *ExponentialJitterBackoff) {
60+
j.throttle = t
61+
}
62+
}
63+
64+
// newExponentialJitterBackoffWithOptions returns an ExponentialJitterBackoff
65+
// with the given options applied.
66+
func newExponentialJitterBackoffWithOptions(maxBackoff time.Duration, optFns ...exponentialJitterBackoffOption) *ExponentialJitterBackoff {
67+
j := NewExponentialJitterBackoff(maxBackoff)
68+
j.retries2026 = true
69+
for _, fn := range optFns {
70+
fn(j)
71+
}
72+
return j
73+
}
74+
3275
// BackoffDelay returns the duration to wait before the next attempt should be
3376
// made. Returns an error if unable get a duration.
3477
func (j *ExponentialJitterBackoff) BackoffDelay(attempt int, err error) (time.Duration, error) {
78+
if j.retries2026 {
79+
return j.backoffDelay2026(attempt, err)
80+
}
81+
return j.backoffDelayLegacy(attempt, err)
82+
}
83+
84+
// backoffDelayLegacy preserves the original backoff formula: b * 2^i, capped
85+
// at maxBackoff.
86+
func (j *ExponentialJitterBackoff) backoffDelayLegacy(attempt int, err error) (time.Duration, error) {
3587
if attempt > int(j.maxBackoffAttempts) {
3688
return j.maxBackoff, nil
3789
}
@@ -47,3 +99,26 @@ func (j *ExponentialJitterBackoff) BackoffDelay(attempt int, err error) (time.Du
4799

48100
return timeconv.FloatSecondsDur(delaySeconds), nil
49101
}
102+
103+
// backoffDelay2026 uses throttle-aware base delay and applies MAX_BACKOFF
104+
// before jitter: t_i = b * min(x * 2^i, MAX_BACKOFF).
105+
func (j *ExponentialJitterBackoff) backoffDelay2026(attempt int, err error) (time.Duration, error) {
106+
x := j.baseDelay
107+
if j.throttle != nil && j.throttle.IsErrorThrottle(err) == aws.TrueTernary {
108+
x = time.Second
109+
}
110+
111+
b, randErr := j.randFloat64()
112+
if randErr != nil {
113+
return 0, randErr
114+
}
115+
116+
ri := math.Pow(2, float64(attempt))
117+
delaySeconds := float64(x) / float64(time.Second) * ri
118+
maxBackoffSeconds := float64(j.maxBackoff) / float64(time.Second)
119+
if delaySeconds > maxBackoffSeconds {
120+
delaySeconds = maxBackoffSeconds
121+
}
122+
123+
return timeconv.FloatSecondsDur(b * delaySeconds), nil
124+
}

aws/retry/middleware.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,11 @@ func (r *Attempt) handleAttempt(
233233
"failed to release retry token after request error, %w", err)
234234
}
235235
// Release the attempt token based on the state of the attempt's error (if any).
236-
if releaseError := releaseAttemptToken(err); releaseError != nil && err != nil {
237-
return out, attemptResult, nopRelease, fmt.Errorf(
238-
"failed to release initial token after request error, %w", err)
236+
if !newRetries2026() || attemptNum == 1 {
237+
if releaseError := releaseAttemptToken(err); releaseError != nil && err != nil {
238+
return out, attemptResult, nopRelease, fmt.Errorf(
239+
"failed to release initial token after request error, %w", err)
240+
}
239241
}
240242
// If there was no error making the attempt, nothing further to do. There
241243
// will be nothing to retry.
@@ -276,6 +278,13 @@ func (r *Attempt) handleAttempt(
276278
// Get a retry token that will be released after the
277279
releaseRetryToken, retryTokenErr := r.retryer.GetRetryToken(ctx, err)
278280
if retryTokenErr != nil {
281+
// Long-polling operations must still back off when quota is exceeded.
282+
if newRetries2026() && internalcontext.GetIsLongPolling(ctx) {
283+
if retryDelay, delayErr := r.retryer.RetryDelay(attemptNum-1, err); delayErr == nil {
284+
retryDelay = adjustForRetryAfterHeader(retryDelay, err, logger, r.LogAttempts)
285+
_ = sdk.SleepWithContext(ctx, retryDelay)
286+
}
287+
}
279288
return out, attemptResult, nopRelease, errors.Join(err, retryTokenErr)
280289
}
281290

@@ -285,10 +294,17 @@ func (r *Attempt) handleAttempt(
285294
// Get the retry delay before another attempt can be made, and sleep for
286295
// that time. Potentially early exist if the sleep is canceled via the
287296
// context.
288-
retryDelay, reqErr := r.retryer.RetryDelay(attemptNum, err)
297+
attempt := attemptNum
298+
if newRetries2026() {
299+
attempt = attemptNum - 1
300+
}
301+
retryDelay, reqErr := r.retryer.RetryDelay(attempt, err)
289302
if reqErr != nil {
290303
return out, attemptResult, releaseRetryToken, reqErr
291304
}
305+
if newRetries2026() {
306+
retryDelay = adjustForRetryAfterHeader(retryDelay, err, logger, r.LogAttempts)
307+
}
292308
if reqErr = sdk.SleepWithContext(ctx, retryDelay); reqErr != nil {
293309
err = &aws.RequestCanceledError{Err: reqErr}
294310
return out, attemptResult, releaseRetryToken, err
@@ -423,6 +439,43 @@ func AddRetryMiddlewares(stack *smithymiddle.Stack, options AddRetryMiddlewaresO
423439
return nil
424440
}
425441

442+
// adjustForRetryAfterHeader checks for the x-amz-retry-after response header
443+
// and clamps the backoff duration accordingly. The header value is an integer
444+
// representing milliseconds. The result is clamped to [t_i, 5s + t_i] where
445+
// t_i is the jittered exponential backoff duration. Invalid header values are
446+
// ignored.
447+
func adjustForRetryAfterHeader(backoff time.Duration, err error, logger logging.Logger, logAttempts bool) time.Duration {
448+
var re *http.ResponseError
449+
if !errors.As(err, &re) || re.Response == nil || re.Response.Response == nil {
450+
return backoff
451+
}
452+
453+
headerVal := re.Response.Header.Get("X-Amz-Retry-After")
454+
if headerVal == "" {
455+
return backoff
456+
}
457+
458+
ms, parseErr := strconv.ParseInt(headerVal, 10, 64)
459+
if parseErr != nil || ms < 0 {
460+
if logAttempts {
461+
logger.Logf(logging.Debug, "ignoring invalid x-amz-retry-after header value %q", headerVal)
462+
}
463+
return backoff
464+
}
465+
466+
retryAfter := time.Duration(ms) * time.Millisecond
467+
minDuration := backoff
468+
maxDuration := 5*time.Second + backoff
469+
470+
if retryAfter < minDuration {
471+
return minDuration
472+
}
473+
if retryAfter > maxDuration {
474+
return maxDuration
475+
}
476+
return retryAfter
477+
}
478+
426479
// Determines the value of exception.type for metrics purposes. We prefer an
427480
// API-specific error code, otherwise it's just the Go type for the value.
428481
func errorType(err error) string {

aws/retry/middleware_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,58 @@ func TestClockSkew(t *testing.T) {
564564
}
565565
}
566566

567+
// TestLegacyRetryDelayAttemptValue verifies that when the new retries flag is
568+
// NOT set, the attempt value passed to RetryDelay is unchanged from legacy
569+
// behavior (1-based: first retry passes 1, second passes 2, etc.).
570+
func TestLegacyRetryDelayAttemptValue(t *testing.T) {
571+
restoreSleep := sdk.TestingUseNopSleep()
572+
defer restoreSleep()
573+
574+
var recordedAttempts []int
575+
retryer := NewStandard(func(o *StandardOptions) {
576+
o.MaxAttempts = 4
577+
o.Backoff = BackoffDelayerFunc(func(attempt int, err error) (time.Duration, error) {
578+
recordedAttempts = append(recordedAttempts, attempt)
579+
return 0, nil
580+
})
581+
})
582+
583+
am := NewAttemptMiddleware(retryer, func(i interface{}) interface{} {
584+
return i
585+
})
586+
587+
num := 0
588+
handler := middleware.FinalizeHandlerFunc(
589+
func(ctx context.Context, in middleware.FinalizeInput) (
590+
out middleware.FinalizeOutput, metadata middleware.Metadata, err error,
591+
) {
592+
num++
593+
if num < 4 {
594+
return out, metadata, mockRetryableError{b: true}
595+
}
596+
return out, metadata, nil
597+
})
598+
599+
_, _, err := am.HandleFinalize(context.Background(), middleware.FinalizeInput{}, handler)
600+
if err != nil {
601+
t.Fatalf("unexpected error: %v", err)
602+
}
603+
604+
// Legacy behavior: attemptNum is passed directly (1-based).
605+
// After attempt 1 fails, RetryDelay is called with attemptNum=1.
606+
// After attempt 2 fails, RetryDelay is called with attemptNum=2.
607+
// After attempt 3 fails, RetryDelay is called with attemptNum=3.
608+
expected := []int{1, 2, 3}
609+
if len(recordedAttempts) != len(expected) {
610+
t.Fatalf("expected %d RetryDelay calls, got %d", len(expected), len(recordedAttempts))
611+
}
612+
for i, exp := range expected {
613+
if recordedAttempts[i] != exp {
614+
t.Errorf("RetryDelay call %d: expected attempt=%d, got attempt=%d", i, exp, recordedAttempts[i])
615+
}
616+
}
617+
}
618+
567619
// mockRawResponseKey is used to test the behavior when response metadata is
568620
// nested within the attempt request.
569621
type mockRawResponseKey struct{}

aws/retry/retry.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ func (r *withMaxBackoffDelay) RetryDelay(attempt int, err error) (time.Duration,
7272
return r.backoff.BackoffDelay(attempt, err)
7373
}
7474

75+
// AddWithLongPolling returns a retryer that is marked as long-polling.
76+
// Long-polling operations will back off even when the retry quota is
77+
// exhausted.
78+
func AddWithLongPolling(r aws.Retryer) aws.Retryer {
79+
return &withLongPolling{RetryerV2: wrapAsRetryerV2(r)}
80+
}
81+
82+
type withLongPolling struct {
83+
aws.RetryerV2
84+
}
85+
86+
func (w *withLongPolling) IsLongPolling() bool { return true }
87+
7588
type wrappedAsRetryerV2 struct {
7689
aws.Retryer
7790
}

0 commit comments

Comments
 (0)