Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(middleware-sdk-s3): add middleware for following region redirects #5185

Merged
merged 8 commits into from
Oct 5, 2023
2 changes: 2 additions & 0 deletions clients/client-s3/src/S3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { getLoggerPlugin } from "@aws-sdk/middleware-logger";
import { getRecursionDetectionPlugin } from "@aws-sdk/middleware-recursion-detection";
import {
getRegionRedirectMiddlewarePlugin,
getValidateBucketNamePlugin,
resolveS3Config,
S3InputConfig,
Expand Down Expand Up @@ -778,6 +779,7 @@ export class S3Client extends __Client<
this.middlewareStack.use(getAwsAuthPlugin(this.config));
this.middlewareStack.use(getValidateBucketNamePlugin(this.config));
this.middlewareStack.use(getAddExpectContinuePlugin(this.config));
this.middlewareStack.use(getRegionRedirectMiddlewarePlugin(this.config));
this.middlewareStack.use(getUserAgentPlugin(this.config));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ && isS3(s))
&& isS3(s)
&& !isEndpointsV2Service(s)
&& containsInputMembers(m, o, BUCKET_ENDPOINT_INPUT_KEYS))
.build(),
RuntimeClientPlugin.builder()
.withConventions(AwsDependency.S3_MIDDLEWARE.dependency, "RegionRedirectMiddleware",
HAS_MIDDLEWARE)
.servicePredicate((m, s) -> isS3(s))
.build()
);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/middleware-sdk-s3/jest.config.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: "ts-jest",
testMatch: ["**/*.e2e.spec.ts"],
};
1 change: 1 addition & 0 deletions packages/middleware-sdk-s3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
"test": "jest",
"test:integration": "jest -c jest.config.integ.js",
"test:e2e": "jest -c jest.config.e2e.js",
"extract:docs": "api-extractor run --local"
},
"main": "./dist-cjs/index.js",
Expand Down
2 changes: 2 additions & 0 deletions packages/middleware-sdk-s3/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from "./check-content-length-header";
export * from "./region-redirect-endpoint-middleware";
export * from "./region-redirect-middleware";
export * from "./s3Configuration";
export * from "./throw-200-exceptions";
export * from "./validate-bucket-name";
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
HandlerExecutionContext,
MetadataBearer,
RelativeMiddlewareOptions,
SerializeHandler,
SerializeHandlerArguments,
SerializeHandlerOutput,
SerializeMiddleware,
} from "@smithy/types";

import { PreviouslyResolved } from "./region-redirect-middleware";

/**
* @internal
*/
export const regionRedirectEndpointMiddleware = (config: PreviouslyResolved): SerializeMiddleware<any, any> => {
return <Output extends MetadataBearer>(
next: SerializeHandler<any, Output>,
context: HandlerExecutionContext
): SerializeHandler<any, Output> =>
async (args: SerializeHandlerArguments<any>): Promise<SerializeHandlerOutput<Output>> => {
const originalRegion = await config.region();
const regionProviderRef = config.region;
if (context.__s3RegionRedirect) {
config.region = async () => {
config.region = regionProviderRef;
return context.__s3RegionRedirect;
};
}
const result = await next(args);
if (context.__s3RegionRedirect) {
const region = await config.region();
if (originalRegion !== region) {
throw new Error("Region was not restored following S3 region redirect.");
}
}
return result;
};
};

