Skip to content

Commit 6e139f7

Browse files
authored
feat(middleware-sdk-s3): add middleware for following region redirects (#5185)
* feat(middleware-sdk-s3): add middleware for following region redirects * test(middleware-sdk-s3): add initial unit tests for region redirect middleware * test(middleware-sdk-s3): unit test addition and initial E2E test * test(middleware-sdk-s3): increase timeout for E2E test * chore(middleware-sdk-s3): adding an await and nit fix * chore(middleware-sdk-s3): split region redirect middlewares in different files * fix(middleware-sdk-s3): bug fix for middleware and test refactor * chore(middleware-sdk-s3): doc update
1 parent 7760dd4 commit 6e139f7

File tree

10 files changed

+307
-1
lines changed

10 files changed

+307
-1
lines changed

clients/client-s3/src/S3Client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { getLoggerPlugin } from "@aws-sdk/middleware-logger";
1010
import { getRecursionDetectionPlugin } from "@aws-sdk/middleware-recursion-detection";
1111
import {
12+
getRegionRedirectMiddlewarePlugin,
1213
getValidateBucketNamePlugin,
1314
resolveS3Config,
1415
S3InputConfig,
@@ -780,6 +781,7 @@ export class S3Client extends __Client<
780781
this.middlewareStack.use(getAwsAuthPlugin(this.config));
781782
this.middlewareStack.use(getValidateBucketNamePlugin(this.config));
782783
this.middlewareStack.use(getAddExpectContinuePlugin(this.config));
784+
this.middlewareStack.use(getRegionRedirectMiddlewarePlugin(this.config));
783785
this.middlewareStack.use(getUserAgentPlugin(this.config));
784786
}
785787

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ && isS3(s))
226226
&& isS3(s)
227227
&& !isEndpointsV2Service(s)
228228
&& containsInputMembers(m, o, BUCKET_ENDPOINT_INPUT_KEYS))
229+
.build(),
230+
RuntimeClientPlugin.builder()
231+
.withConventions(AwsDependency.S3_MIDDLEWARE.dependency, "RegionRedirectMiddleware",
232+
HAS_MIDDLEWARE)
233+
.servicePredicate((m, s) -> isS3(s))
229234
.build()
230235
);
231236
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
preset: "ts-jest",
3+
testMatch: ["**/*.e2e.spec.ts"],
4+
};

