An API Gateway route uses the Lambda proxy (`AWS_PROXY`) integration and every call returns HTTP 502 with `{"message": "Internal server error"}`, yet the function's own CloudWatch logs show it completing without error. What is wrong, and what must a Lambda proxy handler return?
answer
- proxy means no transformation layer
- the return value IS the response
- check the shape, not the logic
- body must already be a string
- statusCode number, body string, isBase64Encoded
basics
~20 sThe function returned a shape API Gateway cannot map to an HTTP response. A Lambda proxy handler must return an object with a numeric statusCode, an optional headers map, and a body that is already a string — returning a raw object or a bare value produces 502.
solid answer
~50 sWith the `AWS_PROXY` integration, API Gateway does no transformation: whatever the function returns *is* the HTTP response, so it must match a fixed shape — `statusCode` as a number, optional `headers` (and `multiValueHeaders` on REST APIs), `body` as a **string**, and `isBase64Encoded` for binary payloads. If the function returns a plain object, forgets `statusCode`, or leaves `body` as an object instead of `JSON.stringify`-ing it, API Gateway cannot build a response and answers 502 with the generic `Internal server error` message — which is why the function's logs look clean. The fix is to stringify the body and return the envelope. One wrinkle: on HTTP APIs using payload format 2.0, a function that returns valid JSON with no `statusCode` is treated as a 200 with that JSON as the body, so the same handler can "work" on an HTTP API and 502 on a REST API.
code
javascript · 17 lines// Correct AWS_PROXY response envelope (payload format 1.0)
export const handler = async (event) => {
try {
const id = event.pathParameters?.id;
if (!id) {
return { statusCode: 400, body: JSON.stringify({ error: "missing id" }) };
}
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }) // string, not an object
};
} catch (err) {
console.error(err);
return { statusCode: 500, body: JSON.stringify({ error: "internal" }) };
}
};go deeper
Memorise the envelope: numeric statusCode, optional headers, and a body you have already stringified. Say plainly that with proxy integration your return value is the HTTP response.
Explain that no transformation layer exists in AWS_PROXY, and separate the failure modes: 502 for malformed or thrown, 504 for integration timeout, 500 with AccessDeniedException for invoke permission.
Demonstrate the diagnosis path — execution logs or $context.integrationErrorMessage in the access log — and show the codified error envelope you would put in a shared handler wrapper so callers never see an opaque 502.
Own the platform contract: one shared response/error helper across services, a house standard on payload format, and log fields chosen so an operator can tell gateway faults from backend faults without reading code.
## What proxy integration actually means API Gateway integrations come in two families. A **non-proxy** (custom) integration lets you write mapping templates that translate the HTTP request into whatever the backend wants, and translate the backend's answer back into an HTTP response. A **proxy** integration deliberately removes that layer: the whole request is handed to the backend as a structured event, and the backend's return value is taken as the whole response. For Lambda that integration type is `AWS_PROXY`. That trade is the source of this failure. Because there is no mapping template to fix things up, the contract on the return value is exact, and any deviation is unmappable. ## The required response shape For the REST-era payload format (format 1.0), the handler must return an object with: - `statusCode` — a **number**, required. - `headers` — optional object of single-valued headers. - `multiValueHeaders` — optional object whose values are arrays, for headers that repeat (`Set-Cookie`). - `body` — a **string**. Not an object, not a number. - `isBase64Encoded` — boolean; when `true`, `body` is base64 and API Gateway decodes it into binary bytes (the media type must also be listed in the API's binary media types). ```javascript export const handler = async (event) => ({ statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: event.pathParameters.id }) }); ``` Return `{ statusCode: 200, body: { id: 1 } }` and you get 502, because `body` is an object. Return `{ id: 1 }` and you get 502, because there is no `statusCode`. ## Why the logs look innocent The function *succeeded*. It ran, logged, and returned. The failure happens afterwards, inside API Gateway, when it tries to turn that return value into an HTTP response. That is exactly why the generic `Internal server error` body is so unhelpful and why the debugging move is to look at the **API Gateway execution logs** (or the access log with `$context.integrationErrorMessage`), not at the function logs. Execution logs will say the response was malformed. ## The 502 / 504 / 500 triage Worth internalising, because interviewers push on it: - **502 Bad Gateway** — the function threw an unhandled error, or returned a shape API Gateway cannot map. - **504 Gateway Timeout** — the integration exceeded API Gateway's limit (tens of seconds), even if the function itself keeps running to its own longer timeout. Raising the Lambda timeout does not help. - **403** — usually authorization or a resource-policy denial, before the integration is reached at all. - **500 with `AccessDeniedException` in the execution log** — API Gateway is not permitted to invoke the target function. ## Payload format 1.0 versus 2.0 REST APIs always use format 1.0. HTTP APIs default to **2.0** and let you pin 1.0. Two differences bite people: 1. The **event** is flatter in 2.0: `event.requestContext.http.method` rather than `event.httpMethod`, `event.rawPath`, `event.rawQueryString`, `cookies` as an array rather than folded into headers. A handler written for a REST API often breaks when copied to an HTTP API. 2. The **response** is more forgiving in 2.0: if the function returns valid JSON without a `statusCode`, API Gateway infers `200` and uses the JSON as the body with `application/json`. Convenient, but it means the strict envelope is a habit you should keep, because the identical handler behind a REST API 502s. ## When to use non-proxy instead Proxy integration puts the HTTP contract in your code, which is usually what you want: the handler is testable, the API definition is thin, and there is no VTL to maintain. Non-proxy integration earns its keep when the backend cannot be changed to speak the envelope — a legacy service, or an `AWS` integration that calls another service directly with no Lambda at all. The price is mapping templates written in Velocity Template Language, plus integration responses that select an HTTP status by regex-matching the error string the backend returned. Teams routinely underestimate how unpleasant that is to own and how hard it is to unit-test, which is why proxy integration is the default recommendation. ## The habit that prevents the bug Wrap every handler's exit in a small helper that builds the envelope, and never return a raw value from business logic. Then a thrown error becomes a deliberate 4xx/5xx envelope with a useful body, instead of an unhandled exception that surfaces to the caller as an opaque 502.
- How do you get a useful error message instead of the generic `Internal server error`?Turn on API Gateway execution logging for the stage, or add `$context.integrationErrorMessage` and `$context.error.message` to the access log format. Those tell you whether the response was malformed, the invocation failed, or permission was denied. Then handle errors inside the function so the caller receives a deliberate status and body rather than an unhandled exception.
- The same handler returns 502 behind a REST API but 200 behind an HTTP API. Why?HTTP APIs default to payload format 2.0, where a function returning valid JSON without a `statusCode` is interpreted as a 200 with that JSON as the body. REST APIs use format 1.0 and require the full envelope, so the missing `statusCode` is unmappable and becomes 502. Always return the explicit envelope so behaviour does not depend on the API type.
- When would you deliberately use a non-proxy Lambda integration instead?When the backend's contract cannot change — a legacy function or another AWS service you are calling directly with no Lambda at all — and you need mapping templates to reshape request and response. You pay for it in VTL you must maintain and integration responses that pick a status code by regex-matching the error string, which is much harder to test than code.
saying these in an interview costs you the question
- Blames the function's business logic when the shape is wrong
- Returns the body as an object instead of a string
- Thinks a 502 always means the backend crashed
- Assumes raising the Lambda timeout cures a 504
- Believes proxy integration still applies mapping templates