/**
* @internal
*/
export const regionRedirectEndpointMiddlewareOptions: RelativeMiddlewareOptions = {
tags: ["REGION_REDIRECT", "S3"],
name: "regionRedirectEndpointMiddleware",
override: true,
relation: "before",
toMiddleware: "endpointV2Middleware",
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { S3 } from "@aws-sdk/client-s3";
import { GetCallerIdentityCommandOutput, STS } from "@aws-sdk/client-sts";

const testValue = "Hello S3 global client!";

describe("S3 Global Client Test", () => {
const regionConfigs = [
{ region: "us-east-1", followRegionRedirects: true },
{ region: "eu-west-1", followRegionRedirects: true },
{ region: "us-west-2", followRegionRedirects: true },
];
const s3Clients = regionConfigs.map((config) => new S3(config));
const stsClient = new STS({});

let callerID = null as unknown as GetCallerIdentityCommandOutput;
let bucketNames = [] as string[];

beforeAll(async () => {
jest.setTimeout(500000);
callerID = await stsClient.getCallerIdentity({});
bucketNames = regionConfigs.map((config) => `${callerID.Account}-redirect-${config.region}`);
await Promise.all(bucketNames.map((bucketName, index) => deleteBucket(s3Clients[index], bucketName)));
await Promise.all(bucketNames.map((bucketName, index) => s3Clients[index].createBucket({ Bucket: bucketName })));
});

afterAll(async () => {
await Promise.all(bucketNames.map((bucketName, index) => deleteBucket(s3Clients[index], bucketName)));
});

it("Should be able to put objects following region redirect", async () => {
// Upload objects to each bucket
for (const bucketName of bucketNames) {
for (const s3Client of s3Clients) {
const objKey = `object-from-${await s3Client.config.region()}-client`;
await s3Client.putObject({ Bucket: bucketName, Key: objKey, Body: testValue });
}
}
}, 50000);

it("Should be able to get objects following region redirect", async () => {
// Fetch and assert objects
for (const bucketName of bucketNames) {
for (const s3Client of s3Clients) {
const objKey = `object-from-${await s3Client.config.region()}-client`;
const { Body } = await s3Client.getObject({ Bucket: bucketName, Key: objKey });
const data = await Body?.transformToString();
expect(data).toEqual(testValue);
}
}
}, 50000);

it("Should delete objects following region redirect", async () => {
for (const bucketName of bucketNames) {
for (const s3Client of s3Clients) {
const objKey = `object-from-${await s3Client.config.region()}-client`;
await s3Client.deleteObject({ Bucket: bucketName, Key: objKey });
}
}
}, 50000);
});

async function deleteBucket(s3: S3, bucketName: string) {
const Bucket = bucketName;

try {
await s3.headBucket({
Bucket,
});
} catch (e) {
return;
}

const list = await s3
.listObjects({
Bucket,
})
.catch((e) => {
if (!String(e).includes("NoSuchBucket")) {
throw e;
}
return {
Contents: [],
};
});

const promises = [] as any[];
for (const key of list.Contents ?? []) {
promises.push(
s3.deleteObject({
Bucket,
Key: key.Key,
})
);
}
await Promise.all(promises);

try {
return await s3.deleteBucket({
Bucket,
});
} catch (e) {
if (!String(e).includes("NoSuchBucket")) {
throw e;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { HandlerExecutionContext } from "@smithy/types";

import { regionRedirectMiddleware } from "./region-redirect-middleware";

describe(regionRedirectMiddleware.name, () => {
const region = async () => "us-east-1";
const redirectRegion = "us-west-2";
let call = 0;
const next = (arg: any) => {
if (call === 0) {
call++;
throw Object.assign(new Error(), {
name: "PermanentRedirect",
$metadata: { httpStatusCode: 301 },
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
});
}
return null as any;
};

beforeEach(() => {
call = 0;
});

it("set S3 region redirect on context if receiving a PermanentRedirect error code with status 301", async () => {
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: true });
const context = {} as HandlerExecutionContext;
const handler = middleware(next, context);
await handler({ input: null });
expect(context.__s3RegionRedirect).toEqual(redirectRegion);
});

it("does not follow the redirect when followRegionRedirects is false", async () => {
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: false });
const context = {} as HandlerExecutionContext;
const handler = middleware(next, context);
// Simulating a PermanentRedirect error with status 301
await expect(async () => {
await handler({ input: null });
}).rejects.toThrowError(
Object.assign(new Error(), {
Code: "PermanentRedirect",
$metadata: { httpStatusCode: 301 },
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
})
);
// Ensure that context.__s3RegionRedirect is not set
expect(context.__s3RegionRedirect).toBeUndefined();
});
});
77 changes: 77 additions & 0 deletions packages/middleware-sdk-s3/src/region-redirect-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
HandlerExecutionContext,
InitializeHandler,
InitializeHandlerArguments,
InitializeHandlerOptions,
InitializeHandlerOutput,
InitializeMiddleware,
MetadataBearer,
Pluggable,
Provider,
} from "@smithy/types";

