From 11e34c347dfdab2e978cd63cd4bf099b1b8ebd8a Mon Sep 17 00:00:00 2001 From: Viljami + Claude Date: Tue, 16 Jun 2026 08:58:03 +0000 Subject: [PATCH] fix(large-response): measure serialized envelope to prevent 413s The middleware estimated response size from the raw body bytes (body + header string). AWS Lambda enforces its 6MB limit against the fully serialized proxy-response envelope, in which the body is embedded as a JSON string (every '"', '\' and control char escaped) plus statusCode/ headers overhead. That serialization inflates the real payload ~5-15% above the measured value, so a response in the warn band could still exceed 6MB once the runtime serialized it and fail with LAMBDA_RUNTIME RequestEntityTooLarge (413) before the offload/rewrite path triggered. Measure Buffer.byteLength(JSON.stringify(response)) so the estimate matches what the runtime actually checks. Adds a regression test for a body that is under the raw-byte threshold but exceeds it once escaped. Co-authored-by: Claude --- .../large-response-middleware/package.json | 2 +- .../src/index.test.ts | 53 ++++++++++++++++++- .../large-response-middleware/src/index.ts | 19 ++++--- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/large-response-middleware/package.json b/packages/large-response-middleware/package.json index c175585..bcde3dd 100644 --- a/packages/large-response-middleware/package.json +++ b/packages/large-response-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@epilot/large-response-middleware", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "repository": { "type": "git", diff --git a/packages/large-response-middleware/src/index.test.ts b/packages/large-response-middleware/src/index.test.ts index 6adbfd8..4610746 100644 --- a/packages/large-response-middleware/src/index.test.ts +++ b/packages/large-response-middleware/src/index.test.ts @@ -73,7 +73,7 @@ describe('withLargeResponseHandler', () => { } as any); expect(mockLogger.warn).toHaveBeenCalledWith(`Large response detected. ${LARGE_RESPONSE_USER_INFO}`, { - contentLength: 1572872, + contentLength: 1572899, event: { requestContext: {} }, request: {}, response_size_mb: '1.50', @@ -111,7 +111,7 @@ describe('withLargeResponseHandler', () => { expect(mockLogger.error).toHaveBeenCalledWith( `Large response detected (limit exceeded). ${LARGE_RESPONSE_USER_INFO}`, { - contentLength: 1939873, + contentLength: 1939900, event: { requestContext: {} }, request: {}, response_size_mb: '1.85', @@ -219,6 +219,55 @@ describe('withLargeResponseHandler', () => { expect(requestResponseContext?.response?.statusCode).toBe(413); }); + it('should account for JSON-envelope escaping overhead when a body is under the raw-byte threshold but exceeds it once serialized', async () => { + const middleware = withLargeResponseHandler({ + thresholdWarn: 0.5, + thresholdError: 0.9, + sizeLimitInMB: 1, + outputBucket: 'the-bucket-list', + groupRequestsBy: getOrgIdFromContext, + }); + + // 500_000 double-quotes => 500_000 raw bytes (~0.48MB, below both + // thresholds), but each '"' is escaped to '\\"' when the body is embedded + // in the JSON response envelope, so the serialized payload is ~0.95MB and + // exceeds the ERROR threshold. This is the production scenario that caused + // RequestEntityTooLarge (413) when only the raw body was measured. + const content = '"'.repeat(500_000); + const requestResponseContext = { + event: { + requestContext: { + requestId: 'request-id-123', + authorizer: { lambda: { organizationId: 'red-redington' } }, + // biome-ignore lint/suspicious/noExplicitAny: + } as any, + headers: { + Accept: LARGE_RESPONSE_MIME_TYPE, + }, + } as Partial, + response: { + body: content, + }, + // biome-ignore lint/suspicious/noExplicitAny: + } as any; + + await middleware.after(requestResponseContext); + + expect(uploadFileMock).toHaveBeenCalledWith({ + bucket: 'the-bucket-list', + content, + contentType: 'application/json', + fileName: 'request-id-123', + groupId: 'red-redington', + }); + expect(requestResponseContext.response.headers?.['content-type']).toBe(LARGE_RESPONSE_MIME_TYPE); + expect(JSON.parse(requestResponseContext.response.body)).toMatchObject({ + $payload_ref: expect.stringMatching( + /http:\/\/localhost:4566\/the-bucket-list\/red-redington\/\d+-\d+-\d+\/la-caballa/, + ), + }); + }); + describe('when request header "Accept":"application/large-response.vnd+json" is given', () => { it('should not log ERROR with "Large response detected (limit exceeded)" when content length is over ERROR threshold', async () => { const middleware = withLargeResponseHandler({ diff --git a/packages/large-response-middleware/src/index.ts b/packages/large-response-middleware/src/index.ts index c0242d6..32da3f5 100644 --- a/packages/large-response-middleware/src/index.ts +++ b/packages/large-response-middleware/src/index.ts @@ -53,14 +53,17 @@ export const withLargeResponseHandler = ({ try { const groupId = groupRequestsBy?.(handlerRequestContext.event) || 'all'; const awsRequestId = handlerRequestContext.event.requestContext?.requestId; - const responseHeadersString = response.headers - ? Object.entries(response.headers) - .map(([h, v]) => `${h}: ${v}`) - .join(' ') - : ''; - const payload = (handlerRequestContext?.response?.body ?? '') + responseHeadersString; - - const aproxContentLengthBytes = Buffer.byteLength(payload, 'utf8'); + + // AWS Lambda enforces its payload limit against the *fully serialized* + // proxy-response envelope (statusCode + headers + body), measured in + // UTF-8 bytes. The body is embedded as a JSON string, so every '"', + // '\\' and control char inside it is escaped, and the envelope adds its + // own structural overhead. Measuring only the raw body (or body + + // header string) under-counts this, which means a response that fits + // the threshold here can still exceed the limit once the runtime + // serializes it -> RequestEntityTooLarge (413). Measure the serialized + // envelope so our estimate matches what the runtime actually checks. + const aproxContentLengthBytes = response ? Buffer.byteLength(JSON.stringify(response), 'utf8') : 0; const contentLengthMB = aproxContentLengthBytes > 0 ? aproxContentLengthBytes / TO_MB_FACTOR : 0.0; const sizeLimitInMB = (_sizeLimitInMB ?? LIMIT_REQUEST_SIZE_MB) * 1.0; const thresholdWarnInMB = (thresholdWarn ?? 0.0) * 1.0 * sizeLimitInMB;