Skip to content

Commit b877fc2

Browse files
authored
chore(util-retry): refine conditions for longpoll backoff (#1966)
1 parent 769ed47 commit b877fc2

3 files changed

Lines changed: 257 additions & 19 deletions

File tree

.changeset/sixty-singers-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smithy/util-retry": patch
3+
---
4+
5+
refine conditions for long poll backoff in v2026 retry behavior

packages/util-retry/src/StandardRetryStrategy.spec.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import type { RetryErrorInfo } from "@smithy/types";
2-
import { afterEach, describe, expect, test as it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest";
33

44
import { RETRY_MODES } from "./config";
5+
import { MAXIMUM_RETRY_DELAY } from "./constants";
6+
import { DefaultRetryBackoffStrategy } from "./DefaultRetryBackoffStrategy";
57
import { DefaultRetryToken } from "./DefaultRetryToken";
68
import { Retry } from "./retries-2026-config";
79
import { StandardRetryStrategy } from "./StandardRetryStrategy";
810

11+
class DeterministicRetryBackoffStrategy extends DefaultRetryBackoffStrategy {
12+
public computeNextBackoffDelay(i: number): number {
13+
const b = 1; // maximum instead of Math.random()
14+
const r = 2;
15+
const t_i = b * Math.min(this.x * r ** i, MAXIMUM_RETRY_DELAY);
16+
return Math.floor(t_i);
17+
}
18+
}
19+
920
vi.mock("./DefaultRetryToken");
1021

1122
describe(StandardRetryStrategy.name, () => {
@@ -139,4 +150,186 @@ describe(StandardRetryStrategy.name, () => {
139150
}
140151
});
141152
});
153+
154+
describe("retryCode", () => {
155+
it("returns code 1 (non-retryable) with highest priority over other reasons", async () => {
156+
vi.mocked(DefaultRetryToken).mockImplementation(
157+
() =>
158+
({
159+
getRetryCount: () => 5,
160+
getRetryCost: () => 0,
161+
getRetryDelay: () => 0,
162+
}) as any
163+
);
164+
const retryStrategy = new StandardRetryStrategy(1);
165+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
166+
// non-retryable + attempts exhausted: should get code 1 (non-retryable wins)
167+
const result = retryStrategy["retryCode"](token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo, 1);
168+
expect(result).toBe(1);
169+
});
170+
171+
it("returns code 2 (attempts exhausted) when retryable but no attempts left", async () => {
172+
vi.mocked(DefaultRetryToken).mockImplementation(
173+
() =>
174+
({
175+
getRetryCount: () => 2,
176+
getRetryCost: () => 0,
177+
getRetryDelay: () => 0,
178+
}) as any
179+
);
180+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
181+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
182+
const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts);
183+
expect(result).toBe(2);
184+
});
185+
186+
it("returns code 3 (no capacity) when retryable with attempts left but no tokens", async () => {
187+
vi.mocked(DefaultRetryToken).mockImplementation(
188+
() =>
189+
({
190+
getRetryCount: () => 0,
191+
getRetryCost: () => 0,
192+
getRetryDelay: () => 0,
193+
}) as any
194+
);
195+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
196+
retryStrategy["capacity"] = 0;
197+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
198+
const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts);
199+
expect(result).toBe(3);
200+
});
201+
202+
it("returns code 0 (OK to retry) when all conditions are met", async () => {
203+
vi.mocked(DefaultRetryToken).mockImplementation(
204+
() =>
205+
({
206+
getRetryCount: () => 0,
207+
getRetryCost: () => 0,
208+
getRetryDelay: () => 0,
209+
}) as any
210+
);
211+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
212+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
213+
const result = retryStrategy["retryCode"](token, errorInfo, maxAttempts);
214+
expect(result).toBe(0);
215+
});
216+
});
217+
218+
describe("long-poll token behavior", () => {
219+
it("throws with $backoff when long-poll token has no capacity (code 3)", async () => {
220+
vi.mocked(DefaultRetryToken).mockImplementation(
221+
() =>
222+
({
223+
getRetryCount: () => 0,
224+
getRetryCost: () => 0,
225+
getRetryDelay: () => 0,
226+
isLongPoll: () => true,
227+
}) as any
228+
);
229+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
230+
retryStrategy["capacity"] = 0;
231+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
232+
try {
233+
await retryStrategy.refreshRetryTokenForRetry(token, errorInfo);
234+
fail("expected error");
235+
} catch (error: any) {
236+
expect(error.message).toBe("No retry token available");
237+
// $backoff is 0 when Retry.v2026 is false, non-zero when true
238+
expect(error.$backoff).toBe(0);
239+
}
240+
});
241+
242+
it("throws with $backoff=0 when long-poll token fails for non-capacity reason (code 1)", async () => {
243+
vi.mocked(DefaultRetryToken).mockImplementation(
244+
() =>
245+
({
246+
getRetryCount: () => 0,
247+
getRetryCost: () => 0,
248+
getRetryDelay: () => 0,
249+
isLongPoll: () => true,
250+
}) as any
251+
);
252+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
253+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
254+
try {
255+
await retryStrategy.refreshRetryTokenForRetry(token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo);
256+
fail("expected error");
257+
} catch (error: any) {
258+
expect(error.message).toBe("No retry token available");
259+
expect(error.$backoff).toBe(0);
260+
}
261+
});
262+
263+
it("retries long-poll token even when retryCode is non-zero, if capacity exists", async () => {
264+
const getRetryCount = vi.fn().mockReturnValue(0);
265+
vi.mocked(DefaultRetryToken).mockImplementation(
266+
() =>
267+
({
268+
getRetryCount,
269+
getRetryCost: () => 0,
270+
getRetryDelay: () => 0,
271+
isLongPoll: () => true,
272+
}) as any
273+
);
274+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
275+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
276+
const refreshed = await retryStrategy.refreshRetryTokenForRetry(token, errorInfo);
277+
expect(refreshed).toBeDefined();
278+
});
279+
280+
describe("with Retry.v2026 enabled", () => {
281+
let originalV2026: boolean;
282+
283+
beforeEach(() => {
284+
originalV2026 = Retry.v2026;
285+
Retry.v2026 = true;
286+
});
287+
288+
afterEach(() => {
289+
Retry.v2026 = originalV2026;
290+
});
291+
292+
it("throws with non-zero $backoff for code 3", async () => {
293+
vi.mocked(DefaultRetryToken).mockImplementation(
294+
() =>
295+
({
296+
getRetryCount: () => 0,
297+
getRetryCost: () => 0,
298+
getRetryDelay: () => 0,
299+
isLongPoll: () => true,
300+
}) as any
301+
);
302+
const retryStrategy = new StandardRetryStrategy({
303+
maxAttempts,
304+
backoff: new DeterministicRetryBackoffStrategy(),
305+
});
306+
retryStrategy["capacity"] = 0;
307+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
308+
await expect(retryStrategy.refreshRetryTokenForRetry(token, errorInfo)).rejects.toMatchObject({
309+
message: "No retry token available",
310+
$backoff: 50, // b=1 * min(50 * 2^0, 20000) = 50
311+
});
312+
});
313+
314+
it("throws with $backoff=0 for non-capacity code", async () => {
315+
vi.mocked(DefaultRetryToken).mockImplementation(
316+
() =>
317+
({
318+
getRetryCount: () => 0,
319+
getRetryCost: () => 0,
320+
getRetryDelay: () => 0,
321+
isLongPoll: () => true,
322+
}) as any
323+
);
324+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
325+
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
326+
await expect(
327+
retryStrategy.refreshRetryTokenForRetry(token, { errorType: "CLIENT_ERROR" } as RetryErrorInfo)
328+
).rejects.toMatchObject({
329+
message: "No retry token available",
330+
$backoff: 0,
331+
});
332+
});
333+
});
334+
});
142335
});

