Skip to content

Commit d055da7

Browse files
authored
feat: add retry informational headers (#1266)
1 parent ddfaabc commit d055da7

File tree

4 files changed

+186
-10
lines changed

4 files changed

+186
-10
lines changed

packages/middleware-retry/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
},
1515
"license": "Apache-2.0",
1616
"dependencies": {
17+
"@aws-sdk/protocol-http": "1.0.0-gamma.1",
1718
"@aws-sdk/service-error-classification": "1.0.0-gamma.1",
1819
"@aws-sdk/types": "1.0.0-gamma.1",
19-
"tslib": "^1.8.0"
20+
"react-native-get-random-values": "^1.4.0",
21+
"tslib": "^1.8.0",
22+
"uuid": "^8.0.0"
2023
},
2124
"devDependencies": {
22-
"@aws-sdk/protocol-http": "1.0.0-gamma.1",
2325
"@aws-sdk/smithy-client": "1.0.0-gamma.1",
2426
"@types/jest": "^25.1.4",
2527
"jest": "^25.1.0",

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

+162-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { defaultDelayDecider } from "./delayDecider";
88
import { defaultRetryDecider } from "./retryDecider";
99
import { StandardRetryStrategy, RetryQuota } from "./defaultStrategy";
1010
import { getDefaultRetryQuota } from "./defaultRetryQuota";
11+
import { HttpRequest } from "@aws-sdk/protocol-http";
12+
import { v4 } from "uuid";
1113

1214
jest.mock("@aws-sdk/service-error-classification", () => ({
1315
isThrottlingError: jest.fn().mockReturnValue(true)
@@ -30,32 +32,43 @@ jest.mock("./defaultRetryQuota", () => {
3032
return { getDefaultRetryQuota: () => mockDefaultRetryQuota };
3133
});
3234

35+
jest.mock("@aws-sdk/protocol-http", () => ({
36+
HttpRequest: {
37+
isInstance: jest.fn().mockReturnValue(false)
38+
}
39+
}));
40+
41+
jest.mock("uuid", () => ({
42+
v4: jest.fn(() => "42")
43+
}));
44+
3345
describe("defaultStrategy", () => {
46+
let next: jest.Mock; // variable for next mock function in utility methods
3447
const maxAttempts = 3;
3548

3649
const mockSuccessfulOperation = (
3750
maxAttempts: number,
3851
options?: { mockResponse?: string }
3952
) => {
40-
const next = jest.fn().mockResolvedValueOnce({
53+
next = jest.fn().mockResolvedValueOnce({
4154
response: options?.mockResponse,
4255
output: { $metadata: {} }
4356
});
4457

4558
const retryStrategy = new StandardRetryStrategy(maxAttempts);
46-
return retryStrategy.retry(next, {} as any);
59+
return retryStrategy.retry(next, { request: { headers: {} } } as any);
4760
};
4861

4962
const mockFailedOperation = async (
5063
maxAttempts: number,
5164
options?: { mockError?: Error }
5265
) => {
5366
const mockError = options?.mockError ?? new Error("mockError");
54-
const next = jest.fn().mockRejectedValue(mockError);
67+
next = jest.fn().mockRejectedValue(mockError);
5568

5669
const retryStrategy = new StandardRetryStrategy(maxAttempts);
5770
try {
58-
await retryStrategy.retry(next, {} as any);
71+
await retryStrategy.retry(next, { request: { headers: {} } } as any);
5972
} catch (error) {
6073
expect(error).toStrictEqual(mockError);
6174
return error;
@@ -72,13 +85,13 @@ describe("defaultStrategy", () => {
7285
output: { $metadata: {} }
7386
};
7487

75-
const next = jest
88+
next = jest
7689
.fn()
7790
.mockRejectedValueOnce(mockError)
7891
.mockResolvedValueOnce(mockResponse);
7992

8093
const retryStrategy = new StandardRetryStrategy(maxAttempts);
81-
return retryStrategy.retry(next, {} as any);
94+
return retryStrategy.retry(next, { request: { headers: {} } } as any);
8295
};
8396

8497
const mockSuccessAfterTwoFails = (
@@ -91,14 +104,14 @@ describe("defaultStrategy", () => {
91104
output: { $metadata: {} }
92105
};
93106

94-
const next = jest
107+
next = jest
95108
.fn()
96109
.mockRejectedValueOnce(mockError)
97110
.mockRejectedValueOnce(mockError)
98111
.mockResolvedValueOnce(mockResponse);
99112

100113
const retryStrategy = new StandardRetryStrategy(maxAttempts);
101-
return retryStrategy.retry(next, {} as any);
114+
return retryStrategy.retry(next, { request: { headers: {} } } as any);
102115
};
103116

104117
afterEach(() => {
@@ -423,4 +436,145 @@ describe("defaultStrategy", () => {
423436
});
424437
});
425438
});
439+
440+
describe("retry informational header: amz-sdk-invocation-id", () => {
441+
describe("not added if HttpRequest.isInstance returns false", () => {
442+
it("on successful operation", async () => {
443+
await mockSuccessfulOperation(maxAttempts);
444+
expect(next).toHaveBeenCalledTimes(1);
445+
expect(
446+
next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"]
447+
).not.toBeDefined();
448+
});
449+
450+
it("in case of single failure", async () => {
451+
await mockSuccessAfterOneFail(maxAttempts);
452+
expect(next).toHaveBeenCalledTimes(2);
453+
[0, 1].forEach(index => {
454+
expect(
455+
next.mock.calls[index][0].request.headers["amz-sdk-invocation-id"]
456+
).not.toBeDefined();
457+
});
458+
});
459+
460+
it("in case of all failures", async () => {
461+
await mockFailedOperation(maxAttempts);
462+
expect(next).toHaveBeenCalledTimes(maxAttempts);
463+
[...Array(maxAttempts).keys()].forEach(index => {
464+
expect(
465+
next.mock.calls[index][0].request.headers["amz-sdk-invocation-id"]
466+
).not.toBeDefined();
467+
});
468+
});
469+
});
470+
471+
it("uses a unique header for every SDK operation invocation", async () => {
472+
const { isInstance } = HttpRequest;
473+
((isInstance as unknown) as jest.Mock).mockReturnValue(true);
474+
475+
const uuidForInvocationOne = "uuid-invocation-1";
476+
const uuidForInvocationTwo = "uuid-invocation-2";
477+
(v4 as jest.Mock)
478+
.mockReturnValueOnce(uuidForInvocationOne)
479+
.mockReturnValueOnce(uuidForInvocationTwo);
480+
481+
const next = jest.fn().mockResolvedValue({
482+
response: "mockResponse",
483+
output: { $metadata: {} }
484+
});
485+
486+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
487+
await retryStrategy.retry(next, { request: { headers: {} } } as any);
488+
await retryStrategy.retry(next, { request: { headers: {} } } as any);
489+
490+
expect(next).toHaveBeenCalledTimes(2);
491+
expect(
492+
next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"]
493+
).toBe(uuidForInvocationOne);
494+
expect(
495+
next.mock.calls[1][0].request.headers["amz-sdk-invocation-id"]
496+
).toBe(uuidForInvocationTwo);
497+
498+
((isInstance as unknown) as jest.Mock).mockReturnValue(false);
499+
});
500+
501+
it("uses same value for additional HTTP requests associated with an SDK operation", async () => {
502+
const { isInstance } = HttpRequest;
503+
((isInstance as unknown) as jest.Mock).mockReturnValueOnce(true);
504+
505+
const uuidForInvocation = "uuid-invocation-1";
506+
(v4 as jest.Mock).mockReturnValueOnce(uuidForInvocation);
507+
508+
await mockSuccessAfterOneFail(maxAttempts);
509+
510+
expect(next).toHaveBeenCalledTimes(2);
511+
expect(
512+
next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"]
513+
).toBe(uuidForInvocation);
514+
expect(
515+
next.mock.calls[1][0].request.headers["amz-sdk-invocation-id"]
516+
).toBe(uuidForInvocation);
517+
518+
((isInstance as unknown) as jest.Mock).mockReturnValue(false);
519+
});
520+
});
521+
522+
describe("retry informational header: amz-sdk-request", () => {
523+
describe("not added if HttpRequest.isInstance returns false", () => {
524+
it("on successful operation", async () => {
525+
await mockSuccessfulOperation(maxAttempts);
526+
expect(next).toHaveBeenCalledTimes(1);
527+
expect(
528+
next.mock.calls[0][0].request.headers["amz-sdk-request"]
529+
).not.toBeDefined();
530+
});
531+
532+
it("in case of single failure", async () => {
533+
await mockSuccessAfterOneFail(maxAttempts);
534+
expect(next).toHaveBeenCalledTimes(2);
535+
[0, 1].forEach(index => {
536+
expect(
537+
next.mock.calls[index][0].request.headers["amz-sdk-request"]
538+
).not.toBeDefined();
539+
});
540+
});
541+
542+
it("in case of all failures", async () => {
543+
await mockFailedOperation(maxAttempts);
544+
expect(next).toHaveBeenCalledTimes(maxAttempts);
545+
[...Array(maxAttempts).keys()].forEach(index => {
546+
expect(
547+
next.mock.calls[index][0].request.headers["amz-sdk-request"]
548+
).not.toBeDefined();
549+
});
550+
});
551+
});
552+
553+
it("adds header for each attempt", async () => {
554+
const { isInstance } = HttpRequest;
555+
((isInstance as unknown) as jest.Mock).mockReturnValue(true);
556+
557+
const mockError = new Error("mockError");
558+
next = jest.fn(args => {
559+
// the header needs to be verified inside jest.Mock as arguments in
560+
// jest.mocks.calls has the value passed in final call
561+
const index = next.mock.calls.length - 1;
562+
expect(args.request.headers["amz-sdk-request"]).toBe(
563+
`attempt=${index + 1}; max=${maxAttempts}`
564+
);
565+
throw mockError;
566+
});
567+
568+
const retryStrategy = new StandardRetryStrategy(maxAttempts);
569+
try {
570+
await retryStrategy.retry(next, { request: { headers: {} } } as any);
571+
} catch (error) {
572+
expect(error).toStrictEqual(mockError);
573+
return error;
574+
}
575+
576+
expect(next).toHaveBeenCalledTimes(maxAttempts);
577+
((isInstance as unknown) as jest.Mock).mockReturnValue(false);
578+
});
579+
});
426580
});

packages/middleware-retry/src/defaultStrategy.ts

+13
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
RetryStrategy
1515
} from "@aws-sdk/types";
1616
import { getDefaultRetryQuota } from "./defaultRetryQuota";
17+
import { HttpRequest } from "@aws-sdk/protocol-http";
18+
import { v4 } from "uuid";
1719

1820
/**
1921
* Determines whether an error is retryable based on the number of retries
@@ -95,8 +97,19 @@ export class StandardRetryStrategy implements RetryStrategy {
9597
let retryTokenAmount;
9698
let attempts = 0;
9799
let totalDelay = 0;
100+
101+
const { request } = args;
102+
if (HttpRequest.isInstance(request)) {
103+
request.headers["amz-sdk-invocation-id"] = v4();
104+
}
105+
98106
while (true) {
99107
try {
108+
if (HttpRequest.isInstance(request)) {
109+
request.headers["amz-sdk-request"] = `attempt=${attempts + 1}; max=${
110+
this.maxAttempts
111+
}`;
112+
}
100113
const { response, output } = await next(args);
101114

102115
this.retryQuota.releaseRetryTokens(retryTokenAmount);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//reference: https://github.com/uuidjs/uuid#getrandomvalues-not-supported
2+
import "react-native-get-random-values";
3+
export * from "./retryMiddleware";
4+
export * from "./defaultStrategy";
5+
export * from "./configurations";
6+
export * from "./delayDecider";
7+
export * from "./retryDecider";

0 commit comments

Comments
 (0)