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: support client handling of large responses #3

Merged
merged 6 commits into from
Aug 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,181 changes: 598 additions & 583 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/large-response-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ When a client can handle a Large Response, it must send a request with the HTTP

If the client provides the large response MIME type, the Lambda will not log an error using `Log.error`. Instead, it will rewrite the original response with a reference to the offloaded large payload. Furthermore, the rewritten response will include the HTTP header `Content-Type` with the value `application/large-response.vnd+json`.

When the client doesn't provide the large response MIME type, and prefers to deal with the large response as a bad request instead of an HTTP 500, the client can send the `Handle-Large-Response: true` header. The Lambda will rewrite the original response with a custom message and HTTP status code 413 (Payload Too Large). This enables the client to detect a large response and handle it accordingly, by calling the API with a more strict filtering criteria.

If the client does not provide the large response MIME type, the Lambda will log an error with `Log.error` and rewrite the original response with a custom message (can be configured) and HTTP status code 413 (Payload Too Large).

### Middleware Configuration:
Expand Down
5 changes: 5 additions & 0 deletions packages/large-response-middleware/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ Client -> "API Gateway": Request data with/without header
"API Gateway" -> Lambda: Invoke Lambda

alt If "Accept: application:large-response.vnd+json" is present
Lambda -> Lambda: Logs info event
Lambda -> "S3 Bucket": Save large response
"S3 Bucket" -> Lambda: Return S3 ref
Lambda -> "API Gateway": Return S3 ref `{ $payload_ref: "s3://..." }`
"API Gateway" -> Client: Return $payload_ref
else If "Handle-Large-Request: true" is present
Lambda -> Lambda: Logs info event
Lambda -> Lambda: Skips S3 content dump
Lambda -> Client: Response 413 (Payload Too Large)
else If request header is not present
Lambda -> Lambda: Logs error event
Lambda -> "API Gateway": Response 500 (Internal Server Error)
"API Gateway" -> Client: Response 413 (Payload Too Large)
end
Expand Down
5 changes: 5 additions & 0 deletions packages/large-response-middleware/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const config: Config.InitialOptions = {
maxWorkers: 1, // run tests sequentially
moduleDirectories: ['node_modules', 'src'],
modulePaths: ['node_modules'],
transform: {
'^.+\\.js$': 'babel-jest',
},
transformIgnorePatterns: ['/node_modules/(?!(yn)/)'],
extensionsToTreatAsEsm: ['.ts'],
};

export default config;
4 changes: 2 additions & 2 deletions packages/large-response-middleware/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions packages/large-response-middleware/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@epilot/large-response-middleware",
"version": "0.0.11",
"version": "0.0.15",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -40,14 +40,14 @@
},
"size-limit": [
{
"limit": "2 kB",
"limit": "3 kB",
"path": "lib/index.js"
}
],
"devDependencies": {
"@babel/cli": "^7.23.4",
"@babel/core": "^7.23.6",
"@babel/preset-env": "^7.23.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.23.3",
"@dazn/lambda-powertools-logger": "^1.28.1",
"@middy/core": "^2.5.7",
Expand All @@ -62,6 +62,7 @@
"@types/node": "^20.10.5",
"aws-lambda": "^1.0.7",
"aws-sdk": "^2.816.0",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
Expand All @@ -82,6 +83,7 @@
"aws-sdk": "^2.816.0"
},
"dependencies": {
"core-js": "^3.34.0"
"core-js": "^3.34.0",
"yn": "^5.0.0"
}
}
110 changes: 82 additions & 28 deletions packages/large-response-middleware/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import * as Lambda from 'aws-lambda';
import { getOrgIdFromContext } from './__tests__/util';

import * as middleware from './';
import { LARGE_RESPONSE_MIME_TYPE, withLargeResponseHandler } from './';
import {
LARGE_RESPONSE_HANDLED_INFO,
LARGE_RESPONSE_MIME_TYPE,
LARGE_RESPONSE_USER_INFO,
withLargeResponseHandler,
} from './';

