Amazon API Gateway's HTTP API exposes a Lambda Event Function through an HTTPS endpoint. With a Lambda proxy integration, API Gateway turns each HTTP request into an event, invokes your handler, and turns its result into an HTTP response. Your code exports a handler; it doesn't start an HTTP server.
In Lab 5 of AWS by Doing, S3 stored a file and triggered Lambda asynchronously. The upload could succeed while the handler failed.
This time, the caller waits for an HTTP response. We'll check the route, permission to invoke Lambda, and the handler's own result, then break the invoke permission and restore it.
The question to follow is which part of the request path produced this response? A validation error from the handler and a gateway error need different fixes.
This is part 6 of AWS by Doing. We'll use API Gateway's HTTP API product with payload format 2.0 in Ohio, us-east-2. The separate Lambda Web Functions tutorial runs a listening HTTP server; we'll compare that model after the lab.
We ran this with AWS CLI 2.37.9, local Node.js 24.17.0 and the nodejs24.x Lambda runtime on October 22, 2026, then repeated the full sequence on November 3. In both runs, an unhandled Lambda error and missing invoke permission returned HTTP 500. Lambda's invocation status was 200 for the handler error and 403 for the permission failure; the logs showed where each request failed. Restoring permission brought the same endpoint back to HTTP 200 without changing the handler.
Quick answer
- Test a handler that reads an HTTP API event and returns an explicit proxy response.
- Deploy it with a dedicated execution role and log group.
- Create an HTTP API, Lambda integration, three routes and a stage.
- Grant this API permission to invoke this function.
- Test the HTTPS endpoint, introduce an invoke-permission failure, and follow requests through both logs.
- Restore the permission, verify recovery, and remove the lab resources.
We'll check both the HTTP response and the logs for each failure.
The route and the handler do different jobs
API Gateway selects a route using the HTTP method and path. Our three routes target the same function:
| HTTP request | Handler behavior |
|---|---|
GET /health | Returns HTTP 200 with a health response |
GET /hello?name=Ravi | Returns a greeting; an empty name produces a handled HTTP 400 |
GET /error | Throws a deliberate, unhandled error |
The AWS_PROXY integration uses payload format 2.0. That determines the event fields our handler reads and the response format API Gateway understands. We'll set it explicitly. Lambda integration contract
There is no ANY route or catch-all $default route. Unmatched requests should stop at API Gateway. HTTP API route selection
API Gateway also has a REST API product with different features and configuration. This lab uses HTTP APIs and aws apigatewayv2 commands throughout. HTTP APIs and REST APIs
Before creating resources
You'll need AWS CLI v2, Node.js 24, Python 3, zip, uuidgen and curl, plus an authenticated development account.
Your CLI identity needs permission to create, inspect and delete the API, integration, routes, stage, function, IAM role, inline policy and log groups. It also needs iam:PassRole, lambda:InvokeFunction, and permission to manage the function's resource policy. API Gateway access logging requires additional CloudWatch log-delivery and resource-policy permissions. Logging permissions
The endpoint is public and unauthenticated for this disposable experiment. It serves only a health response, a greeting and a deliberate error. Don't add real customer data or business operations without authentication and authorization.
API Gateway, Lambda and logs can incur charges. We'll use a small stage throttle and clean up in the same session. Throttling is a best-effort target, not a guaranteed request ceiling or spending cap. HTTP API throttling
Run these blocks in the same Bash or Zsh terminal and stop on unexpected setup errors. Keep the local directory until cleanup is verified. Generated policies, ARNs, log records and the endpoint URL should stay local; redact identifiers before sharing output.
Step 1: Write a handler for the HTTP event
Create unique names and a clean package directory:
set +x
umask 077
export AWS_PAGER=""
LAB_REGION=us-east-2
LAB_NAME="aws-by-doing-06-$(uuidgen | tr '[:upper:]' '[:lower:]' | cut -c1-12)"
LAB_ROLE="$LAB_NAME-execution"
LAB_FUNCTION_GROUP="/aws/lambda/$LAB_NAME"
LAB_ACCESS_GROUP="/aws/apigateway/$LAB_NAME"
LAB_DIR=$(mktemp -d)
LAB_APP="$LAB_DIR/app"
mkdir "$LAB_APP"
lab_aws() {
aws --region "$LAB_REGION" --no-cli-pager "$@"
}
printf 'Keep this directory for cleanup: %s\n' "$LAB_DIR"
printf '%s\n' "$LAB_NAME" > "$LAB_DIR/name"
printf '%s\n' "$LAB_ROLE" > "$LAB_DIR/role"
printf '%s\n' "$LAB_REGION" > "$LAB_DIR/region"Save index.mjs in that directory's app folder:
export async function handler(event, context = {}) {
if (event?.version !== "2.0" || typeof event.routeKey !== "string" ||
typeof event.requestContext?.http?.method !== "string") {
throw new Error("Expected an HTTP API payload version 2.0");
}
const fields = {
routeKey: event.routeKey,
gatewayRequestId: event.requestContext.requestId ?? "local",
lambdaRequestId: context.awsRequestId ?? "local",
};
const reply = (statusCode, body) => {
console.info({ event: "response", ...fields, statusCode });
return {
statusCode,
headers: { "content-type": "application/json" },
isBase64Encoded: false,
body: JSON.stringify(body),
};
};
switch (event.routeKey) {
case "GET /health":
return reply(200, { status: "ok" });
case "GET /hello": {
const name = event.queryStringParameters?.name ?? "world";
if (typeof name !== "string" || !name.trim() || name.trim().length > 40) {
return reply(400, { error: "name must contain 1 to 40 characters" });
}
return reply(200, { message: `Hello, ${name.trim()}!` });
}
case "GET /error":
console.error({ event: "handler_failed", ...fields });
throw new Error("Deliberate handler failure");
default:
return reply(404, { error: "Not found in handler" });
}
}The proxy response's body is a JSON string. API Gateway uses statusCode and headers to build the HTTP response, with that string as its body.
The logs include the selected route and both request IDs. They omit names, headers, request bodies and full events. The gateway ID connects the HTTP request to the handler; the Lambda ID connects the application entry to runtime records.
Try a synthetic event locally:
cat > "$LAB_DIR/event.json" <<'JSON'
{
"version": "2.0",
"routeKey": "GET /health",
"requestContext": {
"requestId": "synthetic-direct-only",
"http": { "method": "GET" }
}
}
JSON
node --input-type=module - "$LAB_APP/index.mjs" "$LAB_DIR/event.json" <<'JS'
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const { handler } = await import(pathToFileURL(process.argv[2]).href);
const event = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
console.log(await handler(event, { awsRequestId: "local-test" }));
JSExpect statusCode: 200 and body: '{"status":"ok"}'. This checks the handler with our sample event. We'll test the real request path after deployment.
Step 2: Deploy with a logging-only execution role
Create both log groups and save the existing CloudWatch resource policies before enabling access logging:
LAB_ACCOUNT=$(lab_aws sts get-caller-identity --query Account --output text)
LAB_ACCESS_ARN="arn:aws:logs:$LAB_REGION:$LAB_ACCOUNT:log-group:$LAB_ACCESS_GROUP"
lab_aws logs describe-resource-policies > "$LAB_DIR/logging-policies-before.json"
for group in "$LAB_FUNCTION_GROUP" "$LAB_ACCESS_GROUP"; do
lab_aws logs create-log-group --log-group-name "$group"
lab_aws logs put-retention-policy --log-group-name "$group" --retention-in-days 1
done
python3 - "$LAB_DIR" "$LAB_REGION" "$LAB_ACCOUNT" "$LAB_FUNCTION_GROUP" <<'PY'
import json
import sys
from pathlib import Path
directory, region, account, group = sys.argv[1:]
directory = Path(directory)
trust = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"}]
}
policy = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": f"arn:aws:logs:{region}:{account}:log-group:{group}:*"}]
}
(directory / "trust.json").write_text(json.dumps(trust))
(directory / "logs-policy.json").write_text(json.dumps(policy))
PY
LAB_ROLE_ARN=$(lab_aws iam create-role --role-name "$LAB_ROLE" \
--assume-role-policy-document "file://$LAB_DIR/trust.json" \
--query Role.Arn --output text)
lab_aws iam put-role-policy --role-name "$LAB_ROLE" --policy-name LabLogs \
--policy-document "file://$LAB_DIR/logs-policy.json"
sleep 30
(cd "$LAB_APP" && zip -q "$LAB_DIR/function.zip" index.mjs)
lab_aws lambda create-function --function-name "$LAB_NAME" \
--runtime nodejs24.x --handler index.handler --role "$LAB_ROLE_ARN" \
--architectures arm64 --memory-size 128 --timeout 5 \
--logging-config "LogGroup=$LAB_FUNCTION_GROUP,LogFormat=JSON,ApplicationLogLevel=INFO,SystemLogLevel=INFO" \
--zip-file "fileb://$LAB_DIR/function.zip" > "$LAB_DIR/function.json"
lab_aws lambda wait function-active-v2 --function-name "$LAB_NAME"
LAB_FUNCTION_ARN=$(lab_aws lambda get-function-configuration \
--function-name "$LAB_NAME" --query FunctionArn --output text)The 30-second pause gives the new role's trust policy time to propagate before Lambda assumes the role. It was enough in the November rerun. If function creation still reports a propagation error, inspect the role, wait, and check whether the named function exists before retrying. Don't create replacement resources to work around it.
The execution role lets the running code write logs. API Gateway's invoke permission comes separately. Test the deployed handler before adding the HTTP layer:
lab_aws lambda invoke --function-name "$LAB_NAME" \
--invocation-type RequestResponse --cli-binary-format raw-in-base64-out \
--payload "file://$LAB_DIR/event.json" "$LAB_DIR/direct-response.json" \
> "$LAB_DIR/direct-invoke.json"
cat "$LAB_DIR/direct-invoke.json"
cat "$LAB_DIR/direct-response.json"Check the invocation metadata for FunctionError and inspect the response file separately. This isolates the deployed handler before we test the gateway.
Our direct invocation returned StatusCode: 200, ExecutedVersion: $LATEST, and no FunctionError. Its separate response payload was:
{"statusCode":200,"headers":{"content-type":"application/json"},"isBase64Encoded":false,"body":"{\"status\":\"ok\"}"}That is the handler's proxy response. Once API Gateway is in front of it, the client should receive just {"status":"ok"} as the HTTP body.
Step 3: Create the API, integration and routes
LAB_API_ID=$(lab_aws apigatewayv2 create-api --name "$LAB_NAME" \
--protocol-type HTTP --query ApiId --output text)
printf '%s\n' "$LAB_API_ID" > "$LAB_DIR/api-id"
LAB_INTEGRATION_ID=$(lab_aws apigatewayv2 create-integration \
--api-id "$LAB_API_ID" --integration-type AWS_PROXY \
--integration-method POST --integration-uri "$LAB_FUNCTION_ARN" \
--payload-format-version 2.0 --timeout-in-millis 10000 \
--query IntegrationId --output text)
for route in 'GET /health' 'GET /hello' 'GET /error'; do
lab_aws apigatewayv2 create-route --api-id "$LAB_API_ID" \
--route-key "$route" --authorization-type NONE \
--target "integrations/$LAB_INTEGRATION_ID" > /dev/null
done
LAB_SOURCE_ARN="arn:aws:execute-api:$LAB_REGION:$LAB_ACCOUNT:$LAB_API_ID/\$default/GET/*"
lab_aws lambda add-permission --function-name "$LAB_NAME" \
--statement-id AllowHttpApiLab --action lambda:InvokeFunction \
--principal apigateway.amazonaws.com --source-arn "$LAB_SOURCE_ARN" \
--source-account "$LAB_ACCOUNT" > "$LAB_DIR/invoke-permission.json"The integration method POST describes how API Gateway invokes Lambda. Our client routes are still GET routes. These are different operations.
The function resource policy permits this API's $default stage and GET paths to invoke the handler. The execution role still only permits logging. This is the same separation we used for S3, with a different invoking service. HTTP API invoke permission
Configure access logs and an automatically deployed stage:
python3 - "$LAB_DIR" "$LAB_ACCESS_ARN" <<'PY'
import json
import sys
from pathlib import Path
settings = {
"DestinationArn": sys.argv[2],
"Format": json.dumps({
"requestId": "$context.requestId",
"routeKey": "$context.routeKey",
"status": "$context.status",
"lambdaServiceStatus": "$context.integrationStatus",
"integrationError": "$context.integrationErrorMessage"
}, separators=(",", ":"))
}
(Path(sys.argv[1]) / "access-log-settings.json").write_text(json.dumps(settings))
PY
lab_aws apigatewayv2 create-stage --api-id "$LAB_API_ID" \
--stage-name '$default' --auto-deploy \
--default-route-settings '{"ThrottlingBurstLimit":5,"ThrottlingRateLimit":2}' \
--access-log-settings "file://$LAB_DIR/access-log-settings.json" > "$LAB_DIR/stage.json"
LAB_URL=$(lab_aws apigatewayv2 get-api --api-id "$LAB_API_ID" \
--query ApiEndpoint --output text)
lab_aws apigatewayv2 get-routes --api-id "$LAB_API_ID" \
--query 'Items[].{Route:RouteKey,Target:Target,Authorization:AuthorizationType}'
lab_aws apigatewayv2 get-integration --api-id "$LAB_API_ID" \
--integration-id "$LAB_INTEGRATION_ID" \
--query '{Type:IntegrationType,Payload:PayloadFormatVersion,Method:IntegrationMethod}'The $default stage serves at the endpoint's root without a /prod prefix. It isn't a $default route. We still have only three explicit routes.
The access log omits source IPs, query strings, headers and bodies. status is the HTTP status the client receives. lambdaServiceStatus is the Lambda service's invocation status, which can be 200 even when our handler returns an HTTP error. integrationError helps explain a failed integration, including one that never reaches the handler. Logging variables
Step 4: Test the endpoint, not just the function
Use a helper to save the response headers and body while printing the HTTP status. It uses GET unless you supply a method as its third argument:
lab_http() {
curl --noproxy '*' --proto '=https' --tlsv1.2 \
--connect-timeout 10 --max-time 20 --silent --show-error \
-X "${3:-GET}" \
-D "$LAB_DIR/$1-headers.txt" -o "$LAB_DIR/$1-body.json" \
-w '%{http_code}\n' "$LAB_URL$2" || return
cat "$LAB_DIR/$1-body.json" || return
printf '\n'
}
lab_http health '/health'
lab_http hello '/hello?name=Ravi'
lab_http validation '/hello?name='The helper stops if curl cannot complete the request. It keeps HTTP error responses available for inspection. Wait for a successful baseline before changing permissions; inspect the configuration and logs if the endpoint doesn't answer as expected.
The validation branch returns an HTTP error deliberately. It doesn't throw, so Lambda can execute the handler successfully while the client receives HTTP 400. That's an application response, not a broken integration.
Our /health request returned HTTP 200 with {"status":"ok"}. The greeting returned HTTP 200 with {"message":"Hello, Ravi!"}. The empty-name request returned HTTP 400 with:
{"error":"name must contain 1 to 40 characters"}For that validation request, the access log recorded HTTP status 400 and Lambda service status 200. The matching handler entry recorded statusCode: 400.
Step 5: Compare routing and handler failures
lab_http missing '/missing'
lab_http wrong-method '/health' POST
lab_http handler-error '/error'/missing has no route. POST /health doesn't match GET /health either. With no catch-all, API Gateway rejects those requests. The handler's local fallback, Not found in handler, is different from the gateway's Not Found body.
GET /error does have a route. Our handler logs handler_failed and throws. The client gets a generic server error; the useful details are in the logs. Returning a correctly formatted error response instead would let the application choose its own HTTP status and message.
In our run, both unmatched requests returned HTTP 404 with {"message":"Not Found"}. The /error request returned HTTP 500 with:
{"message":"Internal Server Error"}Its access log recorded Lambda service status 200 and an integration error naming Deliberate handler failure. The handler's handler_failed entry and runtime error had the same Lambda request ID.
Step 6: Remove the invoke permission, then restore it
Remove only the statement created for this disposable function:
lab_aws lambda remove-permission --function-name "$LAB_NAME" \
--statement-id AllowHttpApiLab
lab_aws lambda get-policy --function-name "$LAB_NAME"
lab_http permission-error '/health'
lab_aws logs tail "$LAB_ACCESS_GROUP" --since 10m --format short
lab_aws logs tail "$LAB_FUNCTION_GROUP" --since 10m --format shortWith no statements left, get-policy should return ResourceNotFoundException. That describes the missing resource policy; the function itself still exists.
The route and integration still point to the handler, and its execution role hasn't changed. API Gateway has lost permission to invoke it. Check integrationError rather than widening the execution role.
The /health request now returned HTTP 500 with the same generic body as /error. This time, the access log recorded Lambda service status 403, and its integration error described missing permission to call the integration. No application entry matched that gateway request ID in our saved query.
The diagnostic says either the integration's IAM role or API Gateway lacks permission. Our integration has no credentials role, and we removed a specific function-policy statement. That controlled change tells us which permission to restore.
Wait until that failure is visible, then restore the same statement:
lab_aws lambda add-permission --function-name "$LAB_NAME" \
--statement-id AllowHttpApiLab --action lambda:InvokeFunction \
--principal apigateway.amazonaws.com --source-arn "$LAB_SOURCE_ARN" \
--source-account "$LAB_ACCOUNT" > "$LAB_DIR/restored-permission.json"
lab_http recovered '/health'Our fresh /health request returned HTTP 200 with {"status":"ok"} and a matching handler entry. The route, integration, endpoint URL and code were unchanged; we restored only the function-policy statement. API Gateway doesn't automatically retry a failed Lambda invocation, so this was a new request.
Follow the request across both logs
The saved HTTP headers include apigw-requestid. Match it to requestId in the gateway access log and gatewayRequestId in our application entry. Then use lambdaRequestId to find the corresponding runtime records.
Logs can arrive after the HTTP response. Query again if needed. A missing application entry alone doesn't prove that the handler never ran; combine the request-specific gateway error, inspected permission state and saved log query.
The local tests cover response formatting, greetings, validation, selected-field logging and the deliberate throw. The HTTPS requests check the deployed routes, integration and invoke permission; the saved logs let us follow those requests.
What we observed
The full eight-request sequence ran on November 3, 2026:
| Request or change | HTTP result | Evidence |
|---|---|---|
GET /health | 200 | Health JSON; matching access and handler entries |
GET /hello?name=Ravi | 200 | Greeting JSON; matching access and handler entries |
GET /hello?name= | 400 | Validation JSON; Lambda service status 200 |
GET /missing | 404 | Gateway Not Found body; no matching route |
POST /health | 404 | Gateway Not Found body; only a GET route exists |
GET /error | 500 | Lambda service status 200; correlated handler and runtime errors |
/health after removing invoke permission | 500 | Lambda service status 403; gateway permission diagnostic |
/health after restoring permission | 200 | Health JSON and matching handler entry at the same endpoint |
The final query contained six gateway access records. Neither unmatched request appeared in those logs. The Lambda query contained 20 events, including six application entries and six completion reports: one direct synthetic invocation plus five HTTP handler invocations. The permission failure and unmatched requests had no corresponding application entries in that query.
We matched each access record to a saved response-header request ID. The five HTTP handler invocations also matched application entries and completion reports. The deliberate error had a runtime error with errorMessage: Deliberate handler failure for the same Lambda request ID.
Why did API Gateway return HTTP 500 for two different failures?
AWS's general Lambda/API Gateway error guide describes HTTP 502 for a function error or malformed response. The October 22 and November 3 complete runs both returned HTTP 500 for the deliberate unhandled error. That's the observed result for this HTTP API lab. Lambda error handling through API Gateway
The logs show that the handler ran and threw; they don't explain the difference from the general guide. We didn't introduce a malformed proxy response. In both runs, a handler error and missing invoke permission produced the same HTTP status. Their log records told us which operation failed.
We didn't measure throughput, cold-start distributions or final billed cost, and we didn't test authenticated routes.
API Gateway, Function URLs and Web Functions
This lab uses a Lambda Event Function behind an API Gateway HTTP API. Our handler receives an event and returns a proxy response. API Gateway owns the routes in front of it.
A Function URL is another HTTPS entry point for an Event Function, using an HTTP event/response contract. A Web Function runs a listening HTTP server instead. Function URL contract, Web Functions model
For the hands-on comparison, see AWS Lambda Web Functions Tutorial: Deploy a Node.js API. There, Node's HTTP server handles requests directly. Its startup failure and recovery follow a different request path from this API Gateway integration.
The choice depends on the programming model and gateway features your application needs. These labs demonstrate the two paths; they don't benchmark one against the other.
Clean up the endpoint and supporting resources
Inspect the recorded API name and function role first:
lab_aws apigatewayv2 get-api --api-id "$LAB_API_ID" --query '{Name:Name,Type:ProtocolType}'
lab_aws lambda get-function-configuration --function-name "$LAB_NAME" \
--query '{Name:FunctionName,Role:Role}'
lab_aws apigatewayv2 delete-api --api-id "$LAB_API_ID"
lab_aws apigatewayv2 get-api --api-id "$LAB_API_ID"
lab_aws lambda remove-permission --function-name "$LAB_NAME" --statement-id AllowHttpApiLab
lab_aws lambda delete-function --function-name "$LAB_NAME"
lab_aws lambda get-function-configuration --function-name "$LAB_NAME"
lab_aws lambda get-policy --function-name "$LAB_NAME"
lab_aws iam delete-role-policy --role-name "$LAB_ROLE" --policy-name LabLogs
lab_aws iam delete-role --role-name "$LAB_ROLE"
lab_aws iam get-role --role-name "$LAB_ROLE"Expect NotFoundException for the deleted API, ResourceNotFoundException for the function and its policy, and NoSuchEntity for the role. Permission and connection failures don't verify deletion.
API access logging can create or update a CloudWatch Logs resource policy. Compare the saved baseline with a fresh snapshot:
lab_aws logs describe-resource-policies > "$LAB_DIR/logging-policies-after.json"Only remove a newly created policy after verifying that all its resources belong to this lab's exact access-log group. If an existing policy gained a lab-specific entry, review that entry while preserving the policy's other resources.
In this run, the baseline, installed and final resource-policy lists were all empty. No CloudWatch resource-policy deletion was needed.
Then remove both groups:
lab_aws logs delete-log-group --log-group-name "$LAB_FUNCTION_GROUP"
lab_aws logs delete-log-group --log-group-name "$LAB_ACCESS_GROUP"
for group in "$LAB_FUNCTION_GROUP" "$LAB_ACCESS_GROUP"; do
lab_aws logs describe-log-groups --log-group-name-prefix "$group" \
--query 'logGroups[].logGroupName' --output json
doneConfirm that neither exact group remains. Keep the local files until every absence check passes. If setup stopped partway through, remove only the dedicated resources that were actually created.
Final absence checks confirmed that the API, function, function policy, execution role and both log groups were absent. The October cleanup had also passed. No resources from either run remain active.
Find the boundary before changing the code
Check the method and route first, then whether API Gateway could invoke Lambda. If it could, inspect the handler's response or runtime error.
On your next failing endpoint, carry its gateway request ID through the access log and into the handler's logs. That gives you a concrete place to investigate before changing application code or widening permissions.
Related posts
- Lambda: Run Code Without Managing a Server — the handler model and the difference between triggering work and completing it.
- Understanding AWS IAM: Upload Allowed, Download Denied — why permission for one operation doesn't imply permission for another.
- AWS Lambda Web Functions Tutorial: Deploy a Node.js API — the separate walkthrough for a listening Node.js HTTP server.