packages/util-retry/src/StandardRetryStrategy.ts

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ export type StandardRetryStrategyOptions = {
3131
backoff?: StandardRetryBackoffStrategy;
3232
};
3333

34+
/**
35+
* Reason for refusing to retry.
36+
* @internal
37+
*/
38+
const refusal = {
39+
/**
40+
* Error is not retryable via classification.
41+
*/
42+
incompatible: 1,
43+
/**
44+
* attempt count exhausted.
45+
*/
46+
attempts: 2,
47+
/**
48+
* capacity exhausted.
49+
*/
50+
capacity: 3,
51+
} as const;
52+
3453
/**
3554
* @public
3655
*/
@@ -70,9 +89,11 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
7089
): Promise<StandardRetryToken> {
7190
const maxAttempts = await this.getMaxAttempts();
7291

73-
const shouldRetry = this.shouldRetry(token, errorInfo, maxAttempts);
92+
const retryCode = this.retryCode(token, errorInfo, maxAttempts);
93+
const shouldRetry = retryCode === 0;
94+
const isLongPoll = token.isLongPoll?.();
7495

75-
if (shouldRetry || token.isLongPoll?.()) {
96+
if (shouldRetry || isLongPoll) {
7697
const errorType = errorInfo.errorType;
7798
this.retryBackoffStrategy.setDelayBase(errorType === "THROTTLING" ? Retry.throttlingDelay() : this.baseDelay);
7899

@@ -86,8 +107,15 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
86107
);
87108
}
88109

89-
if (!shouldRetry /* implies long poll */) {
90-
throw Object.assign(new Error("No retry token available"), { $backoff: Retry.v2026 ? retryDelay : 0 });
110+
if (!shouldRetry) {
111+
/**
112+
* We only apply additional backoff if `isLongPoll` and the retryCode=3 indicates
113+
* that capacity is exhausted. Running out of attempts or having a
114+
* non-retryable error does *not* apply backoff.
115+
*/
116+
throw Object.assign(new Error("No retry token available"), {
117+
$backoff: Retry.v2026 && retryCode === refusal.capacity && isLongPoll ? retryDelay : 0,
118+
});
91119
} else {
92120
const capacityCost = this.getCapacityCost(errorType);
93121
this.capacity -= capacityCost;
@@ -116,6 +144,14 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
116144
return this.capacity;
117145
}
118146

147+
/**
148+
* There is an existing integration which accesses this field.
149+
* @deprecated
150+
*/
151+
public async maxAttempts(): Promise<number> {
152+
return this.maxAttemptsProvider();
153+
}
154+
119155
private async getMaxAttempts() {
120156
try {
121157
return await this.maxAttemptsProvider();
@@ -125,14 +161,26 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
125161
}
126162
}
127163

128-
private shouldRetry(tokenToRenew: StandardRetryToken, errorInfo: RetryErrorInfo, maxAttempts: number): boolean {
164+
/**
165+
* 0 - OK to retry.
166+
* 1 - error is not classified as retryable.
167+
* 2 - attempt count exhausted.
168+
* 3 - no capacity left (retry tokens exhausted).
169+
*
170+
* @returns 0 or the number of the highest priority (lowest integer) reason why retry is not possible.
171+
*/
172+
private retryCode(
173+
tokenToRenew: StandardRetryToken,
174+
errorInfo: RetryErrorInfo,
175+
maxAttempts: number
176+
): 0 | (typeof refusal)[keyof typeof refusal] {
129177
const attempts = tokenToRenew.getRetryCount() + 1;
130178

131-
return (
132-
/* has attempt remaining */ attempts < maxAttempts &&
133-
/* has capacity */ this.capacity >= this.getCapacityCost(errorInfo.errorType) &&
134-
/* and is retryable classification */ this.isRetryableError(errorInfo.errorType)
135-
);
179+
const retryableStatus = this.isRetryableError(errorInfo.errorType) ? 0 : refusal.incompatible;
180+
const attemptStatus = attempts < maxAttempts ? 0 : refusal.attempts;
181+
const capacityStatus = this.capacity >= this.getCapacityCost(errorInfo.errorType) ? 0 : refusal.capacity;
182+
183+
return retryableStatus || attemptStatus || capacityStatus;
136184
}
137185

138186
private getCapacityCost(errorType: RetryErrorType) {
@@ -142,12 +190,4 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
142190
private isRetryableError(errorType: RetryErrorType): boolean {
143191
return errorType === "THROTTLING" || errorType === "TRANSIENT";
144192
}
145-
146-
/**
147-
* There is an existing integration which accesses this field.
148-
* @deprecated
149-
*/
150-
public async maxAttempts(): Promise<number> {
151-
return this.maxAttemptsProvider();
152-
}
153193
}

0 commit comments

Comments
 (0)