Skip to content

Commit 9524fa1

Browse files
authored
fix(middleware-retry): use delay from response header if it's higher (#3916)
1 parent ff037e0 commit 9524fa1

File tree

2 files changed

+119
-15
lines changed

2 files changed

+119
-15
lines changed

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

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HttpRequest } from "@aws-sdk/protocol-http";
1+
import { HttpRequest, HttpResponse } from "@aws-sdk/protocol-http";
22
import { isThrottlingError } from "@aws-sdk/service-error-classification";
33
import { v4 } from "uuid";
44

@@ -88,6 +88,9 @@ describe("defaultStrategy", () => {
8888
(HttpRequest as unknown as jest.Mock).mockReturnValue({
8989
isInstance: jest.fn().mockReturnValue(false),
9090
});
91+
(HttpResponse as unknown as jest.Mock).mockReturnValue({
92+
isInstance: jest.fn().mockReturnValue(false),
93+
});
9194
(v4 as jest.Mock).mockReturnValue("42");
9295
});
9396

@@ -220,22 +223,101 @@ describe("defaultStrategy", () => {
220223
});
221224
});
222225

223-
it("delay value returned", async () => {
224-
jest.spyOn(global, "setTimeout");
226+
describe("totalRetryDelay", () => {
227+
describe("when retry-after is not set", () => {
228+
it("should be equal to sum of values computed by delayDecider", async () => {
229+
jest.spyOn(global, "setTimeout");
230+
231+
const FIRST_DELAY = 100;
232+
const SECOND_DELAY = 200;
233+
234+
(defaultDelayDecider as jest.Mock).mockReturnValueOnce(FIRST_DELAY).mockReturnValueOnce(SECOND_DELAY);
235+
236+
const maxAttempts = 3;
237+
const error = await mockFailedOperation(maxAttempts);
238+
expect(error.$metadata.totalRetryDelay).toEqual(FIRST_DELAY + SECOND_DELAY);
239+
240+
expect(defaultDelayDecider as jest.Mock).toHaveBeenCalledTimes(maxAttempts - 1);
241+
expect(setTimeout).toHaveBeenCalledTimes(maxAttempts - 1);
242+
expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe(FIRST_DELAY);
243+
expect((setTimeout as unknown as jest.Mock).mock.calls[1][1]).toBe(SECOND_DELAY);
244+
});
245+
});
225246

226-
const FIRST_DELAY = 100;
227-
const SECOND_DELAY = 200;
247+
describe("when retry-after is set", () => {
248+
const getErrorWithValues = async (
249+
delayDeciderInMs: number,
250+
retryAfter: number | string,
251+
retryAfterHeaderName?: string
252+
) => {
253+
(defaultDelayDecider as jest.Mock).mockReturnValueOnce(delayDeciderInMs);
254+
255+
const maxAttempts = 2;
256+
const mockError = new Error();
257+
Object.defineProperty(mockError, "$response", {
258+
value: {
259+
headers: { [retryAfterHeaderName ? retryAfterHeaderName : "retry-after"]: String(retryAfter) },
260+
},
261+
});
262+
const error = await mockFailedOperation(maxAttempts, { mockError });
263+
expect(defaultDelayDecider as jest.Mock).toHaveBeenCalledTimes(maxAttempts - 1);
264+
expect(setTimeout).toHaveBeenCalledTimes(maxAttempts - 1);
265+
266+
return error;
267+
};
268+
269+
beforeEach(() => {
270+
jest.spyOn(global, "setTimeout");
271+
});
228272

229-
(defaultDelayDecider as jest.Mock).mockReturnValueOnce(FIRST_DELAY).mockReturnValueOnce(SECOND_DELAY);
273+
describe("uses retry-after value if it's greater than that from delayDecider", () => {
274+
beforeEach(() => {
275+
const { isInstance } = HttpResponse;
276+
(isInstance as unknown as jest.Mock).mockReturnValueOnce(true);
277+
});
278+
279+
describe("when value is in seconds", () => {
280+
const testWithHeaderName = async (retryAfterHeaderName: string) => {
281+
const delayDeciderInMs = 2000;
282+
const retryAfterInSeconds = 3;
283+
284+
const error = await getErrorWithValues(delayDeciderInMs, retryAfterInSeconds, retryAfterHeaderName);
285+
expect(error.$metadata.totalRetryDelay).toEqual(retryAfterInSeconds * 1000);
286+
expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe(retryAfterInSeconds * 1000);
287+
};
288+
289+
it("with header in small case", async () => {
290+
testWithHeaderName("retry-after");
291+
});
292+
293+
it("with header with first letter capital", async () => {
294+
testWithHeaderName("Retry-After");
295+
});
296+
});
297+
298+
it("when value is a Date", async () => {
299+
const mockDateNow = Date.now();
300+
jest.spyOn(Date, "now").mockReturnValue(mockDateNow);
301+
302+
const delayDeciderInMs = 2000;
303+
const retryAfterInSeconds = 3;
304+
const retryAfterDate = new Date(mockDateNow + retryAfterInSeconds * 1000);
305+
306+
const error = await getErrorWithValues(delayDeciderInMs, retryAfterDate.toISOString());
307+
expect(error.$metadata.totalRetryDelay).toEqual(retryAfterInSeconds * 1000);
308+
expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe(retryAfterInSeconds * 1000);
309+
});
310+
});
230311

231-
const maxAttempts = 3;
232-
const error = await mockFailedOperation(maxAttempts);
233-
expect(error.$metadata.totalRetryDelay).toEqual(FIRST_DELAY + SECOND_DELAY);
312+
it("ignores retry-after value if it's smaller than that from delayDecider", async () => {
313+
const delayDeciderInMs = 3000;
314+
const retryAfterInSeconds = 2;
234315

235-
expect(defaultDelayDecider as jest.Mock).toHaveBeenCalledTimes(maxAttempts - 1);
236-
expect(setTimeout).toHaveBeenCalledTimes(maxAttempts - 1);
237-
expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe(FIRST_DELAY);
238-
expect((setTimeout as unknown as jest.Mock).mock.calls[1][1]).toBe(SECOND_DELAY);
316+
const error = await getErrorWithValues(delayDeciderInMs, retryAfterInSeconds);
317+
expect(error.$metadata.totalRetryDelay).toEqual(delayDeciderInMs);
318+
expect((setTimeout as unknown as jest.Mock).mock.calls[0][1]).toBe(delayDeciderInMs);
319+
});
320+
});
239321
});
240322
});
241323

