Skip to content

Commit 53dba64

Browse files
authored
Merge pull request #3 from epilot-dev/feat.support-client-handling-of-large-responses
feat: support client handling of large responses
2 parents 2088624 + 42223e7 commit 53dba64

File tree

9 files changed

+736
-620
lines changed

9 files changed

+736
-620
lines changed

package-lock.json

Lines changed: 598 additions & 583 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/large-response-middleware/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ When a client can handle a Large Response, it must send a request with the HTTP
2323

2424
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`.
2525

26+
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.
27+
2628
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).
2729

2830
### Middleware Configuration:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
presets: [
3+
['@babel/preset-env', { targets: { node: 'current' } }],
4+
],
5+
};

packages/large-response-middleware/docs/architecture-1.plantuml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ Client -> "API Gateway": Request data with/without header
99
"API Gateway" -> Lambda: Invoke Lambda
1010

1111
alt If "Accept: application:large-response.vnd+json" is present
12+
Lambda -> Lambda: Logs info event
1213
Lambda -> "S3 Bucket": Save large response
1314
"S3 Bucket" -> Lambda: Return S3 ref
1415
Lambda -> "API Gateway": Return S3 ref `{ $payload_ref: "s3://..." }`
1516
"API Gateway" -> Client: Return $payload_ref
17+
else If "Handle-Large-Request: true" is present
18+
Lambda -> Lambda: Logs info event
19+
Lambda -> Lambda: Skips S3 content dump
20+
Lambda -> Client: Response 413 (Payload Too Large)
1621
else If request header is not present
22+
Lambda -> Lambda: Logs error event
1723
Lambda -> "API Gateway": Response 500 (Internal Server Error)
1824
"API Gateway" -> Client: Response 413 (Payload Too Large)
1925
end

packages/large-response-middleware/jest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const config: Config.InitialOptions = {
1414
maxWorkers: 1, // run tests sequentially
1515
moduleDirectories: ['node_modules', 'src'],
1616
modulePaths: ['node_modules'],
17+
transform: {
18+
'^.+\\.js$': 'babel-jest',
19+
},
20+
transformIgnorePatterns: ['/node_modules/(?!(yn)/)'],
21+
extensionsToTreatAsEsm: ['.ts'],
1722
};
1823

1924
export default config;

packages/large-response-middleware/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/large-response-middleware/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@epilot/large-response-middleware",
3-
"version": "0.0.11",
3+
"version": "0.0.15",
44
"license": "MIT",
55
"repository": {
66
"type": "git",
@@ -40,14 +40,14 @@
4040
},
4141
"size-limit": [
4242
{
43-
"limit": "2 kB",
43+
"limit": "3 kB",
4444
"path": "lib/index.js"
4545
}
4646
],
4747
"devDependencies": {
4848
"@babel/cli": "^7.23.4",
49-
"@babel/core": "^7.23.6",
50-
"@babel/preset-env": "^7.23.6",
49+
"@babel/core": "^7.25.2",
50+
"@babel/preset-env": "^7.25.4",
5151
"@babel/preset-typescript": "^7.23.3",
5252
"@dazn/lambda-powertools-logger": "^1.28.1",
5353
"@middy/core": "^2.5.7",
@@ -62,6 +62,7 @@
6262
"@types/node": "^20.10.5",
6363
"aws-lambda": "^1.0.7",
6464
"aws-sdk": "^2.816.0",
65+
"babel-jest": "^29.7.0",
6566
"cross-env": "^7.0.3",
6667
"jest": "^29.7.0",
6768
"jest-junit": "^16.0.0",
@@ -82,6 +83,7 @@
8283
"aws-sdk": "^2.816.0"
8384
},
8485
"dependencies": {
85-
"core-js": "^3.34.0"
86+
"core-js": "^3.34.0",
87+
"yn": "^5.0.0"
8688
}
8789
}

packages/large-response-middleware/src/index.test.ts

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import * as Lambda from 'aws-lambda';
55
import { getOrgIdFromContext } from './__tests__/util';
66

77
import * as middleware from './';
8-
import { LARGE_RESPONSE_MIME_TYPE, withLargeResponseHandler } from './';
8+
import {
9+
LARGE_RESPONSE_HANDLED_INFO,
10+
LARGE_RESPONSE_MIME_TYPE,
11+
LARGE_RESPONSE_USER_INFO,
12+
withLargeResponseHandler,
13+
} from './';
914

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

66-
expect(LogWarnSpy).toHaveBeenCalledWith(
67-
"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.",
68-
{
69-
contentLength: 1572872,
70-
event: { requestContext: {} },
71-
request: {},
72-
response_size_mb: '1.50',
73-
$payload_ref: expect.stringMatching(
74-
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
75-
),
76-
},
77-
);
71+
expect(LogWarnSpy).toHaveBeenCalledWith(`Large response detected. ${LARGE_RESPONSE_USER_INFO}`, {
72+
contentLength: 1572872,
73+
event: { requestContext: {} },
74+
request: {},
75+
response_size_mb: '1.50',
76+
$payload_ref: expect.stringMatching(
77+
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
78+
),
79+
});
7880
});
7981

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

102104
await middleware.after(requestResponseContext);
103105

104-
expect(LogErrorSpy).toHaveBeenCalledWith(
105-
"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.",
106-
{
107-
contentLength: 1939873,
108-
event: { requestContext: {} },
109-
request: {},
110-
response_size_mb: '1.85',
111-
$payload_ref: expect.stringMatching(
112-
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
113-
),
114-
},
115-
);
106+
expect(LogErrorSpy).toHaveBeenCalledWith(`Large response detected (limit exceeded). ${LARGE_RESPONSE_USER_INFO}`, {
107+
contentLength: 1939873,
108+
event: { requestContext: {} },
109+
request: {},
110+
response_size_mb: '1.85',
111+
$payload_ref: expect.stringMatching(
112+
/http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/,
113+
),
114+
});
116115

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

147146
await middleware.after(requestResponseContext);
148147

149-
expect(JSON.parse(requestResponseContext.response?.body)?.message).toBe(
150-
"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.",
151-
);
148+
expect(JSON.parse(requestResponseContext.response?.body)?.message).toBe(LARGE_RESPONSE_USER_INFO);
152149
expect(requestResponseContext?.response?.statusCode).toBe(413);
153150
});
154151

@@ -267,4 +264,61 @@ describe('withLargeResponseHandler', () => {
267264
});
268265
});
269266
});
267+
268+
describe('when request header "X-Handle-Large-Response:true" is given', () => {
269+
it('should return 413 and not log ERROR with "Large response detected (limit exceeded)" when content length is over ERROR threshold', async () => {
270+
const middleware = withLargeResponseHandler({
271+
thresholdWarn: 0.5,
272+
thresholdError: 0.9,
273+
sizeLimitInMB: 1,
274+
outputBucket: 'the-bucket-list',
275+
groupRequestsBy: getOrgIdFromContext,
276+
});
277+
const LogErrorSpy = jest.spyOn(Log, 'error');
278+
const content = Buffer.alloc(1024 * 1024, 'a').toString();
279+
const requestResponseContext = {
280+
event: {
281+
requestContext: {
282+
requestId: 'request-id-123',
283+
authorizer: {
284+
lambda: {
285+
organizationId: 'red-redington',
286+
},
287+
},
288+
} as any,
289+
headers: {
290+
'Handle-Large-Response': 'true',
291+
},
292+
} as Partial<Lambda.APIGatewayProxyEventV2>,
293+
response: {
294+
headers: {
295+
random: Buffer.alloc(0.85 * 1024 * 1024, 'a').toString(), // 0.85MB
296+
},
297+
body: content,
298+
},
299+
} as any;
300+
301+
await middleware.after(requestResponseContext);
302+
303+
expect(LogErrorSpy).not.toHaveBeenCalled();
304+
expect(uploadFileSpy).not.toHaveBeenCalled();
305+
306+
const parsedBody = JSON.parse(requestResponseContext.response.body);
307+
308+
expect(requestResponseContext.response).toMatchObject({
309+
isBase64Encoded: false,
310+
statusCode: 413,
311+
headers: {
312+
random: requestResponseContext.response.headers.random,
313+
'content-type': 'application/large-response.vnd+json',
314+
},
315+
});
316+
expect(parsedBody).toMatchObject({
317+
meta: {
318+
content_length_mb: '1.85',
319+
},
320+
message: LARGE_RESPONSE_HANDLED_INFO,
321+
});
322+
});
323+
});
270324
});

packages/large-response-middleware/src/index.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'path';
33
import Log from '@dazn/lambda-powertools-logger';
44
import middy from '@middy/core';
55
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
6+
import yn from 'yn';
67

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

@@ -16,7 +17,9 @@ const TO_MB_FACTOR = 1_048_576.0;
1617
*/
1718
export const LIMIT_REQUEST_SIZE_MB = 6.0;
1819
export const LARGE_RESPONSE_MIME_TYPE = 'application/large-response.vnd+json';
19-
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.`;
20+
export const HANDLE_LARGE_RESPONSE_HEADER = 'handle-large-response';
21+
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.`;
22+
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.`;
2023