import {
regionRedirectEndpointMiddleware,
regionRedirectEndpointMiddlewareOptions,
} from "./region-redirect-endpoint-middleware";

/**
* @internal
*/
export interface PreviouslyResolved {
region: Provider<string>;
followRegionRedirects: boolean;
}

/**
* @internal
*/
export function regionRedirectMiddleware(clientConfig: PreviouslyResolved): InitializeMiddleware<any, any> {
return <Output extends MetadataBearer>(
next: InitializeHandler<any, Output>,
context: HandlerExecutionContext
): InitializeHandler<any, Output> =>
async (args: InitializeHandlerArguments<any>): Promise<InitializeHandlerOutput<Output>> => {
try {
return await next(args);
} catch (err) {
// console.log("Region Redirect", clientConfig.followRegionRedirects, err.name, err.$metadata.httpStatusCode);
if (
clientConfig.followRegionRedirects &&
err.name === "PermanentRedirect" &&
err.$metadata.httpStatusCode === 301
) {
try {
const actualRegion = err.$response.headers["x-amz-bucket-region"];
context.logger?.debug(`Redirecting from ${await clientConfig.region()} to ${actualRegion}`);
context.__s3RegionRedirect = actualRegion;
} catch (e) {
throw new Error("Region redirect failed: " + e);
}
return next(args);
} else {
throw err;
}
}
};
}

/**
* @internal
*/
export const regionRedirectMiddlewareOptions: InitializeHandlerOptions = {
step: "initialize",
tags: ["REGION_REDIRECT", "S3"],
name: "regionRedirectMiddleware",
override: true,
};

/**
* @internal
*/
export const getRegionRedirectMiddlewarePlugin = (clientConfig: PreviouslyResolved): Pluggable<any, any> => ({
applyToStack: (clientStack) => {
clientStack.add(regionRedirectMiddleware(clientConfig), regionRedirectMiddlewareOptions);
clientStack.addRelativeTo(regionRedirectEndpointMiddleware(clientConfig), regionRedirectEndpointMiddlewareOptions);
},
});
11 changes: 10 additions & 1 deletion packages/middleware-sdk-s3/src/s3Configuration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @public
*
*
* All endpoint parameters with built-in bindings of AWS::S3::*
*/
export interface S3InputConfig {
Expand All @@ -17,17 +17,26 @@ export interface S3InputConfig {
* Whether multi-region access points (MRAP) should be disabled.
*/
disableMultiregionAccessPoints?: boolean;
/**
* This feature was previously called the S3 Global Client.
* This can result in additional latency as failed requests are retried
* with a corrected region when receiving a permanent redirect error with status 301.
* This feature should only be used as a last resort if you do not know the region of your bucket(s) ahead of time.
*/
followRegionRedirects?: boolean;
siddsriv marked this conversation as resolved.
Show resolved Hide resolved
}

export interface S3ResolvedConfig {
forcePathStyle: boolean;
useAccelerateEndpoint: boolean;
disableMultiregionAccessPoints: boolean;
followRegionRedirects: boolean;
}

export const resolveS3Config = <T>(input: T & S3InputConfig): T & S3ResolvedConfig => ({
...input,
forcePathStyle: input.forcePathStyle ?? false,
useAccelerateEndpoint: input.useAccelerateEndpoint ?? false,
disableMultiregionAccessPoints: input.disableMultiregionAccessPoints ?? false,
followRegionRedirects: input.followRegionRedirects ?? false,
});