const uploadFileSpy = jest.spyOn(middleware, 'uploadFile').mockResolvedValue({
filename: 'red-redington/2023-12-13/la-caballa',
Expand Down Expand Up @@ -63,18 +68,15 @@ describe('withLargeResponseHandler', () => {
},
} as any);

expect(LogWarnSpy).toHaveBeenCalledWith(
"Large response detected. Call the API with the HTTP header 'Accept: application/large-response.vnd+json' to receive the payload through an S3 ref and avoid HTTP 500 errors.",
{
contentLength: 1572872,
event: { requestContext: {} },
request: {},
response_size_mb: '1.50',
$payload_ref: expect.stringMatching(
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
),
},
);
expect(LogWarnSpy).toHaveBeenCalledWith(`Large response detected. ${LARGE_RESPONSE_USER_INFO}`, {
contentLength: 1572872,
event: { requestContext: {} },
request: {},
response_size_mb: '1.50',
$payload_ref: expect.stringMatching(
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
),
});
});

it('should log ERROR with "Large response detected (limit exceeded)" when content length is over ERROR threshold', async () => {
Expand All @@ -101,18 +103,15 @@ describe('withLargeResponseHandler', () => {

await middleware.after(requestResponseContext);

expect(LogErrorSpy).toHaveBeenCalledWith(
"Large response detected (limit exceeded). Call the API with the HTTP header 'Accept: application/large-response.vnd+json' to receive the payload through an S3 ref and avoid HTTP 500 errors.",
{
contentLength: 1939873,
event: { requestContext: {} },
request: {},
response_size_mb: '1.85',
$payload_ref: expect.stringMatching(
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
),
},
);
expect(LogErrorSpy).toHaveBeenCalledWith(`Large response detected (limit exceeded). ${LARGE_RESPONSE_USER_INFO}`, {
contentLength: 1939873,
event: { requestContext: {} },
request: {},
response_size_mb: '1.85',
$payload_ref: expect.stringMatching(
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
),
});

expect(uploadFileSpy).toHaveBeenCalledWith({
bucket: 'the-bucket-list',
Expand Down Expand Up @@ -146,9 +145,7 @@ describe('withLargeResponseHandler', () => {

await middleware.after(requestResponseContext);

expect(JSON.parse(requestResponseContext.response?.body)?.message).toBe(
"Call the API with the HTTP header 'Accept: application/large-response.vnd+json' to receive the payload through an S3 ref and avoid HTTP 500 errors.",
);
expect(JSON.parse(requestResponseContext.response?.body)?.message).toBe(LARGE_RESPONSE_USER_INFO);
expect(requestResponseContext?.response?.statusCode).toBe(413);
});

Expand Down Expand Up @@ -267,4 +264,61 @@ describe('withLargeResponseHandler', () => {
});
});
});

describe('when request header "X-Handle-Large-Response:true" is given', () => {
it('should return 413 and not log ERROR with "Large response detected (limit exceeded)" when content length is over ERROR threshold', async () => {
const middleware = withLargeResponseHandler({
thresholdWarn: 0.5,
thresholdError: 0.9,
sizeLimitInMB: 1,
outputBucket: 'the-bucket-list',
groupRequestsBy: getOrgIdFromContext,
});
const LogErrorSpy = jest.spyOn(Log, 'error');
const content = Buffer.alloc(1024 * 1024, 'a').toString();
const requestResponseContext = {
event: {
requestContext: {
requestId: 'request-id-123',
authorizer: {
lambda: {
organizationId: 'red-redington',
},
},
} as any,
headers: {
'Handle-Large-Response': 'true',
},
} as Partial<Lambda.APIGatewayProxyEventV2>,
response: {
headers: {
random: Buffer.alloc(0.85 * 1024 * 1024, 'a').toString(), // 0.85MB
},
body: content,
},
} as any;

await middleware.after(requestResponseContext);

expect(LogErrorSpy).not.toHaveBeenCalled();
expect(uploadFileSpy).not.toHaveBeenCalled();

const parsedBody = JSON.parse(requestResponseContext.response.body);

expect(requestResponseContext.response).toMatchObject({
isBase64Encoded: false,
statusCode: 413,
headers: {
random: requestResponseContext.response.headers.random,
'content-type': 'application/large-response.vnd+json',
},
});
expect(parsedBody).toMatchObject({
meta: {
content_length_mb: '1.85',
},
message: LARGE_RESPONSE_HANDLED_INFO,
});
});
});
});
31 changes: 29 additions & 2 deletions packages/large-response-middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import Log from '@dazn/lambda-powertools-logger';
import middy from '@middy/core';
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import yn from 'yn';

