Skip to content

Commit d666720

Browse files
kuhetrivikr
andauthored
fix(util-retry): make standard retry tokens immutable (#4755)
* fix(util-retry): use token instances with shared token availability * fix(util-retry): remove mutations in standard retry tokens * fix(util-retry): make standard retry token immutable, deprecate some methods on standard retry token * fix(util-retry): remove deprecated parts of StandardRetryToken * chore: remove redundant variables retryCost and timeoutRetryCost * fix(util-retry): packages/util-retry/src/StandardRetryStrategy.ts Co-authored-by: Trivikram Kamat <[email protected]> * fix(util-retry): remove unnecessary input --------- Co-authored-by: Trivikram Kamat <[email protected]>
1 parent 8874468 commit d666720

File tree

9 files changed

+164
-445
lines changed

9 files changed

+164
-445
lines changed

packages/middleware-retry/src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SdkError } from "@aws-sdk/types";
44
* Determines whether an error is retryable based on the number of retries
55
* already attempted, the HTTP status code, and the error received (if any).
66
*
7-
* @param error The error encountered.
7+
* @param error - The error encountered.
88
*/
99
export interface RetryDecider {
1010
(error: SdkError): boolean;
@@ -13,8 +13,8 @@ export interface RetryDecider {
1313
/**
1414
* Determines the number of milliseconds to wait before retrying an action.
1515
*
16-
* @param delayBase The base delay (in milliseconds).
17-
* @param attempts The number of times the action has already been tried.
16+
* @param delayBase - The base delay (in milliseconds).
17+
* @param attempts - The number of times the action has already been tried.
1818
*/
1919
export interface DelayDecider {
2020
(delayBase: number, attempts: number): number;

packages/types/src/retry.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -95,26 +95,9 @@ export interface RetryToken {
9595
*/
9696
export interface StandardRetryToken extends RetryToken {
9797
/**
98-
* @returns wheather token has remaining tokens.
98+
* @returns the cost of the last retry attempt.
9999
*/
100-
hasRetryTokens(errorType: RetryErrorType): boolean;
101-
102-
/**
103-
* @returns the number of available tokens.
104-
*/
105-
getRetryTokenCount(errorInfo: RetryErrorInfo): number;
106-
107-
/**
108-
* @returns the cost of the last retry attemp.
109-
*/
110-
getLastRetryCost(): number | undefined;
111-
112-
/**
113-
* Releases a number of tokens.
114-
*
115-
* @param amount - of tokens to release.
116-
*/
117-
releaseRetryTokens(amount?: number): void;
100+
getRetryCost(): number | undefined;
118101
}
119102

120103
/**

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RetryErrorInfo, RetryErrorType, StandardRetryToken } from "@aws-sdk/types";
1+
import { RetryErrorInfo, StandardRetryToken } from "@aws-sdk/types";
22

33
import { AdaptiveRetryStrategy } from "./AdaptiveRetryStrategy";
44
import { RETRY_MODES } from "./config";
@@ -17,10 +17,7 @@ describe(AdaptiveRetryStrategy.name, () => {
1717
updateClientSendingRate: jest.fn(),
1818
};
1919
const mockRetryToken: StandardRetryToken = {
20-
hasRetryTokens: (errorType: RetryErrorType) => true,
21-
getLastRetryCost: () => 1,
22-
getRetryTokenCount: (errorInfo: RetryErrorInfo) => 1,
23-
releaseRetryTokens: (amount: number) => {},
20+
getRetryCost: () => 1,
2421
getRetryCount: () => 1,
2522
getRetryDelay: () => 1,
2623
};

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe(ConfiguredRetryStrategy.name, () => {
1111
errorType: "TRANSIENT",
1212
});
1313

14-
expect(retryToken.getRetryCount()).toBe(4);
15-
expect(retryToken.getRetryDelay()).toBe(4000);
14+
expect(retryToken.getRetryCount()).toBe(5);
15+
expect(retryToken.getRetryDelay()).toBe(5000);
1616
});
1717
});

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

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { RetryErrorInfo, RetryErrorType } from "@aws-sdk/types";
22

33
import { RETRY_MODES } from "./config";
44
import { DEFAULT_RETRY_DELAY_BASE, INITIAL_RETRY_TOKENS } from "./constants";
5-
import { getDefaultRetryToken } from "./defaultRetryToken";
5+
import { createDefaultRetryToken } from "./defaultRetryToken";
66
import { StandardRetryStrategy } from "./StandardRetryStrategy";
77

88
jest.mock("./defaultRetryToken");
@@ -18,7 +18,7 @@ describe(StandardRetryStrategy.name, () => {
1818
const errorInfo = { errorType: "TRANSIENT" } as RetryErrorInfo;
1919

2020
beforeEach(() => {
21-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
21+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
2222
});
2323

2424
afterEach(() => {
@@ -37,48 +37,40 @@ describe(StandardRetryStrategy.name, () => {
3737
expect(retryStrategy.mode).toStrictEqual(RETRY_MODES.STANDARD);
3838
});
3939

40-
describe("retryToken init", () => {
41-
it("sets retryToken", () => {
42-
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
43-
expect(retryStrategy["retryToken"]).toBe(getDefaultRetryToken(INITIAL_RETRY_TOKENS, DEFAULT_RETRY_DELAY_BASE));
44-
});
45-
});
46-
4740
describe("acquireInitialRetryToken", () => {
4841
it("returns default retryToken", async () => {
4942
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
5043
const retryToken = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
51-
expect(retryToken).toEqual(getDefaultRetryToken(INITIAL_RETRY_TOKENS, DEFAULT_RETRY_DELAY_BASE));
44+
expect(retryToken).toEqual(
45+
createDefaultRetryToken({
46+
retryDelay: DEFAULT_RETRY_DELAY_BASE,
47+
retryCount: 0,
48+
})
49+
);
5250
});
5351
});
5452

5553
describe("refreshRetryTokenForRetry", () => {
5654
it("refreshes the token", async () => {
57-
const getRetryTokenCount = jest.fn().mockReturnValue(1);
5855
const getRetryCount = jest.fn().mockReturnValue(0);
5956
const hasRetryTokens = jest.fn().mockReturnValue(true);
6057
const mockRetryToken = {
6158
getRetryCount,
62-
getRetryTokenCount,
6359
hasRetryTokens,
6460
};
65-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
61+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
6662
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
6763
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
68-
const refreshedToken = await retryStrategy.refreshRetryTokenForRetry(token, errorInfo);
69-
expect(getRetryTokenCount).toHaveBeenCalledTimes(1);
70-
expect(getRetryTokenCount).toHaveBeenCalledWith(errorInfo);
71-
expect(getRetryCount).toHaveBeenCalledTimes(1);
72-
expect(hasRetryTokens).toHaveBeenCalledTimes(1);
73-
expect(hasRetryTokens).toHaveBeenCalledWith(errorInfo.errorType);
64+
await retryStrategy.refreshRetryTokenForRetry(token, errorInfo);
65+
expect(getRetryCount).toHaveBeenCalledTimes(3);
7466
});
7567

7668
it("throws when attempts exceeds maxAttempts", async () => {
7769
const mockRetryToken = {
7870
getRetryCount: () => 2,
7971
getRetryTokenCount: (errorInfo: any) => 1,
8072
};
81-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
73+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
8274
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(1));
8375
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
8476
try {
@@ -93,7 +85,7 @@ describe(StandardRetryStrategy.name, () => {
9385
getRetryCount: () => 5,
9486
getRetryTokenCount: (errorInfo: any) => 1,
9587
};
96-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
88+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
9789
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(5));
9890
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
9991
try {
@@ -109,7 +101,7 @@ describe(StandardRetryStrategy.name, () => {
109101
getRetryTokenCount: (errorInfo: any) => 1,
110102
hasRetryTokens: (errorType: RetryErrorType) => false,
111103
};
112-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
104+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
113105
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
114106
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
115107
try {
@@ -125,7 +117,7 @@ describe(StandardRetryStrategy.name, () => {
125117
getRetryTokenCount: (errorInfo: any) => 1,
126118
hasRetryTokens: (errorType: RetryErrorType) => true,
127119
};
128-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
120+
(createDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
129121
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
130122
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
131123
const errorInfo = {
@@ -137,24 +129,5 @@ describe(StandardRetryStrategy.name, () => {
137129
expect(error).toStrictEqual(noRetryTokenAvailableError);
138130
}
139131
});
140-
141-
describe("recordSuccess", () => {
142-
it("releases tokens", async () => {
143-
const retryCost = 1;
144-
const releaseRetryTokens = jest.fn();
145-
const getLastRetryCost = jest.fn().mockReturnValue(retryCost);
146-
const mockRetryToken = {
147-
releaseRetryTokens,
148-
getLastRetryCost,
149-
};
150-
(getDefaultRetryToken as jest.Mock).mockReturnValue(mockRetryToken);
151-
const retryStrategy = new StandardRetryStrategy(() => Promise.resolve(maxAttempts));
152-
const token = await retryStrategy.acquireInitialRetryToken(retryTokenScope);
153-
retryStrategy.recordSuccess(token);
154-
expect(releaseRetryTokens).toHaveBeenCalledTimes(1);
155-
expect(releaseRetryTokens).toHaveBeenCalledWith(retryCost);
156-
expect(getLastRetryCost).toHaveBeenCalledTimes(1);
157-
});
158-
});
159132
});
160133
});

packages/util-retry/src/StandardRetryStrategy.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,82 @@
11
import { Provider, RetryErrorInfo, RetryErrorType, RetryStrategyV2, StandardRetryToken } from "@aws-sdk/types";
22

33
import { DEFAULT_MAX_ATTEMPTS, RETRY_MODES } from "./config";
4-
import { DEFAULT_RETRY_DELAY_BASE, INITIAL_RETRY_TOKENS } from "./constants";
5-
import { getDefaultRetryToken } from "./defaultRetryToken";
4+
import {
5+
DEFAULT_RETRY_DELAY_BASE,
6+
INITIAL_RETRY_TOKENS,
7+
NO_RETRY_INCREMENT,
8+
RETRY_COST,
9+
THROTTLING_RETRY_DELAY_BASE,
10+
TIMEOUT_RETRY_COST,
11+
} from "./constants";
12+
import { getDefaultRetryBackoffStrategy } from "./defaultRetryBackoffStrategy";
13+
import { createDefaultRetryToken } from "./defaultRetryToken";
614

715
/**
816
* @public
917
*/
1018
export class StandardRetryStrategy implements RetryStrategyV2 {
1119
public readonly mode: string = RETRY_MODES.STANDARD;
12-
private retryToken: StandardRetryToken;
20+
private capacity: number = INITIAL_RETRY_TOKENS;
21+
private readonly retryBackoffStrategy = getDefaultRetryBackoffStrategy();
1322
private readonly maxAttemptsProvider: Provider<number>;
1423

1524
constructor(maxAttempts: number);
1625
constructor(maxAttemptsProvider: Provider<number>);
1726
constructor(private readonly maxAttempts: number | Provider<number>) {
18-
this.retryToken = getDefaultRetryToken(INITIAL_RETRY_TOKENS, DEFAULT_RETRY_DELAY_BASE);
1927
this.maxAttemptsProvider = typeof maxAttempts === "function" ? maxAttempts : async () => maxAttempts;
2028
}
2129

2230
public async acquireInitialRetryToken(retryTokenScope: string): Promise<StandardRetryToken> {
23-
return this.retryToken;
31+
return createDefaultRetryToken({
32+
retryDelay: DEFAULT_RETRY_DELAY_BASE,
33+
retryCount: 0,
34+
});
2435
}
2536

2637
public async refreshRetryTokenForRetry(
27-
tokenToRenew: StandardRetryToken,
38+
token: StandardRetryToken,
2839
errorInfo: RetryErrorInfo
2940
): Promise<StandardRetryToken> {
3041
const maxAttempts = await this.getMaxAttempts();
3142

32-
if (this.shouldRetry(tokenToRenew, errorInfo, maxAttempts)) {
33-
tokenToRenew.getRetryTokenCount(errorInfo);
34-
return tokenToRenew;
43+
if (this.shouldRetry(token, errorInfo, maxAttempts)) {
44+
const errorType = errorInfo.errorType;
45+
this.retryBackoffStrategy.setDelayBase(
46+
errorType === "THROTTLING" ? THROTTLING_RETRY_DELAY_BASE : DEFAULT_RETRY_DELAY_BASE
47+
);
48+
49+
const delayFromErrorType = this.retryBackoffStrategy.computeNextBackoffDelay(token.getRetryCount());
50+
const retryDelay = errorInfo.retryAfterHint
51+
? Math.max(errorInfo.retryAfterHint.getTime() - Date.now() || 0, delayFromErrorType)
52+
: delayFromErrorType;
53+
54+
const capacityCost = this.getCapacityCost(errorType);
55+
this.capacity -= capacityCost;
56+
return createDefaultRetryToken({
57+
retryDelay,
58+
retryCount: token.getRetryCount() + 1,
59+
retryCost: capacityCost,
60+
});
3561
}
62+
3663
throw new Error("No retry token available");
3764
}
3865

3966
public recordSuccess(token: StandardRetryToken): void {
40-
this.retryToken.releaseRetryTokens(token.getLastRetryCost());
67+
this.capacity = Math.max(INITIAL_RETRY_TOKENS, this.capacity + (token.getRetryCost() ?? NO_RETRY_INCREMENT));
68+
}
69+
70+
/**
71+
* @returns the current available retry capacity.
72+
*
73+
* This number decreases when retries are executed and refills when requests or retries succeed.
74+
*/
75+
public getCapacity(): number {
76+
return this.capacity;
4177
}
4278

4379
private async getMaxAttempts() {
44-
let maxAttempts: number;
4580
try {
4681
return await this.maxAttemptsProvider();
4782
} catch (error) {
@@ -52,13 +87,18 @@ export class StandardRetryStrategy implements RetryStrategyV2 {
5287

5388
private shouldRetry(tokenToRenew: StandardRetryToken, errorInfo: RetryErrorInfo, maxAttempts: number): boolean {
5489
const attempts = tokenToRenew.getRetryCount();
90+
5591
return (
5692
attempts < maxAttempts &&
57-
tokenToRenew.hasRetryTokens(errorInfo.errorType) &&
93+
this.capacity >= this.getCapacityCost(errorInfo.errorType) &&
5894
this.isRetryableError(errorInfo.errorType)
5995
);
6096
}
6197

98+
private getCapacityCost(errorType: RetryErrorType) {
99+
return errorType === "TRANSIENT" ? TIMEOUT_RETRY_COST : RETRY_COST;
100+
}
101+
62102
private isRetryableError(errorType: RetryErrorType): boolean {
63103
return errorType === "THROTTLING" || errorType === "TRANSIENT";
64104
}

0 commit comments

Comments
 (0)