2124
export type FileUploadContext = {
2225
bucket: string;
@@ -64,10 +67,13 @@ export const withLargeResponseHandler = ({
6467
const sizeLimitInMB = (_sizeLimitInMB ?? LIMIT_REQUEST_SIZE_MB) * 1.0;
6568
const thresholdWarnInMB = (thresholdWarn ?? 0.0) * 1.0 * sizeLimitInMB;
6669
const thresholdErrorInMB = (thresholdError ?? 0.0) * 1.0 * sizeLimitInMB;
70+
const clientCanHandleLargeResponseBadRequest = Object.entries(requestHeaders).find(
71+
([header, v]) => header.toLowerCase() === HANDLE_LARGE_RESPONSE_HEADER && yn(v),
72+
);
6773

6874
let $payload_ref = null;
6975

70-
if (contentLengthMB > thresholdWarnInMB) {
76+
if (contentLengthMB > thresholdWarnInMB && !clientCanHandleLargeResponseBadRequest) {
7177
const { url } = await safeUploadLargeResponse({
7278
groupId: String(groupId),
7379
contentType: 'application/json',
@@ -97,6 +103,27 @@ export const withLargeResponseHandler = ({
97103
response_size_mb: contentLengthMB.toFixed(2),
98104
$payload_ref,
99105
});
106+
} else if (clientCanHandleLargeResponseBadRequest) {
107+
response.isBase64Encoded = false;
108+
response.statusCode = 413;
109+
110+
response.body = JSON.stringify({
111+
meta: {
112+
content_length_mb: contentLengthMB.toFixed(2),
113+
},
114+
message: getCustomErrorMessage(customErrorMessage || LARGE_RESPONSE_HANDLED_INFO, event),
115+
});
116+
117+
response.headers = { ...response.headers, ['content-type']: LARGE_RESPONSE_MIME_TYPE };
118+
Log.info(
119+
`Large response detected (limit exceeded). Client signaled that it can handle large responses via 413. Rewriting response with { metadata, message } `,
120+
{
121+
contentLength: aproxContentLengthBytes,
122+
event,
123+
request: event.requestContext,
124+
response_size_mb: contentLengthMB.toFixed(2),
125+
},
126+
);
100127
} else {
101128
Log.error(`Large response detected (limit exceeded). ${LARGE_RESPONSE_USER_INFO}`, {
102129
contentLength: aproxContentLengthBytes,

0 commit comments

Comments
 (0)