import { getS3Client } from './s3/s3-client';

Expand All @@ -16,7 +17,9 @@ const TO_MB_FACTOR = 1_048_576.0;
*/
export const LIMIT_REQUEST_SIZE_MB = 6.0;
export const LARGE_RESPONSE_MIME_TYPE = 'application/large-response.vnd+json';
const LARGE_RESPONSE_USER_INFO = `Call the API with the HTTP header 'Accept: ${LARGE_RESPONSE_MIME_TYPE}' to receive the payload through an S3 ref and avoid HTTP 500 errors.`;
export const HANDLE_LARGE_RESPONSE_HEADER = 'handle-large-response';
export const LARGE_RESPONSE_USER_INFO = `Call the API with the HTTP header 'Accept: ${LARGE_RESPONSE_MIME_TYPE}' to receive the payload through an S3 ref and avoid 413 errors or '${HANDLE_LARGE_RESPONSE_HEADER}: true' to acknowledge you can handle the 413.`;
export const LARGE_RESPONSE_HANDLED_INFO = `'${HANDLE_LARGE_RESPONSE_HEADER}: true' received means client can handle this event. The response is too large and can't be returned to the client.`;

export type FileUploadContext = {
bucket: string;
Expand Down Expand Up @@ -64,10 +67,13 @@ export const withLargeResponseHandler = ({
const sizeLimitInMB = (_sizeLimitInMB ?? LIMIT_REQUEST_SIZE_MB) * 1.0;
const thresholdWarnInMB = (thresholdWarn ?? 0.0) * 1.0 * sizeLimitInMB;
const thresholdErrorInMB = (thresholdError ?? 0.0) * 1.0 * sizeLimitInMB;
const clientCanHandleLargeResponseBadRequest = Object.entries(requestHeaders).find(
([header, v]) => header.toLowerCase() === HANDLE_LARGE_RESPONSE_HEADER && yn(v),
);

let $payload_ref = null;

if (contentLengthMB > thresholdWarnInMB) {
if (contentLengthMB > thresholdWarnInMB && !clientCanHandleLargeResponseBadRequest) {
const { url } = await safeUploadLargeResponse({
groupId: String(groupId),
contentType: 'application/json',
Expand Down Expand Up @@ -97,6 +103,27 @@ export const withLargeResponseHandler = ({
response_size_mb: contentLengthMB.toFixed(2),
$payload_ref,
});
} else if (clientCanHandleLargeResponseBadRequest) {
response.isBase64Encoded = false;
response.statusCode = 413;

response.body = JSON.stringify({
meta: {
content_length_mb: contentLengthMB.toFixed(2),
},
message: getCustomErrorMessage(customErrorMessage || LARGE_RESPONSE_HANDLED_INFO, event),
});

response.headers = { ...response.headers, ['content-type']: LARGE_RESPONSE_MIME_TYPE };
Log.info(
`Large response detected (limit exceeded). Client signaled that it can handle large responses via 413. Rewriting response with { metadata, message } `,
{
contentLength: aproxContentLengthBytes,
event,
request: event.requestContext,
response_size_mb: contentLengthMB.toFixed(2),
},
);
} else {
Log.error(`Large response detected (limit exceeded). ${LARGE_RESPONSE_USER_INFO}`, {
contentLength: aproxContentLengthBytes,
Expand Down
Loading