diff --git a/packages/middleware-retry/package.json b/packages/middleware-retry/package.json index 663972a363ec..d46f3c183ad9 100644 --- a/packages/middleware-retry/package.json +++ b/packages/middleware-retry/package.json @@ -14,12 +14,13 @@ }, "license": "Apache-2.0", "dependencies": { + "@aws-sdk/protocol-http": "1.0.0-gamma.1", "@aws-sdk/service-error-classification": "1.0.0-gamma.1", "@aws-sdk/types": "1.0.0-gamma.1", - "tslib": "^1.8.0" + "tslib": "^1.8.0", + "uuid": "^8.0.0" }, "devDependencies": { - "@aws-sdk/protocol-http": "1.0.0-gamma.1", "@aws-sdk/smithy-client": "1.0.0-gamma.1", "@types/jest": "^25.1.4", "jest": "^25.1.0", diff --git a/packages/middleware-retry/src/defaultStrategy.spec.ts b/packages/middleware-retry/src/defaultStrategy.spec.ts index 8809f55b6dce..b7bd60575562 100644 --- a/packages/middleware-retry/src/defaultStrategy.spec.ts +++ b/packages/middleware-retry/src/defaultStrategy.spec.ts @@ -8,6 +8,8 @@ import { defaultDelayDecider } from "./delayDecider"; import { defaultRetryDecider } from "./retryDecider"; import { StandardRetryStrategy, RetryQuota } from "./defaultStrategy"; import { getDefaultRetryQuota } from "./defaultRetryQuota"; +import { HttpRequest } from "@aws-sdk/protocol-http"; +import { v4 } from "uuid"; jest.mock("@aws-sdk/service-error-classification", () => ({ isThrottlingError: jest.fn().mockReturnValue(true) @@ -30,20 +32,31 @@ jest.mock("./defaultRetryQuota", () => { return { getDefaultRetryQuota: () => mockDefaultRetryQuota }; }); +jest.mock("@aws-sdk/protocol-http", () => ({ + HttpRequest: { + isInstance: jest.fn().mockReturnValue(false) + } +})); + +jest.mock("uuid", () => ({ + v4: jest.fn(() => "42") +})); + describe("defaultStrategy", () => { + let next: jest.Mock; // variable for next mock function in utility methods const maxAttempts = 3; const mockSuccessfulOperation = ( maxAttempts: number, options?: { mockResponse?: string } ) => { - const next = jest.fn().mockResolvedValueOnce({ + next = jest.fn().mockResolvedValueOnce({ response: options?.mockResponse, output: { $metadata: {} } }); const retryStrategy = new StandardRetryStrategy(maxAttempts); - return retryStrategy.retry(next, {} as any); + return retryStrategy.retry(next, { request: { headers: {} } } as any); }; const mockFailedOperation = async ( @@ -51,11 +64,11 @@ describe("defaultStrategy", () => { options?: { mockError?: Error } ) => { const mockError = options?.mockError ?? new Error("mockError"); - const next = jest.fn().mockRejectedValue(mockError); + next = jest.fn().mockRejectedValue(mockError); const retryStrategy = new StandardRetryStrategy(maxAttempts); try { - await retryStrategy.retry(next, {} as any); + await retryStrategy.retry(next, { request: { headers: {} } } as any); } catch (error) { expect(error).toStrictEqual(mockError); return error; @@ -72,13 +85,13 @@ describe("defaultStrategy", () => { output: { $metadata: {} } }; - const next = jest + next = jest .fn() .mockRejectedValueOnce(mockError) .mockResolvedValueOnce(mockResponse); const retryStrategy = new StandardRetryStrategy(maxAttempts); - return retryStrategy.retry(next, {} as any); + return retryStrategy.retry(next, { request: { headers: {} } } as any); }; const mockSuccessAfterTwoFails = ( @@ -91,14 +104,14 @@ describe("defaultStrategy", () => { output: { $metadata: {} } }; - const next = jest + next = jest .fn() .mockRejectedValueOnce(mockError) .mockRejectedValueOnce(mockError) .mockResolvedValueOnce(mockResponse); const retryStrategy = new StandardRetryStrategy(maxAttempts); - return retryStrategy.retry(next, {} as any); + return retryStrategy.retry(next, { request: { headers: {} } } as any); }; afterEach(() => { @@ -423,4 +436,145 @@ describe("defaultStrategy", () => { }); }); }); + + describe("retry informational header: amz-sdk-invocation-id", () => { + describe("not added if HttpRequest.isInstance returns false", () => { + it("on successful operation", async () => { + await mockSuccessfulOperation(maxAttempts); + expect(next).toHaveBeenCalledTimes(1); + expect( + next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"] + ).not.toBeDefined(); + }); + + it("in case of single failure", async () => { + await mockSuccessAfterOneFail(maxAttempts); + expect(next).toHaveBeenCalledTimes(2); + [0, 1].forEach(index => { + expect( + next.mock.calls[index][0].request.headers["amz-sdk-invocation-id"] + ).not.toBeDefined(); + }); + }); + + it("in case of all failures", async () => { + await mockFailedOperation(maxAttempts); + expect(next).toHaveBeenCalledTimes(maxAttempts); + [...Array(maxAttempts).keys()].forEach(index => { + expect( + next.mock.calls[index][0].request.headers["amz-sdk-invocation-id"] + ).not.toBeDefined(); + }); + }); + }); + + it("uses a unique header for every SDK operation invocation", async () => { + const { isInstance } = HttpRequest; + ((isInstance as unknown) as jest.Mock).mockReturnValue(true); + + const uuidForInvocationOne = "uuid-invocation-1"; + const uuidForInvocationTwo = "uuid-invocation-2"; + (v4 as jest.Mock) + .mockReturnValueOnce(uuidForInvocationOne) + .mockReturnValueOnce(uuidForInvocationTwo); + + const next = jest.fn().mockResolvedValue({ + response: "mockResponse", + output: { $metadata: {} } + }); + + const retryStrategy = new StandardRetryStrategy(maxAttempts); + await retryStrategy.retry(next, { request: { headers: {} } } as any); + await retryStrategy.retry(next, { request: { headers: {} } } as any); + + expect(next).toHaveBeenCalledTimes(2); + expect( + next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"] + ).toBe(uuidForInvocationOne); + expect( + next.mock.calls[1][0].request.headers["amz-sdk-invocation-id"] + ).toBe(uuidForInvocationTwo); + + ((isInstance as unknown) as jest.Mock).mockReturnValue(false); + }); + + it("uses same value for additional HTTP requests associated with an SDK operation", async () => { + const { isInstance } = HttpRequest; + ((isInstance as unknown) as jest.Mock).mockReturnValueOnce(true); + + const uuidForInvocation = "uuid-invocation-1"; + (v4 as jest.Mock).mockReturnValueOnce(uuidForInvocation); + + await mockSuccessAfterOneFail(maxAttempts); + + expect(next).toHaveBeenCalledTimes(2); + expect( + next.mock.calls[0][0].request.headers["amz-sdk-invocation-id"] + ).toBe(uuidForInvocation); + expect( + next.mock.calls[1][0].request.headers["amz-sdk-invocation-id"] + ).toBe(uuidForInvocation); + + ((isInstance as unknown) as jest.Mock).mockReturnValue(false); + }); + }); + + describe("retry informational header: amz-sdk-request", () => { + describe("not added if HttpRequest.isInstance returns false", () => { + it("on successful operation", async () => { + await mockSuccessfulOperation(maxAttempts); + expect(next).toHaveBeenCalledTimes(1); + expect( + next.mock.calls[0][0].request.headers["amz-sdk-request"] + ).not.toBeDefined(); + }); + + it("in case of single failure", async () => { + await mockSuccessAfterOneFail(maxAttempts); + expect(next).toHaveBeenCalledTimes(2); + [0, 1].forEach(index => { + expect( + next.mock.calls[index][0].request.headers["amz-sdk-request"] + ).not.toBeDefined(); + }); + }); + + it("in case of all failures", async () => { + await mockFailedOperation(maxAttempts); + expect(next).toHaveBeenCalledTimes(maxAttempts); + [...Array(maxAttempts).keys()].forEach(index => { + expect( + next.mock.calls[index][0].request.headers["amz-sdk-request"] + ).not.toBeDefined(); + }); + }); + }); + + it("adds header for each attempt", async () => { + const { isInstance } = HttpRequest; + ((isInstance as unknown) as jest.Mock).mockReturnValue(true); + + const mockError = new Error("mockError"); + next = jest.fn(args => { + // the header needs to be verified inside jest.Mock as arguments in + // jest.mocks.calls has the value passed in final call + const index = next.mock.calls.length - 1; + expect(args.request.headers["amz-sdk-request"]).toBe( + `attempt=${index + 1}; max=${maxAttempts}` + ); + throw mockError; + }); + + const retryStrategy = new StandardRetryStrategy(maxAttempts); + try { + await retryStrategy.retry(next, { request: { headers: {} } } as any); + } catch (error) { + expect(error).toStrictEqual(mockError); + return error; + } + + expect(next).toHaveBeenCalledTimes(maxAttempts); + ((isInstance as unknown) as jest.Mock).mockReturnValue(false); + }); + }); }); diff --git a/packages/middleware-retry/src/defaultStrategy.ts b/packages/middleware-retry/src/defaultStrategy.ts index cb8c242d13a9..3271e6f31183 100644 --- a/packages/middleware-retry/src/defaultStrategy.ts +++ b/packages/middleware-retry/src/defaultStrategy.ts @@ -14,6 +14,8 @@ import { RetryStrategy } from "@aws-sdk/types"; import { getDefaultRetryQuota } from "./defaultRetryQuota"; +import { HttpRequest } from "@aws-sdk/protocol-http"; +import { v4 } from "uuid"; /** * Determines whether an error is retryable based on the number of retries @@ -95,8 +97,19 @@ export class StandardRetryStrategy implements RetryStrategy { let retryTokenAmount; let attempts = 0; let totalDelay = 0; + + const { request } = args; + if (HttpRequest.isInstance(request)) { + request.headers["amz-sdk-invocation-id"] = v4(); + } + while (true) { try { + if (HttpRequest.isInstance(request)) { + request.headers["amz-sdk-request"] = `attempt=${attempts + 1}; max=${ + this.maxAttempts + }`; + } const { response, output } = await next(args); this.retryQuota.releaseRetryTokens(retryTokenAmount);