packages/middleware-sdk-s3/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
1212
"test": "jest",
1313
"test:integration": "jest -c jest.config.integ.js",
14+
"test:e2e": "jest -c jest.config.e2e.js",
1415
"extract:docs": "api-extractor run --local"
1516
},
1617
"main": "./dist-cjs/index.js",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from "./check-content-length-header";
2+
export * from "./region-redirect-endpoint-middleware";
3+
export * from "./region-redirect-middleware";
24
export * from "./s3Configuration";
35
export * from "./throw-200-exceptions";
46
export * from "./validate-bucket-name";
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
HandlerExecutionContext,
3+
MetadataBearer,
4+
RelativeMiddlewareOptions,
5+
SerializeHandler,
6+
SerializeHandlerArguments,
7+
SerializeHandlerOutput,
8+
SerializeMiddleware,
9+
} from "@smithy/types";
10+
11+
import { PreviouslyResolved } from "./region-redirect-middleware";
12+
13+
/**
14+
* @internal
15+
*/
16+
export const regionRedirectEndpointMiddleware = (config: PreviouslyResolved): SerializeMiddleware<any, any> => {
17+
return <Output extends MetadataBearer>(
18+
next: SerializeHandler<any, Output>,
19+
context: HandlerExecutionContext
20+
): SerializeHandler<any, Output> =>
21+
async (args: SerializeHandlerArguments<any>): Promise<SerializeHandlerOutput<Output>> => {
22+
const originalRegion = await config.region();
23+
const regionProviderRef = config.region;
24+
if (context.__s3RegionRedirect) {
25+
config.region = async () => {
26+
config.region = regionProviderRef;
27+
return context.__s3RegionRedirect;
28+
};
29+
}
30+
const result = await next(args);
31+
if (context.__s3RegionRedirect) {
32+
const region = await config.region();
33+
if (originalRegion !== region) {
34+
throw new Error("Region was not restored following S3 region redirect.");
35+
}
36+
}
37+
return result;
38+
};
39+
};
40+
41+
/**
42+
* @internal
43+
*/
44+
export const regionRedirectEndpointMiddlewareOptions: RelativeMiddlewareOptions = {
45+
tags: ["REGION_REDIRECT", "S3"],
46+
name: "regionRedirectEndpointMiddleware",
47+
override: true,
48+
relation: "before",
49+
toMiddleware: "endpointV2Middleware",
50+
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { S3 } from "@aws-sdk/client-s3";
2+
import { GetCallerIdentityCommandOutput, STS } from "@aws-sdk/client-sts";
3+
4+
const testValue = "Hello S3 global client!";
5+
6+
describe("S3 Global Client Test", () => {
7+
const regionConfigs = [
8+
{ region: "us-east-1", followRegionRedirects: true },
9+
{ region: "eu-west-1", followRegionRedirects: true },
10+
{ region: "us-west-2", followRegionRedirects: true },
11+
];
12+
const s3Clients = regionConfigs.map((config) => new S3(config));
13+
const stsClient = new STS({});
14+
15+
let callerID = null as unknown as GetCallerIdentityCommandOutput;
16+
let bucketNames = [] as string[];
17+
18+
beforeAll(async () => {
19+
jest.setTimeout(500000);
20+
callerID = await stsClient.getCallerIdentity({});
21+
bucketNames = regionConfigs.map((config) => `${callerID.Account}-redirect-${config.region}`);
22+
await Promise.all(bucketNames.map((bucketName, index) => deleteBucket(s3Clients[index], bucketName)));
23+
await Promise.all(bucketNames.map((bucketName, index) => s3Clients[index].createBucket({ Bucket: bucketName })));
24+
});
25+
26+
afterAll(async () => {
27+
await Promise.all(bucketNames.map((bucketName, index) => deleteBucket(s3Clients[index], bucketName)));
28+
});
29+
30+
it("Should be able to put objects following region redirect", async () => {
31+
// Upload objects to each bucket
32+
for (const bucketName of bucketNames) {
33+
for (const s3Client of s3Clients) {
34+
const objKey = `object-from-${await s3Client.config.region()}-client`;
35+
await s3Client.putObject({ Bucket: bucketName, Key: objKey, Body: testValue });
36+
}
37+
}
38+
}, 50000);
39+
40+
it("Should be able to get objects following region redirect", async () => {
41+
// Fetch and assert objects
42+
for (const bucketName of bucketNames) {
43+
for (const s3Client of s3Clients) {
44+
const objKey = `object-from-${await s3Client.config.region()}-client`;
45+
const { Body } = await s3Client.getObject({ Bucket: bucketName, Key: objKey });
46+
const data = await Body?.transformToString();
47+
expect(data).toEqual(testValue);
48+
}
49+
}
50+
}, 50000);
51+
52+
it("Should delete objects following region redirect", async () => {
53+
for (const bucketName of bucketNames) {
54+
for (const s3Client of s3Clients) {
55+
const objKey = `object-from-${await s3Client.config.region()}-client`;
56+
await s3Client.deleteObject({ Bucket: bucketName, Key: objKey });
57+
}
58+
}
59+
}, 50000);
60+
});
61+
62+
async function deleteBucket(s3: S3, bucketName: string) {
63+
const Bucket = bucketName;
64+
65+
try {
66+
await s3.headBucket({
67+
Bucket,
68+
});
69+
} catch (e) {
70+
return;
71+
}
72+
73+
const list = await s3
74+
.listObjects({
75+
Bucket,
76+
})
77+
.catch((e) => {
78+
if (!String(e).includes("NoSuchBucket")) {
79+
throw e;
80+
}
81+
return {
82+
Contents: [],
83+
};
84+
});
85+
86+
const promises = [] as any[];
87+
for (const key of list.Contents ?? []) {
88+
promises.push(
89+
s3.deleteObject({
90+
Bucket,
91+
Key: key.Key,
92+
})
93+
);
94+
}
95+
await Promise.all(promises);
96+
97+
try {
98+
return await s3.deleteBucket({
99+
Bucket,
100+
});
101+
} catch (e) {
102+
if (!String(e).includes("NoSuchBucket")) {
103+
throw e;
104+
}
105+
}
106+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { HandlerExecutionContext } from "@smithy/types";
2+
3+
import { regionRedirectMiddleware } from "./region-redirect-middleware";
4+
5+
describe(regionRedirectMiddleware.name, () => {
6+
const region = async () => "us-east-1";
7+
const redirectRegion = "us-west-2";
8+
let call = 0;
9+
const next = (arg: any) => {
10+
if (call === 0) {
11+
call++;
12+
throw Object.assign(new Error(), {
13+
name: "PermanentRedirect",
14+
$metadata: { httpStatusCode: 301 },
15+
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
16+
});
17+
}
18+
return null as any;
19+
};
20+
21+
beforeEach(() => {
22+
call = 0;
23+
});
24+
25+
it("set S3 region redirect on context if receiving a PermanentRedirect error code with status 301", async () => {
26+
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: true });
27+
const context = {} as HandlerExecutionContext;
28+
const handler = middleware(next, context);
29+
await handler({ input: null });
30+
expect(context.__s3RegionRedirect).toEqual(redirectRegion);
31+
});
32+
33+
it("does not follow the redirect when followRegionRedirects is false", async () => {
34+
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: false });
35+
const context = {} as HandlerExecutionContext;
36+
const handler = middleware(next, context);
37+
// Simulating a PermanentRedirect error with status 301
38+
await expect(async () => {
39+
await handler({ input: null });
40+
}).rejects.toThrowError(
41+
Object.assign(new Error(), {
42+
Code: "PermanentRedirect",
43+
$metadata: { httpStatusCode: 301 },
44+
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
45+
})
46+
);
47+
// Ensure that context.__s3RegionRedirect is not set
48+
expect(context.__s3RegionRedirect).toBeUndefined();
49+
});
50+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
HandlerExecutionContext,
3+
InitializeHandler,
4+
InitializeHandlerArguments,
5+
InitializeHandlerOptions,
6+
InitializeHandlerOutput,
7+
InitializeMiddleware,
8+
MetadataBearer,
9+
Pluggable,
10+
Provider,
11+
} from "@smithy/types";
12+
13+
import {
14+
regionRedirectEndpointMiddleware,
15+
regionRedirectEndpointMiddlewareOptions,
16+
} from "./region-redirect-endpoint-middleware";
17+
18+
/**
19+
* @internal
20+
*/
21+
export interface PreviouslyResolved {
22+
region: Provider<string>;
23+
followRegionRedirects: boolean;
24+
}
25+
26+
/**
27+
* @internal
28+
*/
29+
export function regionRedirectMiddleware(clientConfig: PreviouslyResolved): InitializeMiddleware<any, any> {
30+
return <Output extends MetadataBearer>(
31+
next: InitializeHandler<any, Output>,
32+
context: HandlerExecutionContext
33+
): InitializeHandler<any, Output> =>
34+
async (args: InitializeHandlerArguments<any>): Promise<InitializeHandlerOutput<Output>> => {
35+
try {
36+
return await next(args);
37+
} catch (err) {
38+
// console.log("Region Redirect", clientConfig.followRegionRedirects, err.name, err.$metadata.httpStatusCode);
39+
if (
40+
clientConfig.followRegionRedirects &&
41+
err.name === "PermanentRedirect" &&
42+
err.$metadata.httpStatusCode === 301
43+
) {
44+
try {
45+
const actualRegion = err.$response.headers["x-amz-bucket-region"];
46+
context.logger?.debug(`Redirecting from ${await clientConfig.region()} to ${actualRegion}`);
47+
context.__s3RegionRedirect = actualRegion;
48+
} catch (e) {
49+
throw new Error("Region redirect failed: " + e);
50+
}
51+
return next(args);
52+
} else {
53+
throw err;
54+
}
55+
}
56+
};
57+
}
58+
59+
/**
60+
* @internal
61+
*/
62+
export const regionRedirectMiddlewareOptions: InitializeHandlerOptions = {
63+
step: "initialize",
64+
tags: ["REGION_REDIRECT", "S3"],
65+
name: "regionRedirectMiddleware",
66+
override: true,
67+
};
68+
69+
/**
70+
* @internal
71+
*/
72+
export const getRegionRedirectMiddlewarePlugin = (clientConfig: PreviouslyResolved): Pluggable<any, any> => ({
73+
applyToStack: (clientStack) => {
74+
clientStack.add(regionRedirectMiddleware(clientConfig), regionRedirectMiddlewareOptions);
75+
clientStack.addRelativeTo(regionRedirectEndpointMiddleware(clientConfig), regionRedirectEndpointMiddlewareOptions);
76+
},
77+
});