packages/middleware-retry/src/StandardRetryStrategy.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HttpRequest } from "@aws-sdk/protocol-http";
1+
import { HttpRequest, HttpResponse } from "@aws-sdk/protocol-http";
22
import { isThrottlingError } from "@aws-sdk/service-error-classification";
33
import { SdkError } from "@aws-sdk/types";
44
import { FinalizeHandler, FinalizeHandlerArguments, MetadataBearer, Provider, RetryStrategy } from "@aws-sdk/types";
@@ -95,10 +95,14 @@ export class StandardRetryStrategy implements RetryStrategy {
9595
attempts++;
9696
if (this.shouldRetry(err as SdkError, attempts, maxAttempts)) {
9797
retryTokenAmount = this.retryQuota.retrieveRetryTokens(err);
98-
const delay = this.delayDecider(
98+
const delayFromDecider = this.delayDecider(
9999
isThrottlingError(err) ? THROTTLING_RETRY_DELAY_BASE : DEFAULT_RETRY_DELAY_BASE,
100100
attempts
101101
);
102+
103+
const delayFromResponse = getDelayFromRetryAfterHeader(err.$response);
104+
const delay = Math.max(delayFromResponse || 0, delayFromDecider);
105+
102106
totalDelay += delay;
103107

104108
await new Promise((resolve) => setTimeout(resolve, delay));
@@ -117,6 +121,24 @@ export class StandardRetryStrategy implements RetryStrategy {
117121
}
118122
}
119123

124+
/**
125+
* Returns number of milliseconds to wait based on "Retry-After" header value.
126+
*/
127+
const getDelayFromRetryAfterHeader = (response: unknown): number | undefined => {
128+
if (!HttpResponse.isInstance(response)) return;
129+
130+
const retryAfterHeaderName = Object.keys(response.headers).find((key) => key.toLowerCase() === "retry-after");
131+
if (!retryAfterHeaderName) return;
132+
133+
const retryAfter = response.headers[retryAfterHeaderName];
134+
135+
const retryAfterSeconds = Number(retryAfter);
136+
if (!Number.isNaN(retryAfterSeconds)) return retryAfterSeconds * 1000;
137+
138+
const retryAfterDate = new Date(retryAfter);
139+
return retryAfterDate.getTime() - Date.now();
140+
};
141+
120142
const asSdkError = (error: unknown): SdkError => {
121143
if (error instanceof Error) return error;
122144
if (error instanceof Object) return Object.assign(new Error(), error);

0 commit comments

Comments
 (0)