An API’s error responses are part of its contract, just like its success responses. Clients write code against them: retry on this status, show this field to the user, refresh the token on that one. With API Gateway in front of Lambda, there are two producers of errors, the gateway and your function, and by default they disagree about what an error looks like. That’s the problem this note is about.
Two sources of errors
Some requests never reach your function. API Gateway rejects them itself and builds the response from a gateway response template. The defaults return a tiny body like {"message": "Forbidden"}:
| What happened | Status | Default body |
|---|---|---|
| Path or method doesn’t exist (REST API) | 403 | {"message":"Missing Authentication Token"} |
| Authorizer denied the caller | 403 | {"message":"User is not authorized to access this resource"} |
| No or invalid token with a Cognito or Lambda authorizer | 401 | {"message":"Unauthorized"} |
| Request body fails model validation | 400 | {"message": "Invalid request body"} |
| Throttled by stage, usage plan or account limit | 429 | {"message":"Too Many Requests"} |
| Integration didn’t answer within the timeout | 504 | {"message": "Endpoint request timed out"} |
| Lambda crashed or returned a malformed response | 502 | {"message": "Internal server error"} |
The first row confuses almost everyone once. A REST API answers an unknown path with 403 “Missing Authentication Token”, not 404, because the gateway can’t tell a typo in the URL from an unsigned request to an IAM-protected route. When a client reports that error, check the URL and the stage name in the path before you look at auth.
Then there are errors your function produces deliberately: validation failures, “not found”, conflicts, business rule violations. Those come back in whatever shape your code writes. Unless you do something about it, a client sees {"message": "..."} from the gateway and {"errorMessage": "...", "errorType": "..."} or a custom shape from Lambda, depending on which layer failed.
How Lambda proxy integration turns crashes into 502s
With Lambda proxy integration (the usual setup), your function must return an object shaped like this:
{ "statusCode": 404, "headers": { "Content-Type": "application/json" }, "body": "{\"error\":\"not found\"}" }body must be a string. Return an object there, forget statusCode, or let an exception escape the handler, and API Gateway can’t build a response. It returns 502 with {"message": "Internal server error"}, and your execution logs show Malformed Lambda proxy response or the function’s error. From the client’s point of view, a bug in your JSON serialisation and an outage look identical.
Timeouts are separate. API Gateway’s integration timeout defaults to 29 seconds. If the function is still running at that point, the client gets 504 while the function keeps going (and may still complete its side effect, which matters for anything that isn’t idempotent). Regional and private REST APIs can request a longer integration timeout, but long work usually belongs on an asynchronous path.
One envelope for every failure
The fix on the function side is a wrapper that catches everything and returns one shape. Expected failures carry a status and a stable machine-readable code; unexpected ones get a generic 500 with full detail logged, not returned. Here’s a runnable version in Node. It’s plain JavaScript, so it runs locally with fake events:
// One error envelope for every failure a Lambda proxy handler can produce.
class AppError extends Error {
constructor(status, code, message, details) { super(message); Object.assign(this, { status, code, details }); }
}
const json = (statusCode, body) => ({
statusCode,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'https://app.example.com' },
body: JSON.stringify(body),
});
export const withContract = (fn) => async (event, context) => {
const requestId = event.requestContext?.requestId ?? context.awsRequestId;
try {
return json(200, await fn(event));
} catch (err) {
if (err instanceof AppError) {
return json(err.status, { error: { code: err.code, message: err.message, details: err.details, requestId } });
}
console.error(JSON.stringify({ requestId, msg: err.message, stack: err.stack })); // full detail to logs only
return json(500, { error: { code: 'INTERNAL', message: 'Something went wrong on our side.', requestId } });
}
};
// The business handler only throws AppErrors for expected failures.
const createTransfer = withContract(async (event) => {
const body = JSON.parse(event.body ?? '{}');
if (!Number.isInteger(body.amountCents) || body.amountCents <= 0)
throw new AppError(400, 'VALIDATION_FAILED', 'amountCents must be a positive integer', [{ field: 'amountCents' }]);
if (body.amountCents > 50_000) throw new AppError(409, 'LIMIT_EXCEEDED', 'Daily transfer limit reached');
if (body.toAccount === 'acct_crash') throw new TypeError("Cannot read properties of undefined (reading 'iban')");
return { transferId: 'tr_9f2', status: 'PENDING' };
});
const ctx = { awsRequestId: 'c0ffee00-0000-4000-8000-000000000001' };
for (const body of [{ amountCents: 1200, toAccount: 'acct_7' }, { amountCents: -5 }, { amountCents: 90_000 }, { amountCents: 100, toAccount: 'acct_crash' }]) {
const res = await createTransfer({ body: JSON.stringify(body), requestContext: { requestId: 'req-1a2b' } }, ctx);
console.log(res.statusCode, res.body);
}Running node contract.mjs prints (stdout only; the crash’s stack trace goes to stderr, which in Lambda means CloudWatch Logs):
200 {"transferId":"tr_9f2","status":"PENDING"}
400 {"error":{"code":"VALIDATION_FAILED","message":"amountCents must be a positive integer","details":[{"field":"amountCents"}],"requestId":"req-1a2b"}}
409 {"error":{"code":"LIMIT_EXCEEDED","message":"Daily transfer limit reached","requestId":"req-1a2b"}}
500 {"error":{"code":"INTERNAL","message":"Something went wrong on our side.","requestId":"req-1a2b"}}A few decisions are baked in here, and each is worth defending in an interview:
- Status codes carry the retry signal. 4xx means “don’t retry this request unchanged”; 5xx and 429 mean “retrying later might work”. A business rule violation returned as 500 makes well-behaved clients retry something that will never succeed.
codeis for programs,messageis for people. Clients branch onLIMIT_EXCEEDED, never on the English text, so you can reword messages without breaking anyone.- The 500 says nothing about the internals. The
TypeErrortext and stack stay in the logs. Returning them leaks implementation details and sometimes data. requestIdis in every error. When a customer sends a screenshot, that ID finds the exact log lines. Using API Gateway’s request ID (fromrequestContext) rather than Lambda’s lets you correlate gateway access logs and function logs.
If you’d rather use a standard than invent a shape, RFC 9457 “problem details” (application/problem+json with type, title, status, detail) is a reasonable choice. Consistency matters more than which shape you pick.
Making the gateway speak the same language
The wrapper can’t touch errors that never reach the function. For those, customise the gateway responses on a REST API so they produce the same envelope. In CloudFormation (illustrative; not deployed):
Default5xx:
Type: AWS::ApiGateway::GatewayResponse
Properties:
RestApiId: !Ref TransfersApi
ResponseType: DEFAULT_5XX
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'https://app.example.com'"
ResponseTemplates:
application/json: '{"error":{"code":"$context.error.responseType","message":$context.error.messageString,"requestId":"$context.requestId"}}'Add the same for DEFAULT_4XX, and more specific ones (such as THROTTLED or UNAUTHORIZED) if you want different codes. $context.error.messageString is already a quoted JSON string, which is why it has no quotes around it in the template. Redeploy the stage afterwards; gateway response changes, like other REST API changes, only take effect on deployment.
The CORS header matters more than it looks. Without it, a browser receiving a 429 or 504 from the gateway reports a CORS error to your JavaScript and hides the actual status, so the front end can’t tell “slow down” from “misconfigured”. HTTP APIs handle CORS for gateway errors when you configure CORS on the API, but they don’t support customised gateway responses, which is one of the trade-offs between the two API types.
Interview practice: the client that retries everything
A mobile team says your payments API is unreliable: they see bursts of 502s and occasionally duplicate payments. Their client retries any non-2xx response three times. Your Lambda handler throws an Error for “insufficient funds”. What’s happening and what would you change?
Answer
Throwing from a proxy-integrated handler means the exception escapes, API Gateway can’t get a valid response, and it returns 502 “Internal server error”. So “insufficient funds”, a normal business outcome, reaches the client as a server error. The client retries it three times, which explains the bursts of 502s: one per user action, times four.
The duplicates come from timeouts. If the payment call is slow and the 29-second integration timeout fires, the client gets a 504 and retries, but the first invocation keeps running and may complete the charge. Now there are two.
I’d fix both sides of the contract. In the function, wrap the handler so expected failures return 4xx with a stable code (402 or 409 with INSUFFICIENT_FUNDS), and only unexpected errors become a generic 500 with a request ID. Customise the gateway’s 4xx and 5xx responses to the same shape. Then require an Idempotency-Key header on payment creation and store results by that key, so a retry after a 504 returns the original outcome instead of charging again. Finally, agree the retry rules with the client team: retry 429, 502, 503 and 504 with backoff and the same idempotency key; never retry other 4xx.