packages/middleware-sdk-s3/src/s3Configuration.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @public
3-
*
3+
*
44
* All endpoint parameters with built-in bindings of AWS::S3::*
55
*/
66
export interface S3InputConfig {
@@ -17,17 +17,26 @@ export interface S3InputConfig {
1717
* Whether multi-region access points (MRAP) should be disabled.
1818
*/
1919
disableMultiregionAccessPoints?: boolean;
20+
/**
21+
* This feature was previously called the S3 Global Client.
22+
* This can result in additional latency as failed requests are retried
23+
* with a corrected region when receiving a permanent redirect error with status 301.
24+
* This feature should only be used as a last resort if you do not know the region of your bucket(s) ahead of time.
25+
*/
26+
followRegionRedirects?: boolean;
2027
}
2128

2229
export interface S3ResolvedConfig {
2330
forcePathStyle: boolean;
2431
useAccelerateEndpoint: boolean;
2532
disableMultiregionAccessPoints: boolean;
33+
followRegionRedirects: boolean;
2634
}
2735

2836
export const resolveS3Config = <T>(input: T & S3InputConfig): T & S3ResolvedConfig => ({
2937
...input,
3038
forcePathStyle: input.forcePathStyle ?? false,
3139
useAccelerateEndpoint: input.useAccelerateEndpoint ?? false,
3240
disableMultiregionAccessPoints: input.disableMultiregionAccessPoints ?? false,
41+
followRegionRedirects: input.followRegionRedirects ?? false,
3342
});

0 commit comments

Comments
 (0)