diff --git a/.changeset/mean-paws-check.md b/.changeset/mean-paws-check.md new file mode 100644 index 00000000000..59c49d6a4f8 --- /dev/null +++ b/.changeset/mean-paws-check.md @@ -0,0 +1,6 @@ +--- +"@smithy/service-error-classification": patch +"@smithy/util-retry": patch +--- + +make $retryable-trait errors considered transient in StandardRetryStrategyV2 diff --git a/packages/middleware-apply-body-checksum/src/middleware-apply-body-checksum.integ.spec.ts b/packages/middleware-apply-body-checksum/src/middleware-apply-body-checksum.integ.spec.ts index 449049e4894..9bdcf5edd44 100644 --- a/packages/middleware-apply-body-checksum/src/middleware-apply-body-checksum.integ.spec.ts +++ b/packages/middleware-apply-body-checksum/src/middleware-apply-body-checksum.integ.spec.ts @@ -6,7 +6,14 @@ import { requireRequestsFrom } from "../../../private/util-test/src/index"; describe("middleware-apply-body-checksum", () => { describe(Weather.name, () => { it("should add body-checksum", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ headers: { "content-md5": /^.{22}(==)?$/i, diff --git a/packages/middleware-content-length/src/middleware-content-length.integ.spec.ts b/packages/middleware-content-length/src/middleware-content-length.integ.spec.ts index f5ccc2b0bf7..487e5abda49 100644 --- a/packages/middleware-content-length/src/middleware-content-length.integ.spec.ts +++ b/packages/middleware-content-length/src/middleware-content-length.integ.spec.ts @@ -6,7 +6,14 @@ import { requireRequestsFrom } from "../../../private/util-test/src/index"; describe("middleware-content-length", () => { describe(Weather.name, () => { it("should not add content-length if no body", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ headers: { "content-length": /undefined/, @@ -24,7 +31,14 @@ describe("middleware-content-length", () => { // This tests that content-length gets set to `2`, only where bodies are // sent in the request. it("should add content-length if body present", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ headers: { "content-length": /2/, diff --git a/packages/middleware-retry/src/middleware-retry.integ.spec.ts b/packages/middleware-retry/src/middleware-retry.integ.spec.ts index f2c0d368e61..d22a529ca62 100644 --- a/packages/middleware-retry/src/middleware-retry.integ.spec.ts +++ b/packages/middleware-retry/src/middleware-retry.integ.spec.ts @@ -6,7 +6,14 @@ import { requireRequestsFrom } from "../../../private/util-test/src/index"; describe("middleware-retry", () => { describe(Weather.name, () => { it("should set retry headers", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ hostname: "foo.bar", diff --git a/packages/middleware-serde/src/middleware-serde.integ.spec.ts b/packages/middleware-serde/src/middleware-serde.integ.spec.ts index 5e60de61afb..06549185864 100644 --- a/packages/middleware-serde/src/middleware-serde.integ.spec.ts +++ b/packages/middleware-serde/src/middleware-serde.integ.spec.ts @@ -6,7 +6,14 @@ import { requireRequestsFrom } from "../../../private/util-test/src/index"; describe("middleware-serde", () => { describe(Weather.name, () => { it("should serialize TestProtocol", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ method: "PUT", hostname: "foo.bar", diff --git a/packages/service-error-classification/src/index.ts b/packages/service-error-classification/src/index.ts index d4a98484c21..3d286509342 100644 --- a/packages/service-error-classification/src/index.ts +++ b/packages/service-error-classification/src/index.ts @@ -9,7 +9,7 @@ import { TRANSIENT_ERROR_STATUS_CODES, } from "./constants"; -export const isRetryableByTrait = (error: SdkError) => error.$retryable !== undefined; +export const isRetryableByTrait = (error: SdkError) => error?.$retryable !== undefined; /** * @deprecated use isClockSkewCorrectedError. This is only used in deprecated code. @@ -55,6 +55,7 @@ export const isThrottlingError = (error: SdkError) => * the name "TimeoutError" to be checked by the TRANSIENT_ERROR_CODES condition. */ export const isTransientError = (error: SdkError, depth = 0): boolean => + isRetryableByTrait(error) || isClockSkewCorrectedError(error) || TRANSIENT_ERROR_CODES.includes(error.name) || NODEJS_TIMEOUT_ERROR_CODES.includes((error as { code?: string })?.code || "") || diff --git a/packages/util-retry/package.json b/packages/util-retry/package.json index 16ef9b665f0..756e67c20dd 100644 --- a/packages/util-retry/package.json +++ b/packages/util-retry/package.json @@ -16,7 +16,9 @@ "format": "prettier --config ../../prettier.config.js --ignore-path ../../.prettierignore --write \"**/*.{ts,md,json}\"", "extract:docs": "api-extractor run --local", "test": "yarn g:vitest run", - "test:watch": "yarn g:vitest watch" + "test:watch": "yarn g:vitest watch", + "test:integration": "yarn g:vitest run -c vitest.config.integ.mts", + "test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.mts" }, "keywords": [ "aws", diff --git a/packages/util-retry/src/retries.integ.spec.ts b/packages/util-retry/src/retries.integ.spec.ts new file mode 100644 index 00000000000..f5d37cef458 --- /dev/null +++ b/packages/util-retry/src/retries.integ.spec.ts @@ -0,0 +1,140 @@ +import { cbor } from "@smithy/core/cbor"; +import { HttpResponse } from "@smithy/protocol-http"; +import { requireRequestsFrom } from "@smithy/util-test/src"; +import { Readable } from "node:stream"; +import { describe, expect, test as it } from "vitest"; +import { XYZService } from "xyz"; + +describe("retries", () => { + function createCborResponse(body: any, status = 200) { + const bytes = cbor.serialize(body); + return new HttpResponse({ + headers: { + "smithy-protocol": "rpc-v2-cbor", + }, + body: Readable.from(bytes), + statusCode: status, + }); + } + + it("should retry throttling and transient-error status codes", async () => { + const client = new XYZService({ + endpoint: "https://localhost/nowhere", + }); + + requireRequestsFrom(client) + .toMatch({ + hostname: /localhost/, + }) + .respondWith( + createCborResponse( + { + __type: "HaltError", + }, + 429 + ), + createCborResponse( + { + __type: "HaltError", + }, + 500 + ), + createCborResponse("", 200) + ); + + const response = await client.getNumbers().catch((e) => e); + + expect(response.$metadata.attempts).toEqual(3); + }); + + it("should retry when a retryable trait is modeled", async () => { + const client = new XYZService({ + endpoint: "https://localhost/nowhere", + }); + + requireRequestsFrom(client) + .toMatch({ + hostname: /localhost/, + }) + .respondWith( + createCborResponse( + { + __type: "RetryableError", + }, + 400 // not retryable status code + ), + createCborResponse( + { + __type: "RetryableError", + }, + 400 // not retryable status code + ), + createCborResponse("", 200) + ); + + const response = await client.getNumbers().catch((e) => e); + + expect(response.$metadata.attempts).toEqual(3); + }); + + it("should retry retryable trait with throttling", async () => { + const client = new XYZService({ + endpoint: "https://localhost/nowhere", + }); + + requireRequestsFrom(client) + .toMatch({ + hostname: /localhost/, + }) + .respondWith( + createCborResponse( + { + __type: "CodedThrottlingError", + }, + 429 + ), + createCborResponse( + { + __type: "MysteryThrottlingError", + }, + 400 // not a retryable status code, but error is modeled as retryable. + ), + createCborResponse("", 200) + ); + + const response = await client.getNumbers().catch((e) => e); + + expect(response.$metadata.attempts).toEqual(3); + }); + + it("should not retry if the error is not modeled with retryable trait and is not otherwise retryable", async () => { + const client = new XYZService({ + endpoint: "https://localhost/nowhere", + }); + + requireRequestsFrom(client) + .toMatch({ + hostname: /localhost/, + }) + .respondWith( + createCborResponse( + { + __type: "HaltError", + }, + 429 // not modeled as retryable, but this is a retryable status code. + ), + createCborResponse( + { + __type: "HaltError", + }, + 400 + ), + createCborResponse("", 200) + ); + + const response = await client.getNumbers().catch((e) => e); + + // stopped at the second error. + expect(response.$metadata.attempts).toEqual(2); + }); +}); diff --git a/packages/util-retry/vitest.config.integ.mts b/packages/util-retry/vitest.config.integ.mts new file mode 100644 index 00000000000..5802db1ac64 --- /dev/null +++ b/packages/util-retry/vitest.config.integ.mts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.integ.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/util-stream/src/util-stream.integ.spec.ts b/packages/util-stream/src/util-stream.integ.spec.ts index 12d460f2d3c..3cab355eefa 100644 --- a/packages/util-stream/src/util-stream.integ.spec.ts +++ b/packages/util-stream/src/util-stream.integ.spec.ts @@ -12,7 +12,14 @@ import { requireRequestsFrom } from "../../../private/util-test/src/index"; describe("util-stream", () => { describe(Weather.name, () => { it("should be uniform between string and Uint8Array payloads", async () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ method: "POST", hostname: "foo.bar", @@ -47,7 +54,14 @@ describe("util-stream", () => { }); describe("blob helper integration", () => { - const client = new Weather({ endpoint: "https://foo.bar" }); + const client = new Weather({ + endpoint: "https://foo.bar", + region: "us-west-2", + credentials: { + accessKeyId: "INTEG", + secretAccessKey: "INTEG", + }, + }); requireRequestsFrom(client).toMatch({ method: "POST", diff --git a/private/my-local-model/src/commands/GetNumbersCommand.ts b/private/my-local-model/src/commands/GetNumbersCommand.ts index 38b4a54a9fa..356bcb5ad2e 100644 --- a/private/my-local-model/src/commands/GetNumbersCommand.ts +++ b/private/my-local-model/src/commands/GetNumbersCommand.ts @@ -56,6 +56,14 @@ export interface GetNumbersCommandOutput extends GetNumbersResponse, __MetadataB * @see {@link GetNumbersCommandOutput} for command's `response` shape. * @see {@link XYZServiceClientResolvedConfig | config} for XYZServiceClient's `config` shape. * + * @throws {@link CodedThrottlingError} (client fault) + * + * @throws {@link MysteryThrottlingError} (client fault) + * + * @throws {@link RetryableError} (client fault) + * + * @throws {@link HaltError} (client fault) + * * @throws {@link XYZServiceServiceException} *

Base exception class for all service exceptions from XYZService service.

* diff --git a/private/my-local-model/src/models/models_0.ts b/private/my-local-model/src/models/models_0.ts index 5be0453f46e..9fb64af1dc7 100644 --- a/private/my-local-model/src/models/models_0.ts +++ b/private/my-local-model/src/models/models_0.ts @@ -1,5 +1,29 @@ // smithy-typescript generated code +import { XYZServiceServiceException as __BaseException } from "./XYZServiceServiceException"; import { NumericValue } from "@smithy/core/serde"; +import { ExceptionOptionType as __ExceptionOptionType } from "@smithy/smithy-client"; + +/** + * @public + */ +export class CodedThrottlingError extends __BaseException { + readonly name: "CodedThrottlingError" = "CodedThrottlingError"; + readonly $fault: "client" = "client"; + $retryable = { + throttling: true, + }; + /** + * @internal + */ + constructor(opts: __ExceptionOptionType) { + super({ + name: "CodedThrottlingError", + $fault: "client", + ...opts, + }); + Object.setPrototypeOf(this, CodedThrottlingError.prototype); + } +} /** * @public @@ -16,3 +40,64 @@ export interface GetNumbersResponse { bigDecimal?: NumericValue | undefined; bigInteger?: bigint | undefined; } + +/** + * @public + */ +export class HaltError extends __BaseException { + readonly name: "HaltError" = "HaltError"; + readonly $fault: "client" = "client"; + /** + * @internal + */ + constructor(opts: __ExceptionOptionType) { + super({ + name: "HaltError", + $fault: "client", + ...opts, + }); + Object.setPrototypeOf(this, HaltError.prototype); + } +} + +/** + * @public + */ +export class MysteryThrottlingError extends __BaseException { + readonly name: "MysteryThrottlingError" = "MysteryThrottlingError"; + readonly $fault: "client" = "client"; + $retryable = { + throttling: true, + }; + /** + * @internal + */ + constructor(opts: __ExceptionOptionType) { + super({ + name: "MysteryThrottlingError", + $fault: "client", + ...opts, + }); + Object.setPrototypeOf(this, MysteryThrottlingError.prototype); + } +} + +/** + * @public + */ +export class RetryableError extends __BaseException { + readonly name: "RetryableError" = "RetryableError"; + readonly $fault: "client" = "client"; + $retryable = {}; + /** + * @internal + */ + constructor(opts: __ExceptionOptionType) { + super({ + name: "RetryableError", + $fault: "client", + ...opts, + }); + Object.setPrototypeOf(this, RetryableError.prototype); + } +} diff --git a/private/my-local-model/src/protocols/Rpcv2cbor.ts b/private/my-local-model/src/protocols/Rpcv2cbor.ts index eacb1536e54..70558c90ce8 100644 --- a/private/my-local-model/src/protocols/Rpcv2cbor.ts +++ b/private/my-local-model/src/protocols/Rpcv2cbor.ts @@ -1,7 +1,14 @@ // smithy-typescript generated code import { GetNumbersCommandInput, GetNumbersCommandOutput } from "../commands/GetNumbersCommand"; import { XYZServiceServiceException as __BaseException } from "../models/XYZServiceServiceException"; -import { GetNumbersRequest, GetNumbersResponse } from "../models/models_0"; +import { + CodedThrottlingError, + GetNumbersRequest, + GetNumbersResponse, + HaltError, + MysteryThrottlingError, + RetryableError, +} from "../models/models_0"; import { buildHttpRpcRequest, cbor, @@ -12,7 +19,13 @@ import { } from "@smithy/core/cbor"; import { nv as __nv } from "@smithy/core/serde"; import { HttpRequest as __HttpRequest, HttpResponse as __HttpResponse } from "@smithy/protocol-http"; -import { _json, collectBody, take, withBaseException } from "@smithy/smithy-client"; +import { + decorateServiceException as __decorateServiceException, + _json, + collectBody, + take, + withBaseException, +} from "@smithy/smithy-client"; import { Endpoint as __Endpoint, HeaderBag as __HeaderBag, @@ -64,12 +77,85 @@ const de_CommandError = async (output: __HttpResponse, context: __SerdeContext): body: await parseErrorBody(output.body, context), }; const errorCode = loadSmithyRpcV2CborErrorCode(output, parsedOutput.body); - const parsedBody = parsedOutput.body; - return throwDefaultError({ - output, - parsedBody, - errorCode, - }) as never; + switch (errorCode) { + case "CodedThrottlingError": + case "org.xyz.v1#CodedThrottlingError": + throw await de_CodedThrottlingErrorRes(parsedOutput, context); + case "HaltError": + case "org.xyz.v1#HaltError": + throw await de_HaltErrorRes(parsedOutput, context); + case "MysteryThrottlingError": + case "org.xyz.v1#MysteryThrottlingError": + throw await de_MysteryThrottlingErrorRes(parsedOutput, context); + case "RetryableError": + case "org.xyz.v1#RetryableError": + throw await de_RetryableErrorRes(parsedOutput, context); + default: + const parsedBody = parsedOutput.body; + return throwDefaultError({ + output, + parsedBody, + errorCode, + }) as never; + } +}; + +/** + * deserializeRpcv2cborCodedThrottlingErrorRes + */ +const de_CodedThrottlingErrorRes = async ( + parsedOutput: any, + context: __SerdeContext +): Promise => { + const body = parsedOutput.body; + const deserialized: any = _json(body); + const exception = new CodedThrottlingError({ + $metadata: deserializeMetadata(parsedOutput), + ...deserialized, + }); + return __decorateServiceException(exception, body); +}; + +/** + * deserializeRpcv2cborHaltErrorRes + */ +const de_HaltErrorRes = async (parsedOutput: any, context: __SerdeContext): Promise => { + const body = parsedOutput.body; + const deserialized: any = _json(body); + const exception = new HaltError({ + $metadata: deserializeMetadata(parsedOutput), + ...deserialized, + }); + return __decorateServiceException(exception, body); +}; + +/** + * deserializeRpcv2cborMysteryThrottlingErrorRes + */ +const de_MysteryThrottlingErrorRes = async ( + parsedOutput: any, + context: __SerdeContext +): Promise => { + const body = parsedOutput.body; + const deserialized: any = _json(body); + const exception = new MysteryThrottlingError({ + $metadata: deserializeMetadata(parsedOutput), + ...deserialized, + }); + return __decorateServiceException(exception, body); +}; + +/** + * deserializeRpcv2cborRetryableErrorRes + */ +const de_RetryableErrorRes = async (parsedOutput: any, context: __SerdeContext): Promise => { + const body = parsedOutput.body; + const deserialized: any = _json(body); + const exception = new RetryableError({ + $metadata: deserializeMetadata(parsedOutput), + ...deserialized, + }); + return __decorateServiceException(exception, body); }; /** @@ -82,6 +168,8 @@ const se_GetNumbersRequest = (input: GetNumbersRequest, context: __SerdeContext) }); }; +// de_CodedThrottlingError omitted. + /** * deserializeRpcv2cborGetNumbersResponse */ @@ -92,6 +180,12 @@ const de_GetNumbersResponse = (output: any, context: __SerdeContext): GetNumbers }) as any; }; +// de_HaltError omitted. + +// de_MysteryThrottlingError omitted. + +// de_RetryableError omitted. + const deserializeMetadata = (output: __HttpResponse): __ResponseMetadata => ({ httpStatusCode: output.statusCode, requestId: diff --git a/private/util-test/src/test-http-handler.ts b/private/util-test/src/test-http-handler.ts index f3ddd6cff7e..17787d29613 100644 --- a/private/util-test/src/test-http-handler.ts +++ b/private/util-test/src/test-http-handler.ts @@ -1,5 +1,5 @@ import type { HttpHandler, HttpRequest, HttpResponse } from "@smithy/protocol-http"; -import type { Client, RequestHandler, RequestHandlerOutput } from "@smithy/types"; +import type { Client, HttpHandlerOptions, RequestHandler, RequestHandlerOutput } from "@smithy/types"; import { expect } from "vitest"; /** @@ -28,60 +28,58 @@ export type HttpRequestMatcher = { log?: boolean; }; -/** - * @internal - */ -const MOCK_CREDENTIALS = { - accessKeyId: "MOCK_ACCESS_KEY_ID", - secretAccessKey: "MOCK_SECRET_ACCESS_KEY_ID", -}; - -type TestHttpHandlerConfig = object; - /** * Supplied to test clients to assert correct requests. * @internal */ -export class TestHttpHandler implements HttpHandler { +export class TestHttpHandler implements HttpHandler { private static WATCHER = Symbol("TestHttpHandler_WATCHER"); - private originalSend?: Client["send"]; + + public readonly matchers: HttpRequestMatcher[]; + + private originalSend?: Function; private originalRequestHandler?: RequestHandler; private client?: Client; + private responseQueue: HttpResponse[] = []; private assertions = 0; - public constructor(public readonly matcher: HttpRequestMatcher) {} + public constructor(...matchers: HttpRequestMatcher[]) { + this.matchers = matchers; + const RESERVED_ENVIRONMENT_VARIABLES = { + AWS_DEFAULT_REGION: 1, + AWS_REGION: 1, + AWS_PROFILE: 1, + AWS_ACCESS_KEY_ID: 1, + AWS_SECRET_ACCESS_KEY: 1, + AWS_SESSION_TOKEN: 1, + AWS_CREDENTIAL_EXPIRATION: 1, + AWS_CREDENTIAL_SCOPE: 1, + AWS_EC2_METADATA_DISABLED: 1, + AWS_WEB_IDENTITY_TOKEN_FILE: 1, + AWS_ROLE_ARN: 1, + AWS_CONTAINER_CREDENTIALS_FULL_URI: 1, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: 1, + AWS_CONTAINER_AUTHORIZATION_TOKEN: 1, + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: 1, + }; + for (const key in RESERVED_ENVIRONMENT_VARIABLES) { + delete process.env[key]; + } + process.env.AWS_ACCESS_KEY_ID = "INTEGRATION_TEST_MOCK"; + process.env.AWS_SECRET_ACCESS_KEY = "INTEGRATION_TEST_MOCK"; + } /** * @param client - to watch for requests. - * @param matcher - optional override of this instance's matchers. + * @param matchers - optional override of this instance's matchers. * * Temporarily hooks the client.send call to check the outgoing request. */ - public watch(client: Client, matcher: HttpRequestMatcher = this.matcher) { + public watch(client: Client): TestHttpHandler { this.client = client; - this.originalRequestHandler = client.config.originalRequestHandler; - // mock credentials to avoid default chain lookup. - client.config.credentials = async () => MOCK_CREDENTIALS; - client.config.credentialDefaultProvider = () => { - return async () => { - return MOCK_CREDENTIALS; - }; - }; - const signerProvider = client.config.signer; - if (typeof signerProvider === "function") { - client.config.signer = async () => { - const _signer = await signerProvider(); - if (typeof _signer.credentialProvider === "function") { - // signer is instance of SignatureV4 - _signer.credentialProvider = async () => { - return MOCK_CREDENTIALS; - }; - } - return _signer; - }; - } + this.originalRequestHandler = client.config.requestHandler; - client.config.requestHandler = new TestHttpHandler(matcher); + client.config.requestHandler = this; if (!(client as any)[TestHttpHandler.WATCHER]) { (client as any)[TestHttpHandler.WATCHER] = true; const originalSend = (this.originalSend = client.send as any); @@ -94,14 +92,27 @@ export class TestHttpHandler implements HttpHandler { }); }; } + + return this; + } + + /** + * @param httpResponses - to enqueue for mock responses. + */ + public respondWith(...httpResponses: HttpResponse[]): TestHttpHandler { + this.responseQueue.push(...httpResponses); + return this; } /** * @throws TestHttpHandlerSuccess to indicate success (only way to control it). * @throws Error any other exception to indicate failure. */ - public async handle(request: HttpRequest): Promise> { - const m = this.matcher; + public async handle( + request: HttpRequest, + handlerOptions?: HttpHandlerOptions + ): Promise> { + const m = this.matchers.length > 1 ? this.matchers.shift()! : this.matchers[0]; if (m.log) { console.log(request); @@ -111,9 +122,9 @@ export class TestHttpHandler implements HttpHandler { this.check(m.hostname, request.hostname); this.check(m.port, request.port); this.check(m.path, request.path); - this.checkAll(m.query, request.query); + this.checkAll(m.query ?? {}, request.query, "query"); - this.checkAll(m.headers, request.headers); + this.checkAll(m.headers ?? {}, request.headers, "header"); this.check(m.body, request.body); this.check(m.method, request.method); @@ -121,6 +132,18 @@ export class TestHttpHandler implements HttpHandler { throw new Error("Request handled with no assertions, empty matcher?"); } + if (this.responseQueue.length > 1) { + return { + response: this.responseQueue.shift()!, + }; + } else { + if (this.responseQueue.length === 1) { + return { + response: this.responseQueue[0], + }; + } + } + throw new TestHttpHandlerSuccess(); } @@ -129,10 +152,9 @@ export class TestHttpHandler implements HttpHandler { (this.client as any).send = this.originalSend as any; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - updateHttpClientConfig(key: keyof TestHttpHandlerConfig, value: TestHttpHandlerConfig[typeof key]): void {} + updateHttpClientConfig(key: never, value: never): void {} - httpHandlerConfigs(): TestHttpHandlerConfig { + httpHandlerConfigs() { return {}; } @@ -167,7 +189,11 @@ export class TestHttpHandler implements HttpHandler { this.assertions++; } - private checkAll(matchers?: Record | Map, observed?: any) { + private checkAll( + matchers: Record | Map, + observed: any, + type: "header" | "query" + ) { if (matchers == null) { return; } @@ -179,7 +205,11 @@ export class TestHttpHandler implements HttpHandler { if (key.startsWith("/") && key.endsWith("/")) { key = new RegExp(key); } else { - this.check(matcher, observed[key]); + const matchingValue = + type === "header" + ? observed[Object.keys(observed).find((k) => k.toLowerCase() === String(key).toLowerCase()) ?? ""] + : observed[key]; + this.check(matcher, matchingValue); } } if (key instanceof RegExp) { @@ -209,8 +239,8 @@ export class TestHttpHandlerSuccess extends Error { */ export const requireRequestsFrom = (client: Client) => { return { - toMatch(matcher: HttpRequestMatcher) { - return new TestHttpHandler(matcher).watch(client); + toMatch(...matchers: HttpRequestMatcher[]) { + return new TestHttpHandler(...matchers).watch(client); }, }; }; diff --git a/smithy-typescript-protocol-test-codegen/model/my-local-model/main.smithy b/smithy-typescript-protocol-test-codegen/model/my-local-model/main.smithy index 4fdba604c94..a7e7fd3cf68 100644 --- a/smithy-typescript-protocol-test-codegen/model/my-local-model/main.smithy +++ b/smithy-typescript-protocol-test-codegen/model/my-local-model/main.smithy @@ -18,6 +18,10 @@ operation GetNumbers { input: GetNumbersRequest output: GetNumbersResponse errors: [ + CodedThrottlingError, + MysteryThrottlingError, + RetryableError, + HaltError ] } @@ -31,4 +35,20 @@ structure GetNumbersRequest { structure GetNumbersResponse { bigDecimal: BigDecimal bigInteger: BigInteger -} \ No newline at end of file +} + +@error("client") +@retryable(throttling: true) +@httpError(429) +structure CodedThrottlingError {} + +@error("client") +@retryable(throttling: true) +structure MysteryThrottlingError {} + +@error("client") +@retryable() +structure RetryableError {} + +@error("client") +structure HaltError {} \ No newline at end of file