Skip to content

Commit abed452

Browse files
authored
feat(client-s3): throw errors with 200 status code (#1657)
1 parent 706768d commit abed452

File tree

10 files changed

+263
-3
lines changed

10 files changed

+263
-3
lines changed

clients/client-s3/S3.spec.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
/// <reference types="mocha" />
2-
import { expect } from "chai";
3-
import { S3 } from "./S3";
4-
import { SerializeMiddleware, BuildMiddleware } from "@aws-sdk/types";
52
import { HttpRequest } from "@aws-sdk/protocol-http";
3+
import { BuildMiddleware, SerializeMiddleware } from "@aws-sdk/types";
4+
import chai from "chai";
5+
import chaiAsPromised from "chai-as-promised";
6+
import { PassThrough } from "stream";
7+
8+
import { S3 } from "./S3";
9+
10+
chai.use(chaiAsPromised);
11+
const { expect } = chai;
612

713
describe("endpoint", () => {
814
it("users can override endpoint from client.", async () => {
@@ -93,3 +99,75 @@ describe("Accesspoint ARN", async () => {
9399
);
94100
});
95101
});
102+
103+
describe("Throw 200 response", () => {
104+
const response = {
105+
statusCode: 200,
106+
headers: {},
107+
body: new PassThrough(),
108+
};
109+
const client = new S3({
110+
region: "us-west-2",
111+
requestHandler: {
112+
handle: async () => ({
113+
response,
114+
}),
115+
},
116+
});
117+
const errorBody = `<?xml version="1.0" encoding="UTF-8"?>
118+
<Error>
119+
<Code>InternalError</Code>
120+
<Message>We encountered an internal error. Please try again.</Message>
121+
<RequestId>656c76696e6727732072657175657374</RequestId>
122+
<HostId>Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg==</HostId>
123+
</Error>`;
124+
const params = {
125+
Bucket: "bucket",
126+
Key: "key",
127+
CopySource: "source",
128+
};
129+
130+
beforeEach(() => {
131+
response.body = new PassThrough();
132+
});
133+
134+
it("should throw if CopyObject() return with 200 and empty payload", async () => {
135+
response.body.end("");
136+
return expect(client.copyObject(params)).to.eventually.be.rejectedWith("S3 aborted request");
137+
});
138+
139+
it("should throw if CopyObject() return with 200 and error preamble", async () => {
140+
response.body.end(errorBody);
141+
return expect(client.copyObject(params)).to.eventually.be.rejectedWith(
142+
"We encountered an internal error. Please try again."
143+
);
144+
});
145+
146+
it("should throw if UploadPartCopy() return with 200 and empty payload", async () => {
147+
response.body.end("");
148+
return expect(client.uploadPartCopy({ ...params, UploadId: "id", PartNumber: 1 })).to.eventually.be.rejectedWith(
149+
"S3 aborted request"
150+
);
151+
});
152+
153+
it("should throw if UploadPartCopy() return with 200 and error preamble", async () => {
154+
response.body.end(errorBody);
155+
return expect(client.uploadPartCopy({ ...params, UploadId: "id", PartNumber: 1 })).to.eventually.be.rejectedWith(
156+
"We encountered an internal error. Please try again."
157+
);
158+
});
159+
160+
it("should throw if CompleteMultipartUpload() return with 200 and empty payload", async () => {
161+
response.body.end("");
162+
return expect(client.completeMultipartUpload({ ...params, UploadId: "id" })).to.eventually.be.rejectedWith(
163+
"S3 aborted request"
164+
);
165+
});
166+
167+
it("should throw if CompleteMultipartUpload() return with 200 and error preamble", async () => {
168+
response.body.end(errorBody);
169+
return expect(client.completeMultipartUpload({ ...params, UploadId: "id" })).to.eventually.be.rejectedWith(
170+
"We encountered an internal error. Please try again."
171+
);
172+
});
173+
});

clients/client-s3/commands/CompleteMultipartUploadCommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
serializeAws_restXmlCompleteMultipartUploadCommand,
66
} from "../protocols/Aws_restXml";
77
import { getBucketEndpointPlugin } from "@aws-sdk/middleware-bucket-endpoint";
8+
import { getThrow200ExceptionsPlugin } from "@aws-sdk/middleware-sdk-s3";
89
import { getSerdePlugin } from "@aws-sdk/middleware-serde";
910
import { HttpRequest as __HttpRequest, HttpResponse as __HttpResponse } from "@aws-sdk/protocol-http";
1011
import { Command as $Command } from "@aws-sdk/smithy-client";
@@ -41,6 +42,7 @@ export class CompleteMultipartUploadCommand extends $Command<
4142
options?: __HttpHandlerOptions
4243
): Handler<CompleteMultipartUploadCommandInput, CompleteMultipartUploadCommandOutput> {
4344
this.middlewareStack.use(getSerdePlugin(configuration, this.serialize, this.deserialize));
45+
this.middlewareStack.use(getThrow200ExceptionsPlugin(configuration));
4446
this.middlewareStack.use(getBucketEndpointPlugin(configuration));
4547

4648
const stack = clientStack.concat(this.middlewareStack);

clients/client-s3/commands/CopyObjectCommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
serializeAws_restXmlCopyObjectCommand,
66
} from "../protocols/Aws_restXml";
77
import { getBucketEndpointPlugin } from "@aws-sdk/middleware-bucket-endpoint";
8+
import { getThrow200ExceptionsPlugin } from "@aws-sdk/middleware-sdk-s3";
89
import { getSerdePlugin } from "@aws-sdk/middleware-serde";
910
import { getSsecPlugin } from "@aws-sdk/middleware-ssec";
1011
import { HttpRequest as __HttpRequest, HttpResponse as __HttpResponse } from "@aws-sdk/protocol-http";
@@ -42,6 +43,7 @@ export class CopyObjectCommand extends $Command<
4243
options?: __HttpHandlerOptions
4344
): Handler<CopyObjectCommandInput, CopyObjectCommandOutput> {
4445
this.middlewareStack.use(getSerdePlugin(configuration, this.serialize, this.deserialize));
46+
this.middlewareStack.use(getThrow200ExceptionsPlugin(configuration));
4547
this.middlewareStack.use(getSsecPlugin(configuration));
4648
this.middlewareStack.use(getBucketEndpointPlugin(configuration));
4749

clients/client-s3/commands/UploadPartCopyCommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
serializeAws_restXmlUploadPartCopyCommand,
66
} from "../protocols/Aws_restXml";
77
import { getBucketEndpointPlugin } from "@aws-sdk/middleware-bucket-endpoint";
8+
import { getThrow200ExceptionsPlugin } from "@aws-sdk/middleware-sdk-s3";
89
import { getSerdePlugin } from "@aws-sdk/middleware-serde";
910
import { getSsecPlugin } from "@aws-sdk/middleware-ssec";
1011
import { HttpRequest as __HttpRequest, HttpResponse as __HttpResponse } from "@aws-sdk/protocol-http";
@@ -42,6 +43,7 @@ export class UploadPartCopyCommand extends $Command<
4243
options?: __HttpHandlerOptions
4344
): Handler<UploadPartCopyCommandInput, UploadPartCopyCommandOutput> {
4445
this.middlewareStack.use(getSerdePlugin(configuration, this.serialize, this.deserialize));
46+
this.middlewareStack.use(getThrow200ExceptionsPlugin(configuration));
4547
this.middlewareStack.use(getSsecPlugin(configuration));
4648
this.middlewareStack.use(getBucketEndpointPlugin(configuration));
4749

codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddS3Config.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ public final class AddS3Config implements TypeScriptIntegration {
6363
"ListBuckets"
6464
);
6565

66+
private static final Set<String> EXCEPTIONS_OF_200_OPERATIONS = SetUtils.of(
67+
"CopyObject",
68+
"UploadPartCopy",
69+
"CompleteMultipartUpload"
70+
);
71+
6672
@Override
6773
public void addConfigInterfaceFields(TypeScriptSettings settings, Model model, SymbolProvider symbolProvider,
6874
TypeScriptWriter writer) {
@@ -118,6 +124,13 @@ public List<RuntimeClientPlugin> getClientPlugins() {
118124
HAS_MIDDLEWARE)
119125
.servicePredicate((m, s) -> testServiceId(s))
120126
.build(),
127+
RuntimeClientPlugin.builder()
128+
.withConventions(AwsDependency.S3_MIDDLEWARE.dependency, "throw200Exceptions",
129+
HAS_MIDDLEWARE)
130+
.operationPredicate(
131+
(m, s, o) -> EXCEPTIONS_OF_200_OPERATIONS.contains(o.getId().getName())
132+
&& testServiceId(s))
133+
.build(),
121134
RuntimeClientPlugin.builder()
122135
.withConventions(AwsDependency.ADD_EXPECT_CONTINUE.dependency, "AddExpectContinue",
123136
HAS_MIDDLEWARE)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./validate-bucket-name";
22
export * from "./use-regional-endpoint";
3+
export * from "./throw-200-exceptions";
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { HttpRequest, HttpResponse } from "@aws-sdk/protocol-http";
2+
3+
import { throw200ExceptionsMiddleware } from "./throw-200-exceptions";
4+
5+
describe("throw200ExceptionsMiddlewareOptions", () => {
6+
const mockNextHandler = jest.fn();
7+
const mockStreamCollector = jest.fn();
8+
const mockUtf8Encoder = jest.fn();
9+
const mockConfig = {
10+
streamCollector: mockStreamCollector,
11+
utf8Encoder: mockUtf8Encoder,
12+
};
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
});
17+
18+
it("should throw if response body is empty", async () => {
19+
expect.assertions(3);
20+
mockStreamCollector.mockResolvedValue(Buffer.from(""));
21+
mockUtf8Encoder.mockReturnValue("");
22+
mockNextHandler.mockReturnValue({
23+
response: new HttpResponse({
24+
statusCode: 200,
25+
headers: {},
26+
body: "",
27+
}),
28+
});
29+
const handler = throw200ExceptionsMiddleware(mockConfig)(mockNextHandler, {} as any);
30+
try {
31+
await handler({
32+
input: {},
33+
request: new HttpRequest({
34+
hostname: "s3.us-east-1.amazonaws.com",
35+
}),
36+
});
37+
} catch (e) {
38+
expect(e).toBeDefined();
39+
expect(e.name).toEqual("InternalError");
40+
expect(e.message).toEqual("S3 aborted request");
41+
}
42+
});
43+
44+
it("should throw if response body contains Error tag", async () => {
45+
const errorBody = `<?xml version="1.0" encoding="UTF-8"?>
46+
<Error>
47+
<Code>InternalError</Code>
48+
<Message>We encountered an internal error. Please try again.</Message>
49+
<RequestId>656c76696e6727732072657175657374</RequestId>
50+
<HostId>Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg==</HostId>
51+
</Error>`;
52+
mockStreamCollector.mockResolvedValue(Buffer.from(errorBody));
53+
mockUtf8Encoder.mockReturnValue(errorBody);
54+
mockNextHandler.mockReturnValue({
55+
response: new HttpResponse({
56+
statusCode: 200,
57+
headers: {},
58+
body: "",
59+
}),
60+
});
61+
const handler = throw200ExceptionsMiddleware(mockConfig)(mockNextHandler, {} as any);
62+
const { response } = await handler({
63+
input: {},
64+
request: new HttpRequest({
65+
hostname: "s3.us-east-1.amazonaws.com",
66+
}),
67+
});
68+
expect(HttpResponse.isInstance(response)).toBe(true);
69+
// @ts-ignore
70+
expect(response.statusCode).toBeGreaterThanOrEqual(400);
71+
});
72+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { HttpResponse } from "@aws-sdk/protocol-http";
2+
import { DeserializeMiddleware, Encoder, Pluggable, RelativeMiddlewareOptions, StreamCollector } from "@aws-sdk/types";
3+
4+
type PreviouslyResolved = {
5+
streamCollector: StreamCollector;
6+
utf8Encoder: Encoder;
7+
};
8+
9+
/**
10+
* In case of an internal error/terminated connection, S3 operations may return 200 errors. CopyObject, UploadPartCopy,
11+
* CompleteMultipartUpload may return empty payload or payload with only xml Preamble.
12+
* @internal
13+
*/
14+
export const throw200ExceptionsMiddleware = (config: PreviouslyResolved): DeserializeMiddleware<any, any> => (
15+
next
16+
) => async (args) => {
17+
const result = await next(args);
18+
const { response } = result;
19+
if (!HttpResponse.isInstance(response)) return result;
20+
const { statusCode, body } = response;
21+
if (statusCode < 200 && statusCode >= 300) return result;
22+
23+
// Throw 2XX response that's either an error or has empty body.
24+
const bodyBytes = await collectBody(body, config);
25+
const bodyString = await collectBodyString(bodyBytes, config);
26+
if (bodyBytes.length === 0) {
27+
const err = new Error("S3 aborted request");
28+
err.name = "InternalError";
29+
throw err;
30+
}
31+
if (bodyString && bodyString.match("<Error>")) {
32+
// Set the error code to 4XX so that error deserializer can parse them
33+
response.statusCode = 400;
34+
}
35+
36+
// Body stream is consumed and paused at this point. So replace the response.body to the collected bytes.
37+
// So that the deserializer can consume the body as normal.
38+
response.body = bodyBytes;
39+
return result;
40+
};
41+
42+
// Collect low-level response body stream to Uint8Array.
43+
const collectBody = (streamBody: any = new Uint8Array(), context: PreviouslyResolved): Promise<Uint8Array> => {
44+
if (streamBody instanceof Uint8Array) {
45+
return Promise.resolve(streamBody);
46+
}
47+
return context.streamCollector(streamBody) || Promise.resolve(new Uint8Array());
48+
};
49+
50+
// Encode Uint8Array data into string with utf-8.
51+
const collectBodyString = (streamBody: any, context: PreviouslyResolved): Promise<string> =>
52+
collectBody(streamBody, context).then((body) => context.utf8Encoder(body));
53+
54+
/**
55+
* @internal
56+
*/
57+
export const throw200ExceptionsMiddlewareOptions: RelativeMiddlewareOptions = {
58+
relation: "after",
59+
toMiddleware: "deserializerMiddleware",
60+
tags: ["THROW_200_EXCEPTIONS", "S3"],
61+
name: "throw200ExceptionsMiddleware",
62+
};
63+
64+
/**
65+
*
66+
* @internal
67+
*/
68+
export const getThrow200ExceptionsPlugin = (config: PreviouslyResolved): Pluggable<any, any> => ({
69+
applyToStack: (clientStack) => {
70+
clientStack.addRelativeTo(throw200ExceptionsMiddleware(config), throw200ExceptionsMiddlewareOptions);
71+
},
72+
});

packages/middleware-sdk-s3/src/use-regional-endpoint.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ type PreviouslyResolved = {
1515
isCustomEndpoint: boolean;
1616
};
1717

18+
/**
19+
* @internal
20+
*/
1821
export const useRegionalEndpointMiddleware = (config: PreviouslyResolved): BuildMiddleware<any, any> => <
1922
Output extends MetadataBearer
2023
>(
@@ -30,12 +33,18 @@ export const useRegionalEndpointMiddleware = (config: PreviouslyResolved): Build
3033
return next({ ...args });
3134
};
3235

36+
/**
37+
* @internal
38+
*/
3339
export const useRegionalEndpointMiddlewareOptions: BuildHandlerOptions = {
3440
step: "build",
3541
tags: ["USE_REGIONAL_ENDPOINT", "S3"],
3642
name: "useRegionalEndpointMiddleware",
3743
};
3844

45+
/**
46+
* @internal
47+
*/
3948
export const getUseRegionalEndpointPlugin = (config: PreviouslyResolved): Pluggable<any, any> => ({
4049
applyToStack: (clientStack) => {
4150
clientStack.add(useRegionalEndpointMiddleware(config), useRegionalEndpointMiddlewareOptions);

packages/middleware-sdk-s3/src/validate-bucket-name.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
} from "@aws-sdk/types";
1010
import { validate as validateArn } from "@aws-sdk/util-arn-parser";
1111

12+
/**
13+
* @internal
14+
*/
1215
export function validateBucketNameMiddleware(): InitializeMiddleware<any, any> {
1316
return <Output extends MetadataBearer>(
1417
next: InitializeHandler<any, Output>
@@ -27,12 +30,18 @@ export function validateBucketNameMiddleware(): InitializeMiddleware<any, any> {
2730
};
2831
}
2932

33+
/**
34+
* @internal
35+
*/
3036
export const validateBucketNameMiddlewareOptions: InitializeHandlerOptions = {
3137
step: "initialize",
3238
tags: ["VALIDATE_BUCKET_NAME"],
3339
name: "validateBucketNameMiddleware",
3440
};
3541

42+
/**
43+
* @internal
44+
*/
3645
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3746
export const getValidateBucketNamePlugin = (unused: any): Pluggable<any, any> => ({
3847
applyToStack: (clientStack) => {

0 commit comments

Comments
 (0)