The most common serverless pattern on AWS, and the source of one error more than any other: {"message": "Internal server error"} with a 502, caused not by your code failing but by returning the wrong shape.
This page is mostly about getting that shape right.
With AWS_PROXY, API Gateway passes the entire request to your function and expects the entire HTTP response back. Your function handles routing, status codes and headers.
aws apigatewayv2 create-integration --api-id "$API_ID" \
--integration-type AWS_PROXY \
--payload-format-version 2.0 \
--integration-uri "arn:aws:lambda:eu-west-1:123456789012:function:orders" \
--timeout-in-millis 29000The alternative, non-proxy AWS integration, uses mapping templates to shape both directions. It lets you keep transformation out of your code, at the cost of VTL in your infrastructure config and error mapping that depends on matching error strings. Use proxy unless you have a specific reason not to.
This is the whole ball game. A proxy integration requires an object with specific keys, and anything else is a 502:
def handler(event, context):
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"orderId": "A-4821"})
}Three rules that cause nearly every 502:
body must be a string. Returning a dict is the single most common mistake, json.dumps() it.
return {"statusCode": 200, "body": {"ok": True}} # 502
return {"statusCode": 200, "body": json.dumps({"ok": True})} # correctstatusCode must be present and an integer. "200" as a string fails.
An unhandled exception is a 502, not a 500 with your error. Catch and return deliberately:
def handler(event, context):
try:
return {"statusCode": 200, "body": json.dumps(do_work(event))}
except NotFound:
return {"statusCode": 404, "body": json.dumps({"error": "not found"})}
except Exception:
logger.exception("unhandled")
return {"statusCode": 500, "body": json.dumps({"error": "internal"})}The gateway's error message never tells you which of these it was. The function's CloudWatch log does, which is why the log group is the first place to look, not the gateway.
HTTP APIs default to 2.0, REST APIs are always 1.0, and the event structures differ in ways that break code moved between them:
| 1.0 | 2.0 | |
|---|---|---|
| Method | event["httpMethod"] | event["requestContext"]["http"]["method"] |
| Path | event["path"] | event["rawPath"] |
| Query string | event["queryStringParameters"] | event["queryStringParameters"] (values joined) |
| Cookies | In headers | event["cookies"] array |
| Response | statusCode required | statusCode optional |
That last row is a real trap. Under 2.0, a function returning a plain object with no statusCode is treated as a successful JSON body with status 200, so a function that silently returns the wrong thing looks like it works. Under 1.0 the same return is a 502. Set statusCode explicitly either way.
Header names arrive lowercased, and event["headers"] in 2.0 joins multiple values with commas rather than providing a multi-value map.
Pin the format explicitly rather than relying on defaults:
--payload-format-version 2.0API Gateway needs permission to invoke the function. This is a resource-based policy on the Lambda, not an execution role:
aws lambda add-permission --function-name orders \
--statement-id apigw-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:eu-west-1:123456789012:$API_ID/*/*"Creating the integration in the console adds this automatically; creating it with the CLI, Terraform or CloudFormation does not, which is why a hand-built integration fails with:
Execution failed due to configuration error: Invalid permissions on Lambda function
Keep --source-arn scoped to your API. A wildcard principal with no source restriction lets any API Gateway in any account invoke your function.
Handled in two places, and getting it half right is worse than not doing it at all.
Preflight (OPTIONS), on an HTTP API this is configuration, and the gateway answers without invoking Lambda:
aws apigatewayv2 update-api --api-id "$API_ID" \
--cors-configuration 'AllowOrigins=https://app.example.com,AllowMethods=GET,POST,PUT,DELETE,OPTIONS,AllowHeaders=content-type,authorization,AllowCredentials=true,MaxAge=300'Actual responses: your function must still return the headers, because CORS applies to every response, not just the preflight:
CORS = {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Credentials": "true",
}
return {"statusCode": 200, "headers": {**CORS, "Content-Type": "application/json"}, "body": json.dumps(data)}The classic symptom is a request that works in curl and fails in the browser with a CORS error, because the error response lacks the headers even though the success response has them. Return CORS headers on error paths too, or a 500 becomes an unhelpful CORS message that hides the real problem.
AllowCredentials=true cannot be combined with AllowOrigins=*; browsers reject it.
Two limits, and the smaller one wins:
So a Lambda configured for five minutes behind API Gateway is cut off at 29 seconds, and the client gets a 504 while the function keeps running and billing. Configure the function timeout below the gateway's:
aws lambda update-function-configuration --function-name orders --timeout 25For genuinely long work, return 202 immediately and do the work asynchronously, put a message on SQS or start a Step Functions execution, then let the client poll or receive a webhook.
A first invocation pays initialisation cost: container start, runtime boot, then your module-level code. Three things help, in order of effect:
Move work to module scope. Clients created inside the handler are rebuilt on every invocation:
import boto3
ddb = boto3.resource("dynamodb") # once per container
table = ddb.Table("Orders")
def handler(event, context):
return table.get_item(Key={...}) # reuses the connectionKeep the package small. A 200 MB deployment package initialises slower than a 5 MB one. Bundle only what you import.
Provisioned concurrency removes cold starts for a fixed hourly cost:
aws lambda put-provisioned-concurrency-config \
--function-name orders --qualifier prod --provisioned-concurrent-executions 5It requires a version or alias, not $LATEST. Use it for latency-sensitive user-facing paths and not for everything, since you pay whether or not the capacity is used.
In order:
# 1. Did the function even run?
aws logs tail /aws/lambda/orders --since 10m --follow# 2. What did the gateway see?
aws logs filter-log-events --log-group-name /aws/apigw/orders \
--filter-pattern '{ $.integrationError = "*" }' --max-items 5No Lambda log at all means permissions. A log showing a successful run means the response shape is wrong. A log showing an exception means the code failed, and the 502 is hiding it, which is why you should catch exceptions and return a real status code.
{"message":"Internal server error"}
Execution failed due to configuration error: Invalid permissions on Lambda function
Missing lambda:add-permission for apigateway.amazonaws.com.
Endpoint request timed out
504 at 29 seconds. Lower the function timeout and make long work asynchronous.
Works in curl, CORS error in the browser. Error responses are missing CORS headers.
| Task | Command |
|---|---|
| Proxy integration | create-integration --integration-type AWS_PROXY |
| Pin the payload format | --payload-format-version 2.0 |
| Grant invoke | aws lambda add-permission --principal apigateway.amazonaws.com |
| Configure CORS | aws apigatewayv2 update-api --cors-configuration ⦠|
| Lower function timeout | aws lambda update-function-configuration --timeout 25 |
| Provisioned concurrency | aws lambda put-provisioned-concurrency-config |
| Read function logs | aws logs tail /aws/lambda/<fn> --follow |
| Test a route | curl -i "https://$API_ID.execute-api.eu-west-1.amazonaws.com/orders/1" |
body must be a JSON string. Returning an object is the top cause of 502s.statusCode must be an integer, and under payload 2.0 its absence silently becomes a 200.<MCQExercise exercise={{ id: 'aws-apigw-lambda-quiz1', title: 'A 502 from a function that succeeded', description: 'A Lambda behind API Gateway logs a successful execution and returns {"statusCode": 200, "body": {"orderId": "A-4821"}}. The client receives 502 Internal server error. Why?', options: [ { id: 'a', text: 'body must be a string. API Gateway cannot serialise an object, so json.dumps() is required' }, { id: 'b', text: 'The response is missing an isBase64Encoded field, which is mandatory' }, { id: 'c', text: 'statusCode 200 must be returned as the string "200" in proxy integrations' }, { id: 'd', text: 'The function needs a longer timeout for API Gateway to accept the response' }, ], correctId: 'a', explanation: 'A proxy integration hands the function's return value straight to the gateway as an HTTP response, and body is the raw response payload, so it must already be a string. Returning a dict means API Gateway cannot build a valid response and reports 502 with a generic message, even though the function ran perfectly and logged success. Wrap it in json.dumps(). This split between "the function worked" and "the client got a 502" is exactly why the Lambda log group is the first place to look.', wrongExplanations: { b: 'isBase64Encoded is optional and defaults to false; it only matters for binary responses.', c: 'The opposite: statusCode must be an integer, and a string value causes its own failure.', d: 'Timeouts produce a 504 after 29 seconds. This function returned promptly.', }, }} />
<MCQExercise exercise={{ id: 'aws-apigw-lambda-quiz2', title: 'Works in curl, fails in the browser', description: 'An API returns data correctly via curl. From the browser, successful requests work but any request that errors shows a CORS error instead of the error message. CORS is configured on the HTTP API. What is wrong?', options: [ { id: 'a', text: 'The error paths in the Lambda do not return the Access-Control-Allow-Origin header, and CORS applies to every response including errors' }, { id: 'b', text: 'CORS configuration on an HTTP API only covers GET requests' }, { id: 'c', text: 'The browser requires AllowOrigins to be set to * for error responses' }, { id: 'd', text: 'curl bypasses CORS, so the API is misconfigured for all requests and only appears to work' }, ], correctId: 'a', explanation: 'The gateway CORS configuration handles the OPTIONS preflight, but with a proxy integration every actual response comes from your function, so each one must carry the CORS headers itself, error paths included. When a 500 or 404 returns without them, the browser blocks the response and reports a CORS failure, hiding the real error entirely and sending people to debug the wrong thing. Define the headers once and spread them into every return, including the catch-all exception handler.', wrongExplanations: { b: 'AllowMethods lists which methods are permitted; the configuration is not restricted to GET.', c: 'A wildcard origin is not required and is incompatible with AllowCredentials=true.', d: 'curl ignores CORS because CORS is a browser security mechanism. The successful responses genuinely are correct.', }, }} />
They are different event and response contracts. Under 1.0 the method is event["httpMethod"] and the path is event["path"]; under 2.0 they move to event["requestContext"]["http"]["method"] and event["rawPath"], cookies get their own array, and statusCode becomes optional in the response. HTTP APIs default to 2.0 and REST APIs are always 1.0, so code moved between them breaks. Pin --payload-format-version explicitly, and always set statusCode, under 2.0 its absence silently produces a 200.
API Gateway needs a resource-based policy on the function allowing apigateway.amazonaws.com to call lambda:InvokeFunction. Creating the integration in the console adds it for you; creating it via CLI, Terraform or CloudFormation does not, which is why this appears in infrastructure-as-code setups and not in console experiments. Add it with aws lambda add-permission, scoping --source-arn to your API so other accounts cannot invoke the function.
Two halves. Configure CORS on the API so the gateway answers OPTIONS preflight requests itself. Then have your function return Access-Control-Allow-Origin on every response, including errors, with a proxy integration the gateway does not add headers to your responses. The tell-tale symptom of getting only the first half right is an API that works in curl and shows CORS errors in the browser whenever something fails, because the error response lacks the headers.
29 seconds, and it is a hard limit on REST APIs that no support request will raise. Lambda itself allows up to 15 minutes, so a long-running function behind a gateway is cut off at 29 seconds while continuing to run and bill. Set the function timeout below the gateway's, 25 seconds is a sensible ceiling, and for genuinely long work, return 202 immediately after queueing to SQS or starting a Step Functions execution, then let the client poll or receive a callback.
In order of effect: initialise SDK clients and database connections at module scope so they persist across invocations in the same container; keep the deployment package small, since initialisation time scales with what has to be loaded; and use provisioned concurrency for latency-sensitive paths, which eliminates cold starts entirely for a fixed hourly cost and requires a version or alias rather than $LATEST. Choosing a faster-starting runtime also helps, though rarely enough to justify a rewrite on its own.
Push, pull and secure Docker images in Amazon ECR. Authenticate with the AWS CLI, tag images, set lifecycle policies, enable scanning and fix common errors.
Understand how Amazon EKS runs Kubernetes on AWS: what AWS manages, node options, access entries, IRSA vs Pod Identity, add-ons and networking pitfalls.
Create an EKS cluster that works the first time: avoid IAM lockouts with access entries vs aws-auth, tag subnets correctly and plan for the VPC CNI IP limit.