How to Fix AWS API Gateway 502 Bad Gateway: Lambda Proxy Response Shape Rule
A 502 Bad Gateway from an API Gateway Lambda proxy integration almost always means your function ran fine and returned something that looked correct to you, but didn't match the one JSON shape API Gateway is contractually allowed to parse — and the counterintuitive part is that a beautifully formed, perfectly valid JSON object is still a malformed response if the fields around it (statusCode as a string, a body that's an object instead of a string, a missing key) don't match that shape exactly.
Jake found this out the expensive way. A customer walked into his phone shop wanting a trade-in quote pulled from a little internal tool Jake had built — a Lambda function behind API Gateway that looked up a device model and spat back a price. It had worked all week. Then, on a Saturday, with the customer standing at the counter, it started throwing 502 Bad Gateway on every third request. Jake refreshed it four times before giving up and quoting the price from memory, which turned out to be $40 too low. He didn't find out about the $40 until his supplier flagged the mismatch two days later, which meant eating the difference himself rather than passing it on to a customer who'd already walked out the door with a signed trade-in slip.
🙋♂️ Jake's Reality Check
"My function didn't error. CloudWatch shows it ran, returned a 200, and finished in 40 milliseconds. How is that a Bad Gateway?"
Because "ran successfully" and "returned something API Gateway can use" are two different things. Your code can execute perfectly and still hand back a JSON object that isn't in the one shape API Gateway's proxy integration is built to parse — and when that happens, API Gateway doesn't try to guess what you meant. It gives up and returns a 502 to the caller, with no detail about why.
⚡ MUST-READ LAMBDA PROXY & PERFORMANCE GUIDES
- ⚡ Fix AWS Lambda Cold Starts & Reduce API Latency
- π AWS Serverless Architecture: Lambda Proxy vs Integration Limits
- π️ Deploy Lambda Integration Rules Cleanly via AWS IaC
- π Secure Your Lambda Proxy API Endpoints with Amazon Cognito
- π AWS Auto Scaling Guide: Handling Spikes in API Traffic
The exact shape API Gateway requires
Here's the piece almost nobody explains clearly: API Gateway doesn't run your code, inspect your logic, or care what your function is trying to do. All it does is take whatever your Lambda function returns and try to fit it into a very narrow template. If it fits, the caller gets your response. If it doesn't fit — even by one wrong data type on one field — API Gateway throws its hands up and returns a 502 Bad Gateway, and your actual response (the one you spent time crafting) never makes it to the client at all.
For a REST API with Lambda proxy integration (the older, "v1" style, still extremely common), the required output looks like this:
{
"isBase64Encoded": false,
"statusCode": 200,
"headers": { "Content-Type": "application/json" },
"multiValueHeaders": { "Set-Cookie": ["a=1", "b=2"] },
"body": "{\"price\": 120}"
}
Notice that body is a string. Not a JSON object — a string that happens to contain JSON text. That single detail is, by a wide margin, the most common cause of this whole error. A function that returns { "price": 120 } as the body field — a real object, not a stringified one — passes every code review, works fine when you call the function directly, and then breaks the instant API Gateway tries to parse it, because API Gateway is expecting text it can hand straight to the client, not a nested object it has to interpret.
statusCode has to be an HTTP status code API Gateway recognizes — a number like 200, 400, or 500, not the string "200" in quotes, and not missing entirely. headers and multiValueHeaders can both be left out if you have nothing extra to send back, but if you include them, headers can only hold single values, while multiValueHeaders can hold arrays — and if the same header name shows up in both, API Gateway merges them and lets the multiValueHeaders version win.
✅ Why this is the one to trust
If the function output isn't in this exact format, API Gateway returns a 502 — full stop, no partial credit. That's not a guess or a community workaround; it's the documented behavior. So the fastest fix for almost every case of this error is comparing what your function actually returned against this shape, field by field, rather than re-reading your business logic seven times looking for a bug that isn't there.
HTTP APIs bend the rule — and that's its own trap
If you're on the newer HTTP API type (not REST API) with a Lambda proxy integration, there's a second wrinkle: HTTP APIs support two different "payload format versions," and they behave differently on the response side.
With format version 1.0, an HTTP API expects the exact same strict shape as a REST API — statusCode, headers, stringified body, the works. With format version 2.0, API Gateway gets more forgiving: if your function returns valid JSON and doesn't include a statusCode field at all, API Gateway assumes you meant a 200 response and wraps your JSON into the body for you automatically. That's genuinely convenient — right up until you have one function shared across a REST API and an HTTP API, or you copy a working handler from one integration type to the other, and the "convenient" version 2.0 shortcut silently stops applying because the target integration is pinned to 1.0.
| Integration | Response shape required | Forgiving of a raw object body? |
|---|---|---|
| REST API (proxy) | statusCode + headers + stringified body | No — strict, always |
| HTTP API, format 1.0 | Same strict shape as REST API | No |
| HTTP API, format 2.0 | Same shape, OR just your data with no statusCode/body wrapper | Yes — if no statusCode is present, it's inferred |
One more difference worth knowing before you go hunting for a missing field: format 2.0 doesn't have separate multiValueHeaders at all. Duplicate headers just get combined with commas into the single headers field, and there's a dedicated cookies array instead of stuffing cookies into headers manually.
The fast diagnostic: stop guessing, turn on logging
Every hour spent re-reading Lambda code, hoping to spot a formatting bug by eye, is an hour you didn't need to spend. API Gateway execution logging will show you the literal response your function returned, before API Gateway tried and failed to interpret it — which turns this from a guessing game into a five-minute read.
- In the API Gateway console, open your API, go to the relevant stage, and enable CloudWatch Logs at the INFO log level (for REST APIs this is under Stage settings → Logs/Tracing; for HTTP APIs it's a logging destination on the stage itself).
- Send the failing request again, either from the client that's breaking or by using the Test button on the method in the console.
- Open the matching CloudWatch log group — it's named after your API ID and stage — and search for the request ID or simply the string "502" or "Malformed".
- Look for a line that says Execution failed due to configuration error: Malformed Lambda proxy response, followed by Endpoint response body before transformations — that second line is your function's actual raw output.
- Compare that raw output, field by field, against the required shape above.
If your logs instead show Execution failed due to a timeout error, you're not looking at a shape problem at all — skip ahead to the section on 504s below, because that's a different failure with a different fix.
Reading the $context access-log variables for faster triage
Execution logs give you the raw response body, but if you turn on custom access logging with the right $context variables, you can see the shape of the failure without opening a single log line in full. Two variables do most of the work: $context.integrationStatus, which for a Lambda proxy integration is literally the statusCode your function returned (so a value here at all tells you the function did return something structurally readable), and $context.error.message, which carries API Gateway's own description of what went wrong — Malformed Lambda proxy response reads very differently from Endpoint request timed out, and you can filter a whole access log down to just the ones that say one or the other.
Wiring an access log format string like $context.requestId $context.status $context.integrationStatus $context.error.message onto a stage turns a pile of individual request logs into something you can grep or query in CloudWatch Logs Insights across thousands of requests at once — genuinely useful once this is happening in production more than once, rather than re-reading one execution log at a time.
This is also the fastest way to answer the question a manager or a teammate will eventually ask mid-incident: "is this happening to everyone, or just one endpoint?" A single CloudWatch Logs Insights query filtering on $context.status = 502 across the whole access log, grouped by resourcePath, answers that in seconds instead of a guess based on whichever complaint reached you first.
Cause 1: body is an object, not a string
This is the single most common cause, so it earns its own section instead of a bullet point. In Node.js, the fix is one function call:
// Wrong — body is a live object
return { statusCode: 200, body: { price: 120 } };
// Right — body is a JSON-formatted string
return { statusCode: 200, body: JSON.stringify({ price: 120 }) };
JSON.stringify — what that actually does
If you haven't run into it before: JSON stands for JavaScript Object Notation, and JSON.stringify() is a built-in function that takes a live JavaScript object sitting in memory and converts it into plain text that looks like that object — the same way a recipe card is a piece of paper describing a dish, not the dish itself. API Gateway wants the recipe card (text it can hand to the caller), not the dish (a live object it would have to interpret).
Node.js isn't the only place this bites people. Python's equivalent is json.dumps() — a dictionary returned straight as "body": price_dict fails the exact same way a raw object does in Node.js. In Go, the equivalent mistake is populating the events.APIGatewayProxyResponse struct's Body field with something that never went through json.Marshal.
⚠️ What this actually breaks
Because this is a data-type mismatch and not a syntax error, nothing in your code, your linter, or your local test run will catch it. Your function will pass unit tests that call the handler directly, because the handler really does return valid-looking data — it only fails once API Gateway tries to serialize the response on the wire, which means it's very easy to ship this straight to production behind a green test suite.
Cause 2: statusCode is missing, or the wrong type
On a REST API or an HTTP API pinned to payload format 1.0, statusCode isn't optional, and it isn't a string. "statusCode": "200" — with quotes around the number — is a different data type than "statusCode": 200, and that difference alone is enough to trigger a 502 in some runtimes. This shows up most often when a value gets pulled from an environment variable, a query string, or another API's response — all of which hand you strings by default — and gets passed straight through without being converted to a real number first.
If your framework leaves statusCode out entirely on a REST API or format-1.0 HTTP API (rather than the format-2.0 case where that's allowed), that's also a malformed response, not a default-to-200 situation. Set it explicitly, every time, on every code path — including the error-handling ones people tend to forget.
Cause 3: an uncaught exception, or a rejected promise
Not every 502 is a formatting bug — some of them are your function genuinely failing, and API Gateway is just the messenger. If the Lambda function runs but throws an error, or an async handler's promise rejects and nobody catches it, API Gateway treats that exactly the same as a malformed response: it can't build a valid HTTP response from an exception, so it returns 502 with the generic body {"message": "Internal server error"}.
The tell here, versus a formatting bug, is in your Lambda function's own CloudWatch log group (not the API Gateway one): you'll see a stack trace, an "Unhandled error" line, or a non-zero exit that never happened in the shape-mismatch cases, because those functions genuinely finished and returned successfully — they just returned the wrong shape.
🙋♂️ Jake's Reality Check
"So if I just wrap the whole handler in a try/catch, does the 502 go away?"
Only if the catch block returns a properly shaped response instead of just swallowing the error. A bare catch (e) { console.log(e); } with nothing returned afterward leaves the function returning undefined — which is itself a malformed response, so you'd trade one 502 for another. The catch block has to return { statusCode: 500, body: JSON.stringify({ error: "..." }) }, same rules as every other code path.
502 vs 500 vs 504 — they are not the same failure
Ethan's take is blunt on this one: "People treat every 5xx from API Gateway as one problem with one fix, and that's exactly why they burn an afternoon on it. Read which number you actually got — it tells you which layer to look at."
| Status | What it means | Where to look |
|---|---|---|
| 500 | Lambda's API rejected the invocation itself | IAM/resource policy, function not found, throttling |
| 502 | Function ran, but errored or returned the wrong shape | Your handler code, and its return value |
| 504 | Function didn't finish before the integration timeout | Function duration vs. the 29-second default (up to 90s configurable) |
In both the 500 and 502 case, API Gateway hands the caller the exact same generic body — {"message": "Internal server error"} — which is exactly why the status code itself, not the body text, is the only reliable signal for which bucket you're in.
When it's actually a permissions problem wearing a 502 costume
If API Gateway can't invoke your function at all — the resource-based policy on the Lambda function doesn't grant API Gateway permission, or the ARN in the integration is stale after a redeploy — that's the 500 case above, not a shape problem, and no amount of fixing your return statement will touch it. The execution log line to watch for here reads along the lines of Invalid permissions on Lambda function rather than anything about a malformed response.
The most common way this happens is deploying a Lambda function under a new name or a new alias/version and forgetting that the resource policy granting API Gateway invoke access was scoped to the old ARN. Re-deploying through the console usually re-adds the permission automatically; re-deploying through raw CloudFormation, Terraform, or the CLI sometimes doesn't, depending on how the integration was defined.
Confirming this takes one command rather than guesswork: aws lambda get-policy --function-name your-function-name shows the current resource policy, and you're looking for a statement with "Service": "apigateway.amazonaws.com" and a SourceArn that matches your actual, current API and stage — not an old stage name, not an old API ID left over from a prior deployment. If that statement is missing or points at the wrong ARN, aws lambda add-permission with the correct source ARN adds it back without needing to touch your function's code at all.
Timeouts: why the fix sometimes has nothing to do with the response shape
REST APIs default to a 29-second integration timeout, which cannot be raised past that in the console without also raising it as a stage-level and account-level service quota — and even then it tops out at 90 seconds for regional and private REST APIs without a support ticket. If your Lambda function is doing genuinely slow work (a large database scan, an image render, calling several other services in sequence), it can time out before it ever gets the chance to return a badly or well-formed response — and that shows up as a 504, not a 502.
⚠️ What this actually breaks
A common mistake is "fixing" a 504 by raising the Lambda function's own timeout setting without touching API Gateway's integration timeout. If your Lambda timeout is longer than API Gateway's integration timeout, API Gateway simply gives up first and returns 504 anyway — your function keeps running in the background, burning compute time, for a response nobody will ever see.
Binary responses and isBase64Encoded
If your function returns anything other than plain text — an image, a PDF, a zip file — the body has to be a Base64-encoded string, and isBase64Encoded has to be explicitly set to true. Skip that flag and API Gateway will try to treat your binary data as plain text, which corrupts the file even when it doesn't outright 502. On top of the flag, the REST API also needs */* (or your specific content type) configured as a binary media type on the API itself — the flag alone isn't enough.
For JSON responses, the safest default is leaving isBase64Encoded set to false rather than leaving it out — it's not strictly required for text responses, but an explicit false costs nothing and removes one more variable when you're debugging later.
There's a second, quieter way binary responses fail even when isBase64Encoded is set correctly: API Gateway decides whether to treat a response as binary using the first value in the client's Accept header, and only that first value. A browser rendering an <img> tag might send an Accept header like image/webp,image/*,*/*;q=0.8 — if image/webp isn't in your API's binary media types list, API Gateway won't treat the response as binary at all, even though your function did everything right, and the image comes through corrupted. Setting the binary media type to */* sidesteps needing to control that ordering, at the cost of telling API Gateway to treat every content type as potentially binary.
Worth knowing the ceiling here too: binary payloads through a Lambda proxy integration are capped at 10 MB total, which is a separate limit from the roughly 6 MB response-payload limit on a plain synchronous Lambda invocation.
Framework wrappers: Flask, Express, and the layer you didn't write
If you're not returning the response shape by hand — you're using something like Mangum for a Python ASGI app, serverless-http for an Express app, or the AWS Lambda Powertools event handlers — the shape still has to come out right on the other side, but now there's a translation layer in between your code and API Gateway that can be the actual source of the mismatch, not your route handlers.
| Wrapper | Common trap |
|---|---|
| Mangum (Python ASGI/FastAPI/Flask) | Not passing api_gateway_base_path or the wrong lifespan mode; also mismatched payload format version handling between REST and HTTP APIs |
| serverless-http (Express/Koa) | A middleware that writes directly to the raw Node.js response stream instead of returning through Express's normal response object |
| AWS Lambda Powertools event handlers | CORS headers added by hand alongside the built-in CORS handling, colliding on the same header name |
The fix in every one of these cases is the same diagnostic from earlier in this post: read the actual raw output in the API Gateway execution log, not what you assume the wrapper library is producing. A wrapper library abstracts the shape away from you right up until the moment it's the thing that's wrong.
Canary deployments and aliases: why the bug only shows up on 10% of traffic
If your Lambda function is fronted by an alias with a canary weight — say, 90% of traffic on a stable version and 10% on a new one you're rolling out — a shape bug introduced in the new version will only 502 on that 10% slice, which makes it look like a flaky, load-related problem rather than what it actually is: one specific version of the code, doing exactly what it always does, every single time it's invoked.
The fix here isn't technical, it's procedural: when you see intermittent 502s during or shortly after a canary rollout, check which Lambda version actually served the failing request before looking anywhere else. CloudWatch Logs group by log stream per concurrent execution environment, and the function version is available in the request's log stream name and in structured logs if you're emitting them — cross-referencing that against your canary weights turns "sometimes it fails" into "it fails whenever version 12 handles the request," which is a much smaller problem to hold in your head.
Java, .NET, and Ruby: same rule, different footgun
Everything above uses Node.js and Python because that's where most people hit this first, but the underlying rule — statusCode as a real integer, body as a real string — is identical no matter which Lambda runtime you're writing in. What changes is which specific line of code accidentally breaks it.
In Java, a handler typically implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> and builds the response with something like new APIGatewayProxyResponseEvent().withStatusCode(200).withBody(...). The trap here is calling .toString() on a plain Java object instead of running it through a real JSON serializer like Jackson's ObjectMapper. Java's default toString() often produces something like com.example.Quote@4aa298b7 — a memory address, not JSON — which is technically a string, so it satisfies the type checker, and still produces a 502 because it isn't valid JSON on the other end.
In C# on .NET, the APIGatewayProxyResponse object's Body property is already typed as a string, so the compiler catches the "forgot to serialize" mistake earlier than Java or Node.js would — but a different trap shows up instead: if you're returning a typed object directly from the handler rather than an APIGatewayProxyResponse, you need the Amazon.Lambda.Serialization.SystemTextJson (or Json.NET) serializer package wired in, or the runtime won't know how to translate your return type into the shape API Gateway expects at all.
In Ruby, the response is a plain hash, and the trap is subtler: a hash built with symbol keys, like { statusCode: 200, body: "..." }, versus string keys, like { "statusCode" => 200, "body" => "..." }, has caused inconsistent parsing depending on runtime version and how the hash gets serialized before it leaves the function. String keys matching the documented field names exactly are the safer default across Ruby Lambda runtimes.
🙋♂️ Jake's Reality Check
"I hired someone to write the checkout function in Java because that's what they knew. Does everything you said about Node.js still apply?"
The contract with API Gateway doesn't care what language wrote the response — it only ever sees the JSON that comes out the other end. Every rule about statusCode's type and body being a real string applies exactly the same to a Java response as a Node.js one; only the specific line of code that breaks it looks different.
Why the 502 only happens sometimes, not every time
This is the pattern that sent Jake's Saturday sideways. Intermittent 502s, rather than constant ones, are usually a sign that one specific code path is malformed while the "happy path" isn't. A function that returns the correct shape on success but forgets to shape the error branch the same way will look perfectly healthy in testing — because you're testing the success path — and then start failing the moment a real edge case (a missing parameter, a downstream service hiccup, a null value nobody expected) routes the request through the untested branch.
The other common intermittent cause is a cold start racing a timeout: the first invocation after a period of inactivity takes noticeably longer to initialize, and if that extra time pushes total execution past the integration timeout only on cold starts, you'll see occasional 504s that masquerade as random 502s if you're not reading the status code carefully.
CORS headers vanishing the moment you "fix" the shape
To enable CORS on a Lambda proxy integration, the header Access-Control-Allow-Origin has to be added inside your function's own headers object — API Gateway's built-in CORS configuration (the one you toggle in the console for other integration types) doesn't apply the same way once you're in proxy mode, because your function fully owns the response. A very common sequence: someone fixes a 502 by correcting the body-stringification bug, redeploys, the 502 disappears, and now the browser reports a CORS failure instead — because the error branch that was fixed didn't carry the same CORS headers the success branch had all along.
The fix is boring but complete: every returned response — success and every error branch — needs the same CORS headers, not just the one path you happened to test first.
A complete before/after, side by side, in three runtimes
Reading the rule is one thing; seeing a full handler go from broken to correct is another. Here's the same trade-in price lookup — Jake's actual use case — written three ways, each with the one-line fix that turns a 502 into a working response.
Node.js
// Before — 502 on every call
exports.handler = async (event) => {
const price = await getPrice(event.pathParameters.model);
return { statusCode: 200, body: { price } }; // body is an object
};
// After
exports.handler = async (event) => {
const price = await getPrice(event.pathParameters.model);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ price }),
isBase64Encoded: false
};
};
Python
# Before — 502 on every call
def handler(event, context):
price = get_price(event["pathParameters"]["model"])
return {"statusCode": 200, "body": {"price": price}} # body is a dict
# After
import json
def handler(event, context):
price = get_price(event["pathParameters"]["model"])
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"price": price}),
"isBase64Encoded": False
}
Go
// Before — 502 on every call
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
price := getPrice(req.PathParameters["model"])
body, _ := json.Marshal(price) // marshalling only the number, not a proper object
return events.APIGatewayProxyResponse{StatusCode: 200, Body: string(body)}, nil
}
// After
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
price := getPrice(req.PathParameters["model"])
payload := map[string]interface{}{"price": price}
body, err := json.Marshal(payload)
if err != nil {
return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error":"internal"}`}, nil
}
return events.APIGatewayProxyResponse{
StatusCode: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: string(body),
}, nil
}
Notice the Go "before" example is subtler than the other two — it does call json.Marshal, so it looks correct at a glance. The bug is that it marshals a bare number instead of a proper JSON object, and it discards the error that json.Marshal can return, which is exactly the kind of thing a code reviewer skims past because the shape of the code looks right even though the shape of the data isn't.
When nothing above fixes it
If you've confirmed the shape is correct, statusCode is a real number, body is a real string, and you're still seeing 502s, work through this shorter list before assuming AWS itself is at fault (which, to be honest about limits, is rare but not impossible):
- Check payload size. A response body over roughly 6 MB (synchronous invocation limit) or 10 MB through certain integration paths will be rejected before it ever reaches the client as a normal response — verify you're not silently truncating or exceeding a size ceiling.
- Check for a second, unhandled error path. Middleware, a logging library, or an authorizer function running before your handler can each independently return a bad shape or throw — the 502 might not originate in the code you're staring at at all.
- Check the integration's payload format version explicitly if you're on an HTTP API — don't assume the console default matches what your infrastructure-as-code actually deployed.
- If you use response streaming (buffered vs. streamed Lambda functions), confirm your configuration is a supported combination — API Gateway doesn't support every stream/invoke combination, and an unsupported one returns its own 500, not a fixable shape issue.
🙋♂️ Jake's Reality Check
"What if I've genuinely checked everything and it's still broken?"
Then admit what you can't see from the outside: if the execution log itself shows a healthy-looking response and API Gateway still 502s, that's worth an AWS support case with the exact request ID in hand, rather than more guessing on your end. Not every problem is yours to solve from the console — sometimes the honest answer is "I don't know, and I need someone with account-level visibility."
Testing this without a live customer watching
The API Gateway console's Test button on a resource's method invokes the full integration chain, including your real Lambda function, and shows you the raw Lambda response alongside the final API Gateway response — which makes it genuinely useful for reproducing a 502 without waiting for a real client request to fail. It's the closest thing to a safe rehearsal space for exactly this class of bug, and it's already built into the console you're probably already looking at.
Outside the console, a plain curl -v against the invoke URL, paired with the CloudWatch execution log for that same request ID, gives you the same picture from the client's point of view — status code, response headers, and body, correlated against exactly what your function returned on the backend.
Customizing the 502 body itself with Gateway Responses
Everything so far has been about preventing the 502 in the first place. Separately, API Gateway has a feature called Gateway Responses that lets you change what the client sees when API Gateway itself generates an error response — including the generic {"message": "Internal server error"} body that ships with both the 500 and 502 cases described earlier. The relevant response type is DEFAULT_5XX, and it covers every 5xx response API Gateway produces on its own, a malformed Lambda proxy response included.
This matters for one very specific, very common failure: your Lambda function might be careful to attach CORS headers to every response it returns, but if API Gateway itself is the one generating the error — because your function's response was malformed and never reached the client at all — none of your function's own header logic ever runs. The browser sees a response with no CORS headers, and reports a CORS error that has nothing to do with CORS and everything to do with the underlying 502.
Overriding DEFAULT_5XX to always include Access-Control-Allow-Origin closes that gap. In an AWS SAM or CloudFormation template, it looks like this:
GatewayResponses:
DEFAULT_5XX:
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
Ethan's take: "This isn't a fix for the bug. It's a seatbelt for when the bug happens anyway — and in a system this many layers deep, it will happen anyway, eventually, to someone."
Catching this before it ships, not after a customer sees it
Unit tests that call your handler function directly and inspect the return value in memory will happily pass even when the response would 502 in production, because they're testing your logic, not the actual contract with API Gateway. A small addition catches the entire category before deploy: after building the response object in your test, assert that statusCode is a real integer, that body is a real string (not an object, not undefined), and that headers, if present, only contain single string values — then run that same assertion against every branch of your handler, success and every error path, not just the happy path you tested first.
This doesn't need a heavyweight framework. A five-line assertion function called from every test case for every branch is enough to turn "we found out from a customer on a Saturday" into "our test suite caught it on a pull request."
If you'd rather lean on an existing library instead of a hand-rolled assertion, ajv or zod in the Node.js world and pydantic in Python can validate a response object against a small schema — statusCode required and numeric, body required and a string, headers optional and single-valued — in a few lines, and the same schema doubles as documentation for anyone new to the codebase who's wondering what a "correct" response is even supposed to look like.
The security angle: what your error branch should never leak
Chasing a 502 tends to produce a very specific bad habit: returning the raw exception, the full stack trace, or a database driver's error message directly in the response body, just to see what's actually wrong while you're debugging — and then forgetting to remove it before the change ships. That's not a formatting problem, but it's a real one: a stack trace can expose internal file paths, library versions with known vulnerabilities, and sometimes fragments of configuration or environment variable names to anyone who can trigger the error on purpose.
The safer pattern is to log the full detail to CloudWatch — where you can still see everything you need for debugging — and return a short, generic error object to the actual client. If you genuinely need the raw detail visible while testing, gate it behind a stage variable or an environment flag that only exists on a non-production stage, so there's no code path in production capable of returning it at all.
Preventing it from happening again
The most durable fix isn't remembering the shape correctly every single time you write a return statement — it's writing one small response-building helper function that every code path, including every error branch, is forced to go through. Something as simple as a respond(statusCode, data) function that always stringifies the body, always sets statusCode as a number, and always attaches the same CORS headers removes the entire category of bug, because there's no longer a place in the codebase where a raw object can accidentally end up in the body field.
✅ Why this is the one to use
One shared response helper, used everywhere, beats remembering the rule everywhere — and it means the next person who adds a new endpoint six months from now inherits the correct shape automatically instead of having to relearn this whole post.
Jake ended up doing exactly that: one small helper function, called from every branch of his trade-in lookup tool, success and failure alike. Ethan's blunt summary once it was done: "You didn't need to get smarter about Lambda. You needed to stop hand-writing the same five lines fifteen different ways." The next Saturday, the same tool handled a busier counter than the one that started all this, without a single 502 — not because the traffic was lighter, but because there was no longer a code path left that could hand API Gateway something it couldn't parse.
Frequently asked questions
Why does a perfectly valid JSON response still cause a 502?
Because "valid JSON" and "the shape API Gateway requires" are different tests. A response can be flawless JSON and still fail if it's a raw object where a string is required, if statusCode is the wrong data type, or if a required field is missing entirely.
What's the exact response format API Gateway expects from Lambda proxy integration?
For a REST API or an HTTP API pinned to payload format 1.0: statusCode as a number, an optional headers object with single values, an optional multiValueHeaders object with arrays, a body that's a string, and isBase64Encoded as a boolean.
Do I need multiValueHeaders if I already set headers?
No. You can use either one on its own if you have no duplicate header names to send. multiValueHeaders only matters when you need to send the same header name more than once, such as multiple Set-Cookie values.
Why does my HTTP API work but the same Lambda breaks on REST API, or the other way around?
HTTP APIs with payload format 2.0 can infer a response from plain JSON without a statusCode field. REST APIs, and HTTP APIs pinned to format 1.0, cannot — they need the full strict shape every time. Copying a handler between the two without checking which format version applies is a common source of exactly this split.
Does throwing an error in my Lambda function always cause a 502?
Yes, if it's uncaught. An unhandled exception or unhandled promise rejection means the function never returns anything at all, and API Gateway has no response to work with, so it returns 502 with a generic error body.
What's the difference between a 502 and a 500 from API Gateway?
A 500 means API Gateway couldn't even invoke your function — usually a permissions or resource policy problem. A 502 means your function ran, but either threw an error or returned a response in the wrong format. The client-facing body text is identical for both, so you have to check the status code itself.
Why do I get 504 instead of 502 on some requests?
504 means your function didn't finish before API Gateway's integration timeout ran out — 29 seconds by default on REST APIs, extendable up to 90 seconds without a support ticket. It's a duration problem, not a shape problem, so fixing your return statement won't touch it.
Do I need to set isBase64Encoded for a plain JSON response?
Not strictly, but setting it to false explicitly is good practice. It's only truly required when you're returning binary data (images, PDFs, zip files) as a Base64-encoded string.
Can a missing statusCode field cause a 502?
On a REST API or an HTTP API using payload format 1.0, yes — statusCode is required. On an HTTP API using payload format 2.0, a missing statusCode is allowed and gets treated as a 200.
Why did my 502 start after I added CORS headers?
Usually because the headers were added to only one response branch (say, the success path) and not others, or because a framework's built-in CORS handling collided with headers you also set by hand, producing an object shape that no longer matched what was expected on that path.
Does returning a Promise instead of awaiting it cause 502s?
Yes, in Node.js. If your async handler returns a Promise object instead of the resolved value — for example, forgetting an await before a call that itself returns a Promise — API Gateway receives something that isn't the expected response shape at all, and treats it as malformed.
Why does the error only happen intermittently, not every request?
Most often because one specific code path — an error branch, an edge case, a null value — is malformed while the main success path isn't, so only requests that route through that branch fail. Cold starts colliding with a tight timeout can also produce intermittent failures that look random but are really duration-related.
How do I see what my Lambda actually returned to API Gateway?
Turn on API Gateway execution logging at the INFO level for the stage, reproduce the failing request, and search the resulting CloudWatch log group for the request ID — the log will show the raw response your function returned, before API Gateway tried to parse it.
Can API Gateway timeout settings fix a 502?
No. Timeout settings only affect 504s. A 502 is a shape or error problem inside a function that already finished executing, so raising a timeout value has no effect on it at all.
Does the API Gateway console "Test" button reproduce real 502s?
Yes — it invokes the actual integration chain including your real Lambda function, and shows both the raw Lambda output and the final API Gateway response side by side, which makes it a reliable way to reproduce and diagnose this without waiting for a live client request to fail.
Will switching my Lambda from proxy integration to non-proxy integration avoid this?
It trades one problem for a different one. Non-proxy (custom) integrations let you map the response through API Gateway's mapping templates instead of requiring the exact proxy shape, but you then own writing and maintaining those mapping templates for every status code and content type — which is more setup, not less, for most teams building a straightforward API.
Revision note. Written September 2026, covering both REST APIs and HTTP APIs (payload format versions 1.0 and 2.0) with Lambda proxy integration. This will need a fresh look if AWS changes the required response shape or the inference behavior on format 2.0 — but the core rule, that the response has to match the documented shape exactly, has held steady for years. If you're staring at a 502 right now with a customer waiting, take a breath, turn on the logging, and read what your function actually sent — the answer really is usually sitting right there in the first few lines.
π RECOMMENDED AWS TROUBLESHOOTING & GUIDES
- π ️ Fix AWS CLI "Could Not Connect to Endpoint URL" Instantly
- π Resolve AWS CLI ExpiredToken Session Credentials Fast
- π Fix AWS Bedrock AccessDeniedException Security Errors
- π Fix AWS S3 AccessDenied on GetObject (Step-by-Step)
- ⚡ Amazon Aurora Guide: Stop Database Throttling & Bottlenecks