Module 3: Serverless & Lambda for AI
6. API Gateway Integration
Overview
In this capsule you'll connect your Lambda to the internet through API Gateway. A Lambda without an HTTP trigger is a function no one can call — API Gateway is what turns your function into a real endpoint. By the end, you'll have a POST endpoint that receives a prompt, invokes your AI Lambda, and returns the response with CORS configured, basic authentication, and rate limiting.
Context: API Gateway is the AWS service that exposes your Lambdas as HTTP endpoints. There are two versions: REST API (v1) and HTTP API (v2). For AI endpoints, HTTP API is almost always the right choice — cheaper, simpler, and with better performance. This capsule covers both so you understand when to use each one, but the project uses HTTP API.
API Gateway as an HTTP Trigger
The complete flow
Client (browser, app, curl)
│
│ POST /ask {"prompt": "..."}
▼
┌──────────────┐
│ API Gateway │ ← Receives HTTP, validates, transforms
│ (HTTP API) │
└──────┬───────┘
│ Invokes Lambda (synchronous)
▼
┌──────────────┐
│ Lambda │ ← Runs handler, calls OpenAI
│ (ai-endpoint)│
└──────┬───────┘
│ Returns response
▼
┌──────────────┐
│ API Gateway │ ← Transforms response, adds headers
└──────┬───────┘
│ HTTP 200 {"answer": "..."}
▼
Client receives response
Latency of the flow
Component Typical latency
────────────────────────────────────
API Gateway 5-15ms
Lambda cold start 1-8s (first time)
Lambda warm <50ms
LLM API call 1-20s (depending on model/tokens)
API Gateway return 3-10ms
────────────────────────────────────
Total (warm): 1.5-20.5s
Total (cold): 2.5-28s
REST API vs HTTP API (v2)
Direct comparison
Feature REST API (v1) HTTP API (v2)
──────────────────────────────────────────────────────────
Price (per million) $3.50 $1.00
Latency overhead 15-30ms 5-10ms
WebSocket support ✅ ✅
Request validation ✅ (built-in) ❌ (in your code)
Usage plans/API keys ✅ (native) ❌ (manual)
Custom authorizers ✅ (Lambda + IAM) ✅ (Lambda + JWT)
Request/response map ✅ (VTL templates) ❌
WAF integration ✅ ❌
Caching ✅ (built-in) ❌
CORS ✅ (manual config) ✅ (simple config)
Payload max 10 MB 10 MB
Timeout max 29 seconds 29 seconds ⚠️
When to use each one
Use HTTP API (v2) when:
├── Your AI endpoint is simple: receives prompt → returns response
├── You don't need caching in Gateway (you do it in Lambda/Redis)
├── You want to minimize costs (3.5x cheaper)
├── Latency matters (less overhead)
└── 90% of the cases in this guide
Use REST API (v1) when:
├── You need native API keys with usage plans
├── You need request validation in Gateway (before invoking Lambda)
├── You need WAF (Web Application Firewall) for compliance
├── You need caching in Gateway to reduce Lambda invocations
└── Enterprise with granular access controls
The 29-second limit
⚠️ Both versions of API Gateway have a maximum timeout of 29 seconds. This is a hard limit you can't change. Your Lambda can have a 15-minute timeout, but if you invoke it via API Gateway, it must respond in under 29 seconds.
Implications for AI:
├── Simple calls to gpt-4o-mini (1-3s): ✅ no problem
├── Calls to gpt-4o with many tokens (5-15s): ✅ generally OK
├── Prompt chains (3+ sequential calls): ⚠️ risky
├── Complex RAG pipeline: ⚠️ may exceed 29s
└── Batch processing: ❌ don't use API Gateway, use direct invocation
For workloads >29s:
├── Async pattern: API Gateway starts Lambda → returns requestId
│ Lambda processes in the background → result in S3/DynamoDB
│ Client polls with GET /status/{requestId}
└── Direct invocation: aws lambda invoke (without Gateway)
Configure HTTP API (v2)
With a SAM template
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: python3.11
Architectures: [arm64]
Resources:
AiApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
CorsConfiguration:
AllowOrigins:
- "https://yourapp.com"
- "http://localhost:3000"
AllowMethods:
- POST
- GET
- OPTIONS
AllowHeaders:
- Content-Type
- Authorization
- X-Api-Key
MaxAge: 3600
AskFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
CodeUri: ./src/
MemorySize: 768
Timeout: 60
Environment:
Variables:
OPENAI_API_KEY: !Ref OpenAiApiKey
MODEL_NAME: gpt-4o-mini
Events:
AskRoute:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /ask
Method: POST
HealthRoute:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /health
Method: GET
HealthFunction:
Type: AWS::Serverless::Function
Properties:
Handler: health.handler
CodeUri: ./src/
MemorySize: 128
Timeout: 5
Events:
HealthRoute:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /health
Method: GET
Parameters:
OpenAiApiKey:
Type: String
NoEcho: true
Outputs:
ApiUrl:
Description: URL of the API Gateway
Value: !Sub "https://${AiApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
With the AWS CLI
# Create HTTP API
aws apigatewayv2 create-api \
--name ai-endpoint-api \
--protocol-type HTTP \
--cors-configuration '{
"AllowOrigins": ["http://localhost:3000"],
"AllowMethods": ["POST", "GET", "OPTIONS"],
"AllowHeaders": ["Content-Type", "Authorization"],
"MaxAge": 3600
}'
# Create the Lambda integration
aws apigatewayv2 create-integration \
--api-id API_ID \
--integration-type AWS_PROXY \
--integration-uri arn:aws:lambda:us-east-1:ACCOUNT:function:ai-endpoint \
--payload-format-version 2.0
# Create the POST /ask route
aws apigatewayv2 create-route \
--api-id API_ID \
--route-key "POST /ask" \
--target "integrations/INTEGRATION_ID"
# Create the stage and deploy
aws apigatewayv2 create-stage \
--api-id API_ID \
--stage-name prod \
--auto-deploy
CORS: Why your frontend gets errors
The problem
Your frontend (localhost:3000):
fetch("https://xyz.execute-api.us-east-1.amazonaws.com/prod/ask", {
method: "POST",
body: JSON.stringify({prompt: "Hi"})
})
Without CORS configured:
→ Browser sends a preflight OPTIONS request
→ API Gateway doesn't know what to answer
→ Browser blocks the response
→ Console: "Access to fetch has been blocked by CORS policy"
CORS configuration in HTTP API
# In the SAM template (the simplest way)
AiApi:
Type: AWS::Serverless::HttpApi
Properties:
CorsConfiguration:
AllowOrigins:
- "http://localhost:3000"
- "https://yourapp.com"
AllowMethods:
- POST
- GET
- OPTIONS
AllowHeaders:
- Content-Type
- Authorization
MaxAge: 3600 # Browser caches the preflight for 1 hour
CORS headers in your Lambda
HTTP API handles CORS automatically if you configure it in the template. But if you use REST API or want explicit control:
def handler(event, context):
headers = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN", "*"),
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
# Preflight request
if event.get("requestContext", {}).get("http", {}).get("method") == "OPTIONS":
return {"statusCode": 200, "headers": headers, "body": ""}
# Your normal logic
body = json.loads(event.get("body", "{}"))
# ... LLM call ...
return {
"statusCode": 200,
"headers": headers,
"body": json.dumps({"answer": "..."})
}
Debugging CORS
# Simulate the preflight from the terminal
curl -v -X OPTIONS \
https://xyz.execute-api.us-east-1.amazonaws.com/prod/ask \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
# You should see in the response:
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Methods: POST, GET, OPTIONS
# Access-Control-Allow-Headers: Content-Type
Request/Response in API Gateway v2
Lambda event format (payload v2.0)
# What your Lambda receives from HTTP API (payload format 2.0):
event = {
"version": "2.0",
"routeKey": "POST /ask",
"rawPath": "/prod/ask",
"headers": {
"content-type": "application/json",
"authorization": "Bearer sk-...",
"x-forwarded-for": "203.0.113.1",
},
"requestContext": {
"http": {
"method": "POST",
"path": "/prod/ask",
"sourceIp": "203.0.113.1",
},
"time": "08/Mar/2026:12:00:00 +0000",
"requestId": "abc123",
},
"body": "{\"prompt\": \"What is serverless?\", \"max_tokens\": 500}",
"isBase64Encoded": False,
}
Handler that parses correctly
import json
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=25)
CORS_HEADERS = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN", "*"),
}
def handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "")
if method == "OPTIONS":
return {"statusCode": 200, "headers": CORS_HEADERS, "body": ""}
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "Invalid JSON body"})
}
prompt = body.get("prompt", "").strip()
if not prompt:
return {
"statusCode": 400,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "prompt is required"})
}
max_tokens = min(body.get("max_tokens", 500), 2000)
try:
response = client.chat.completions.create(
model=os.environ.get("MODEL_NAME", "gpt-4o-mini"),
messages=[
{"role": "system", "content": "Respond concisely and helpfully."},
{"role": "user", "content": prompt}
],
max_tokens=max_tokens,
)
return {
"statusCode": 200,
"headers": CORS_HEADERS,
"body": json.dumps({
"answer": response.choices[0].message.content,
"model": response.model,
"tokens_used": response.usage.total_tokens,
})
}
except Exception as e:
return {
"statusCode": 502,
"headers": CORS_HEADERS,
"body": json.dumps({"error": f"LLM call failed: {str(e)}"})
}
Response format
# What your Lambda must return:
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "http://localhost:3000"
},
"body": "{\"answer\": \"Serverless is...\", \"tokens_used\": 42}"
}
# API Gateway takes this and builds the HTTP response for the client.
# The body MUST be a string (serialized JSON), not a dict.
Authentication
Option 1: API Key (simple)
To protect your endpoint without complex infrastructure:
import os
import json
VALID_API_KEYS = set(os.environ.get("API_KEYS", "").split(","))
def handler(event, context):
api_key = event.get("headers", {}).get("x-api-key", "")
if api_key not in VALID_API_KEYS:
return {
"statusCode": 401,
"body": json.dumps({"error": "Invalid or missing API key"})
}
# ... rest of the handler
# Invoke with an API key
curl -X POST https://xyz.execute-api.us-east-1.amazonaws.com/prod/ask \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-key-here" \
-d '{"prompt": "Hi"}'
Option 2: JWT Authorizer (HTTP API v2)
# SAM template with a JWT authorizer
Resources:
AiApi:
Type: AWS::Serverless::HttpApi
Properties:
Auth:
DefaultAuthorizer: JwtAuthorizer
Authorizers:
JwtAuthorizer:
AuthorizationScopes:
- ai.invoke
IdentitySource: "$request.header.Authorization"
JwtConfiguration:
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_POOLID"
audience:
- "your-client-id"
AskFunction:
Type: AWS::Serverless::Function
Properties:
Events:
AskRoute:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /ask
Method: POST
# The JWT authorizer is applied automatically
HealthRoute:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /health
Method: GET
Auth:
Authorizer: NONE # Health check without auth
Option 3: IAM Authorization
# Invoke with AWS credentials (service-to-service)
aws lambda invoke \
--function-name ai-endpoint \
--payload '{"body": "{\"prompt\": \"Hi\"}"}' \
response.json
# Or with Signature V4 for HTTP
# Useful when another AWS service invokes your endpoint
Recommendation for this guide
For development and learning:
└── API key in a header (Option 1) — simple, functional
For production:
├── JWT with Cognito/Auth0 (Option 2) — if you have users
└── IAM (Option 3) — if it's service-to-service
Rate Limiting and Throttling
Throttling in HTTP API
HTTP API v2 defaults:
├── Account-level: 10,000 requests/second
├── Route-level: configurable
└── Burst: 5,000 concurrent
For AI endpoints, 10K/s is more than enough.
Your bottleneck is OpenAI rate limits, not API Gateway.
Configure throttling per route
# SAM template
Resources:
AiApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
RouteSettings:
"POST /ask":
ThrottlingBurstLimit: 50 # Maximum concurrent
ThrottlingRateLimit: 100 # Requests per second
"GET /health":
ThrottlingBurstLimit: 200
ThrottlingRateLimit: 500
Rate limiting in your Lambda
API Gateway throttling protects your Lambda, but it doesn't protect your OpenAI account. Implement your own rate limiting:
import json
import os
import time
import hashlib
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=25)
MAX_REQUESTS_PER_MINUTE = int(os.environ.get("RATE_LIMIT", "30"))
# In production, use Redis/DynamoDB for distributed rate limiting
# This is a simplified version for a single Lambda instance
request_log = {}
def check_rate_limit(client_ip):
now = time.time()
window_start = now - 60
if client_ip not in request_log:
request_log[client_ip] = []
request_log[client_ip] = [
t for t in request_log[client_ip] if t > window_start
]
if len(request_log[client_ip]) >= MAX_REQUESTS_PER_MINUTE:
return False
request_log[client_ip].append(now)
return True
def handler(event, context):
client_ip = (
event.get("requestContext", {})
.get("http", {})
.get("sourceIp", "unknown")
)
if not check_rate_limit(client_ip):
return {
"statusCode": 429,
"body": json.dumps({
"error": "Rate limit exceeded",
"retry_after_seconds": 60
})
}
# ... rest of the handler
Complete Example: API Gateway → Lambda → OpenAI
Project structure
lambda-ai-api/
├── src/
│ ├── handler.py # POST /ask handler
│ ├── health.py # GET /health handler
│ └── requirements.txt
├── template.yaml # SAM template
└── samconfig.toml # SAM deploy config
src/handler.py
import json
import os
import time
import logging
from openai import OpenAI
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
timeout=int(os.environ.get("LLM_TIMEOUT", "25")),
max_retries=1,
)
CORS_HEADERS = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN", "*"),
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Api-Key",
}
IS_COLD_START = True
def handler(event, context):
global IS_COLD_START
was_cold = IS_COLD_START
IS_COLD_START = False
start = time.time()
method = event.get("requestContext", {}).get("http", {}).get("method", "")
if method == "OPTIONS":
return {"statusCode": 200, "headers": CORS_HEADERS, "body": ""}
api_key = event.get("headers", {}).get("x-api-key", "")
valid_keys = set(os.environ.get("API_KEYS", "").split(","))
if valid_keys != {""} and api_key not in valid_keys:
return {
"statusCode": 401,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "Unauthorized"})
}
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return {
"statusCode": 400,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "Invalid JSON"})
}
prompt = body.get("prompt", "").strip()
if not prompt:
return {
"statusCode": 400,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "prompt is required"})
}
if len(prompt) > 10000:
return {
"statusCode": 400,
"headers": CORS_HEADERS,
"body": json.dumps({"error": "prompt too long (max 10000 chars)"})
}
max_tokens = min(body.get("max_tokens", 500), 2000)
model = os.environ.get("MODEL_NAME", "gpt-4o-mini")
remaining_ms = context.get_remaining_time_in_millis()
if remaining_ms < 10000:
model = "gpt-4o-mini"
max_tokens = min(max_tokens, 200)
try:
llm_start = time.time()
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "Respond concisely and helpfully."},
{"role": "user", "content": prompt}
],
max_tokens=max_tokens,
)
llm_ms = round((time.time() - llm_start) * 1000)
except Exception as e:
logger.error(f"LLM error: {e}")
return {
"statusCode": 502,
"headers": CORS_HEADERS,
"body": json.dumps({"error": f"LLM call failed: {str(e)}"})
}
total_ms = round((time.time() - start) * 1000)
logger.info(json.dumps({
"event": "ask",
"cold_start": was_cold,
"llm_ms": llm_ms,
"total_ms": total_ms,
"tokens": response.usage.total_tokens,
"model": model,
}))
return {
"statusCode": 200,
"headers": CORS_HEADERS,
"body": json.dumps({
"answer": response.choices[0].message.content,
"model": response.model,
"tokens_used": response.usage.total_tokens,
"duration_ms": total_ms,
"cold_start": was_cold,
})
}
src/health.py
import json
import os
import time
def handler(event, context):
start = time.time()
checks = {"lambda": "up"}
openai_key = os.environ.get("OPENAI_API_KEY", "")
if openai_key and not openai_key.startswith("sk-"):
checks["openai_key"] = "invalid_format"
elif openai_key:
checks["openai_key"] = "configured"
else:
checks["openai_key"] = "missing"
overall = "healthy" if checks["openai_key"] == "configured" else "degraded"
return {
"statusCode": 200 if overall == "healthy" else 503,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"status": overall,
"checks": checks,
"region": os.environ.get("AWS_REGION", "unknown"),
"function": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "unknown"),
"memory_mb": os.environ.get("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "unknown"),
"latency_ms": round((time.time() - start) * 1000, 1)
})
}
src/requirements.txt
openai>=1.0.0
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: AI Endpoint with API Gateway
Globals:
Function:
Runtime: python3.11
Architectures: [arm64]
Parameters:
OpenAiApiKey:
Type: String
NoEcho: true
AllowedOrigin:
Type: String
Default: "*"
ApiKeys:
Type: String
Default: ""
Description: Comma-separated API keys (empty = no auth)
Resources:
AiApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
CorsConfiguration:
AllowOrigins:
- !Ref AllowedOrigin
AllowMethods: [POST, GET, OPTIONS]
AllowHeaders: [Content-Type, X-Api-Key]
MaxAge: 3600
RouteSettings:
"POST /ask":
ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100
AskFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
CodeUri: ./src/
MemorySize: 768
Timeout: 60
Environment:
Variables:
OPENAI_API_KEY: !Ref OpenAiApiKey
MODEL_NAME: gpt-4o-mini
LLM_TIMEOUT: "25"
ALLOWED_ORIGIN: !Ref AllowedOrigin
API_KEYS: !Ref ApiKeys
LOG_LEVEL: INFO
Events:
Ask:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /ask
Method: POST
HealthFunction:
Type: AWS::Serverless::Function
Properties:
Handler: health.handler
CodeUri: ./src/
MemorySize: 128
Timeout: 5
Environment:
Variables:
OPENAI_API_KEY: !Ref OpenAiApiKey
Events:
Health:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /health
Method: GET
Outputs:
ApiUrl:
Value: !Sub "https://${AiApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
AskEndpoint:
Value: !Sub "https://${AiApi}.execute-api.${AWS::Region}.amazonaws.com/prod/ask"
HealthEndpoint:
Value: !Sub "https://${AiApi}.execute-api.${AWS::Region}.amazonaws.com/prod/health"
Test the endpoint
# Deploy (if you have an AWS account)
sam build && sam deploy --guided
# Test health
curl https://xyz.execute-api.us-east-1.amazonaws.com/prod/health
# Test ask
curl -X POST https://xyz.execute-api.us-east-1.amazonaws.com/prod/ask \
-H "Content-Type: application/json" \
-H "x-api-key: your-key" \
-d '{"prompt": "What is serverless?", "max_tokens": 200}'
# Test from JavaScript (browser)
# fetch("https://xyz.execute-api.us-east-1.amazonaws.com/prod/ask", {
# method: "POST",
# headers: {"Content-Type": "application/json", "x-api-key": "your-key"},
# body: JSON.stringify({prompt: "Hi", max_tokens: 200})
# }).then(r => r.json()).then(console.log)
Troubleshooting
Problem 1: "CORS error in the browser"
# Symptom: "Access to fetch has been blocked by CORS policy"
# The preflight OPTIONS doesn't return the correct headers
# Diagnosis:
curl -v -X OPTIONS https://your-api.execute-api.us-east-1.amazonaws.com/prod/ask \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST"
# If you do NOT see Access-Control-Allow-Origin in the response:
# 1. Check CorsConfiguration in your template.yaml
# 2. HTTP API handles CORS automatically if it's configured
# 3. If you use REST API, you need to configure the OPTIONS method manually
# 4. Redeploy after changing the CORS config
Problem 2: "502 Bad Gateway" or "Internal Server Error"
# API Gateway can't invoke your Lambda or Lambda returned an error
# Diagnosis:
# 1. Check your Lambda's CloudWatch Logs
aws logs tail /aws/lambda/AskFunction --follow
# 2. Verify the response format is correct
# The body MUST be a string, not a dict
# ❌ {"statusCode": 200, "body": {"answer": "..."}}
# ✅ {"statusCode": 200, "body": "{\"answer\": \"...\"}"}
# 3. Check permissions: API Gateway needs permission to invoke Lambda
# SAM configures it automatically, but with manual CLI you need:
aws lambda add-permission \
--function-name ai-endpoint \
--statement-id apigateway \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com
Problem 3: "Timeout 504 — Endpoint request timed out"
# API Gateway timeout is 29 seconds (hard limit)
# If your Lambda takes longer, Gateway returns 504
# Solutions:
# 1. Optimize your Lambda (faster model, fewer tokens)
# 2. Use the async pattern (return requestId, process in the background)
# 3. Invoke Lambda directly (without Gateway) for long workloads
# If the latency is between 25-29s, it's a timing problem.
# Set the OpenAI client timeout to 20s to detect
# the LLM timeout before Gateway cuts you off.
Problem 4: "Missing Authentication Token" (REST API)
# If you use REST API (v1) and see this error,
# you're probably invoking a path that doesn't exist.
# REST API returns 403 "Missing Authentication Token" for nonexistent routes
# (confusing, but that's how it works).
# Verify:
aws apigateway get-resources --rest-api-id API_ID
# Make sure your route exists and the method is configured
Problem 5: "The response arrives truncated"
# API Gateway has a payload limit of 10MB
# But the most common issue is that your Lambda returns a body
# that isn't a serialized string
# Verify that json.dumps() wraps the whole body:
# ✅ "body": json.dumps({"answer": answer, "tokens": 42})
# ❌ "body": {"answer": answer, "tokens": 42}
Hands-On Exercises
Exercise 1: Add a GET /models endpoint
Create a GET /models endpoint that returns the available models, their estimated cost per 1K tokens, and the allowed max_tokens. It requires no authentication.
See solution
# src/models.py
import json
AVAILABLE_MODELS = {
"gpt-4o-mini": {
"cost_per_1k_input": 0.00015,
"cost_per_1k_output": 0.0006,
"max_tokens": 4096,
"description": "Fast and cheap, good for most tasks"
},
"gpt-4o": {
"cost_per_1k_input": 0.0025,
"cost_per_1k_output": 0.01,
"max_tokens": 4096,
"description": "Most capable, higher cost"
},
}
def handler(event, context):
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"models": AVAILABLE_MODELS,
"default": "gpt-4o-mini"
})
}
In template.yaml, add:
ModelsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: models.handler
CodeUri: ./src/
MemorySize: 128
Timeout: 5
Events:
Models:
Type: HttpApi
Properties:
ApiId: !Ref AiApi
Path: /models
Method: GET
Exercise 2: Implement request validation
Modify the /ask handler to validate: non-empty prompt, max_tokens between 1-2000, and that the requested model exists in the list of available models. Return descriptive errors with status 400.
See solution
import json
import os
from openai import OpenAI
VALID_MODELS = {"gpt-4o-mini", "gpt-4o"}
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=25)
def validate_request(body):
errors = []
prompt = body.get("prompt", "").strip()
if not prompt:
errors.append("prompt is required and cannot be empty")
elif len(prompt) > 10000:
errors.append(f"prompt too long: {len(prompt)} chars (max 10000)")
max_tokens = body.get("max_tokens", 500)
if not isinstance(max_tokens, int) or max_tokens < 1 or max_tokens > 2000:
errors.append("max_tokens must be integer between 1 and 2000")
model = body.get("model", "gpt-4o-mini")
if model not in VALID_MODELS:
errors.append(f"model '{model}' not available. Valid: {', '.join(VALID_MODELS)}")
return errors
def handler(event, context):
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return {
"statusCode": 400,
"body": json.dumps({"errors": ["Invalid JSON body"]})
}
errors = validate_request(body)
if errors:
return {
"statusCode": 400,
"body": json.dumps({"errors": errors})
}
response = client.chat.completions.create(
model=body.get("model", "gpt-4o-mini"),
messages=[{"role": "user", "content": body["prompt"]}],
max_tokens=body.get("max_tokens", 500),
)
return {
"statusCode": 200,
"body": json.dumps({
"answer": response.choices[0].message.content,
"tokens_used": response.usage.total_tokens,
})
}
Exercise 3: Async pattern for long workloads
Implement two endpoints: POST /ask-async that starts processing and returns a request_id, and GET /status/{request_id} that returns the status. Use an in-memory dict as a simplified store (in production you would use DynamoDB).
See solution
# src/ask_async.py
import json
import os
import uuid
import threading
import time
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=50)
results_store = {}
def process_llm(request_id, prompt, max_tokens):
try:
results_store[request_id]["status"] = "processing"
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
results_store[request_id] = {
"status": "completed",
"answer": response.choices[0].message.content,
"tokens_used": response.usage.total_tokens,
"completed_at": time.time(),
}
except Exception as e:
results_store[request_id] = {
"status": "failed",
"error": str(e),
}
def handler(event, context):
route = event.get("routeKey", "")
if route.startswith("POST"):
body = json.loads(event.get("body", "{}"))
request_id = str(uuid.uuid4())[:8]
results_store[request_id] = {"status": "queued"}
thread = threading.Thread(
target=process_llm,
args=(request_id, body["prompt"], body.get("max_tokens", 500))
)
thread.start()
return {
"statusCode": 202,
"body": json.dumps({
"request_id": request_id,
"status": "queued",
"check_url": f"/status/{request_id}"
})
}
elif route.startswith("GET"):
request_id = event.get("pathParameters", {}).get("request_id", "")
result = results_store.get(request_id)
if not result:
return {
"statusCode": 404,
"body": json.dumps({"error": "Request not found"})
}
status_code = 200 if result["status"] == "completed" else 202
return {
"statusCode": status_code,
"body": json.dumps(result)
}
In template.yaml:
AsyncFunction:
Type: AWS::Serverless::Function
Properties:
Handler: ask_async.handler
CodeUri: ./src/
MemorySize: 768
Timeout: 120
Events:
Submit:
Type: HttpApi
Properties:
Path: /ask-async
Method: POST
Status:
Type: HttpApi
Properties:
Path: /status/{request_id}
Method: GET
Exercise 4: Custom domain with API Gateway
Document (without implementing) the steps to connect a custom domain (api.yourapp.com) to your HTTP API. Include: ACM certificate, domain name configuration in API Gateway, and a DNS record in Route 53 or your DNS provider.
See solution
# Step 1: Create an SSL certificate in ACM (us-east-1 for API Gateway)
# AWS Console → Certificate Manager → Request certificate
# Domain: api.yourapp.com
# Validation: DNS (add the CNAME that ACM gives you)
# Step 2: Configure a custom domain in API Gateway
Resources:
CustomDomain:
Type: AWS::ApiGatewayV2::DomainName
Properties:
DomainName: api.yourapp.com
DomainNameConfigurations:
- CertificateArn: arn:aws:acm:us-east-1:ACCOUNT:certificate/CERT-ID
EndpointType: REGIONAL
ApiMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
ApiId: !Ref AiApi
DomainName: !Ref CustomDomain
Stage: prod
# Step 3: DNS Record
# In Route 53 or your DNS provider:
# Type: CNAME (or ALIAS in Route 53)
# Name: api.yourapp.com
# Value: d-XXXXXXXX.execute-api.us-east-1.amazonaws.com
# ↑ You get this value from the custom domain in API Gateway
# Step 4: Verify
# curl https://api.yourapp.com/health
# Should return your Lambda's health check
# With the AWS CLI:
# 1. Create the domain name
aws apigatewayv2 create-domain-name \
--domain-name api.yourapp.com \
--domain-name-configurations CertificateArn=arn:aws:acm:us-east-1:ACCOUNT:certificate/ID
# 2. Create the mapping
aws apigatewayv2 create-api-mapping \
--api-id API_ID \
--domain-name api.yourapp.com \
--stage prod
# 3. Get the target domain for DNS
aws apigatewayv2 get-domain-name --domain-name api.yourapp.com
# → ApiGatewayDomainName: d-XXXXXXXX.execute-api.us-east-1.amazonaws.com
# 4. Configure the CNAME in your DNS
Summary
- API Gateway turns your Lambda into an HTTP endpoint. Without Gateway, your Lambda isn't accessible from the internet.
- HTTP API (v2) is the right choice for AI endpoints — 3.5x cheaper, lower latency, simpler configuration.
- API Gateway's timeout is 29 seconds (hard limit). For longer workloads, use direct invocation or the async pattern.
- CORS must be configured in API Gateway (not just in your Lambda) so browsers can call your endpoint.
- The Lambda response body MUST be a string (
json.dumps()), not a dict — it's the most common error. - Authentication: API key for development, JWT for production with users, IAM for service-to-service.
- Rate limiting: configure throttling in API Gateway AND protect your OpenAI account with rate limits in your code.
- Payload format 2.0 (HTTP API): the event has a different structure than REST API. Use
event["requestContext"]["http"]["method"]for the method.
Additional Resources
- HTTP API (v2) Documentation — Complete HTTP API reference
- REST API vs HTTP API — Official comparison
- CORS Configuration — Configure CORS in HTTP API
- Lambda Proxy Integration — Lambda integration with payload v2.0
- JWT Authorizers — JWT authentication in HTTP API
- API Gateway Throttling — Rate limiting and throttling
- SAM Template Reference — SAM reference
- Custom Domain Names — Configure a custom domain