AWS Serverless

API Gateway 403 / 429 / 5xx Errors: The Complete Troubleshooting Playbook

The page fires at 03:20: 4XXError on the prod stage is a wall of red and the mobile team says “the API just started throwing 403s.” Before you touch a single setting you owe yourself three answers: which status code exactly, which layer of API Gateway generated it, and what does the responseType say. Get those wrong and you will “fix” CORS on a request that was really a throttle, widen an IAM policy for a 403 that was really a stage that never got deployed, or raise a Lambda timeout for a 502 that was really a function returning the wrong response shape. Amazon API Gateway fails in a small, finite set of very specific ways, and every one of them has an exact HTTP status, an exact responseType string, an exact access-log field that confirms it, and an exact fix.

This is the reference you keep open during the incident. API Gateway is not one thing — it is a pipeline (WAF → authorizer → throttle and usage plan → request validation → integration → integration response → gateway response), and each stage can reject a request with its own status code and its own signature. A 403 can be born in six different places; a 429 comes from one of four throttle layers plus the token bucket; a 5xx is almost always your integration, and the difference between a 502 (Lambda returned a malformed proxy shape), a 504 (the backend blew past the 29-second integration timeout) and a 500 (the authorizer errored) is the difference between a two-line code fix and a two-hour goose chase. The whole skill of operating API Gateway at 3 a.m. is naming the layer fast from the signal — and the signal lives in the access log $context fields and a handful of CloudWatch metrics, not in guesswork.

By the end you will read $context.error.responseType, $context.status, $context.integrationStatus, $context.integrationLatency and $context.authorizer.error like an instrument panel; know from one CloudWatch comparison (Latency vs IntegrationLatency) whether the delay is in your backend or in API Gateway’s own overhead; and carry a status + responseType → layer → root cause → confirm → fix playbook you can run under pressure. Everything is shown in both aws CLI (apigateway, apigatewayv2, logs, cloudwatch) and Terraform, with real limits, error strings and $context variables — never a hand-waved number. It maps directly to the API Gateway troubleshooting and observability domains of DVA-C02, SOA-C02 and SAA-C03.

What problem this solves

API Gateway sits at the front door of most serverless and microservice architectures, which means when it returns an error, every client sees it — and the error it returns is frequently misleading on purpose. The single most infamous example is Missing Authentication Token: it almost never means a token is missing. It means the request hit a path or method that API Gateway has no route for, or a stage that was never deployed. Teams burn hours adding authorizers and API keys to fix an auth error that was really a typo in the URL. The same misdirection runs through the whole error surface: a Forbidden that’s an unattached API key, a 500 that’s an authorizer exception, a 502 where your Lambda ran perfectly and returned 200 to API Gateway, which then rejected the shape of that 200.

What breaks without this knowledge is your mean-time-to-resolution and, quietly, your users’ trust. A team ships an API behind a custom domain, forgets the base-path mapping, and every request 403s with Missing Authentication Token — the API works fine on its execute-api URL, so they assume DNS. Another sets a tiny usage-plan quota for a partner, the partner’s nightly batch 429s at request 101, and because API Gateway has no dedicated throttle metric, nobody can see it in CloudWatch and everyone blames the network. A third puts a Lambda proxy integration into production, returns a bare object from the handler instead of the { statusCode, body } envelope, and ships a 502 to production on the happy path — the Lambda Errors metric is flat, so they debug the wrong service. None of these are exotic. They are the default behaviour of a rich, multi-layer service when you do not know which layer speaks which error.

Who hits this: anyone running an API past “hello world.” It bites hardest on teams new to API Gateway’s split between REST APIs (the feature-rich v1 with Gateway Responses, usage plans, mapping templates and WAF) and HTTP APIs (the cheaper, faster v2 with different, simpler error behaviour), on anyone fronting Lambda proxy integrations (where the response shape is a contract you can violate silently), on teams using custom domains and private endpoints (where base-path mappings and resource policies add two more 403 origins), and on anyone who assumed a 5xx meant “AWS is broken” rather than “my integration is.” The fix is almost never “retry and hope.” It is: read the status code, read the responseType, name the layer, confirm on the one field or metric that proves it, and apply the one setting that addresses that layer.

Here is the whole failure field on one screen — the status, the responseType that usually accompanies it, the layer it is born in, the one-line tell, and the first fix — so you can orient before the deep dive:

Status you see Usual responseType Layer it’s born in The one-line tell First fix
403 Forbidden MISSING_AUTHENTICATION_TOKEN Routing Wrong path/method, or stage not deployed Deploy the stage; fix the path/method
403 Forbidden ACCESS_DENIED / INVALID_SIGNATURE Auth (IAM) not authorized to perform: execute-api:Invoke Fix the IAM/resource policy or SigV4 signing
403 Forbidden INVALID_API_KEY (body: Forbidden) API keys Key missing, invalid, or not in a plan on this stage Attach the key to a usage plan bound to the stage
403 Forbidden WAF_FILTERED Security A WAF Web ACL blocked the request Inspect WAF sampled requests; tune the rule
403 Forbidden ACCESS_DENIED (base-path / private) Custom domain / private API Missing base-path mapping or endpoint-policy deny Add the mapping; allow the aws:SourceVpce
401 Unauthorized UNAUTHORIZED Auth (authorizer) Missing/expired/invalid token Fix the token or the authorizer identity source
429 Too Many Requests THROTTLED Throttle Token bucket empty (rate/burst) Raise account/stage/method/plan rate + burst
429 Too Many Requests QUOTA_EXCEEDED Usage plan Daily/weekly/monthly quota spent Raise the quota, or issue a new key
502 Bad Gateway (DEFAULT_5XX) Integration Lambda returned a malformed proxy shape Return { statusCode, body: "<string>" }
503 Service Unavailable (DEFAULT_5XX) Integration Backend / VPC-link target unavailable Restore backend health; check the VPC link
504 Gateway Timeout INTEGRATION_TIMEOUT Integration Backend slower than the 29 s ceiling Speed up the backend; async pattern; raise quota
500 Internal Server Error API_CONFIGURATION_ERROR / AUTHORIZER_FAILURE Config / Auth Bad mapping template, or authorizer threw Fix the VTL template; fix the authorizer

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with API Gateway basics: a REST API has resources (paths) and methods (HTTP verbs) wired to integrations (Lambda, HTTP, AWS-service, or a mock), deployed to a stage (prod, dev) that gives you the invoke URL https://{api-id}.execute-api.{region}.amazonaws.com/{stage}. You should know what a Lambda proxy integration is (API Gateway passes the whole request to Lambda and expects a specific response envelope back), be able to run aws from a shell and read JSON with --query, and be able to read a Terraform aws provider block. HTTP status codes, CORS, and the idea of an authorizer should be familiar.

This is the operational, incident-time companion to the design-side reference. If you have never built one of these APIs, start with the build-it-first walkthrough Amazon API Gateway Hands-On: REST vs HTTP APIs, Authorizers & Throttling — it covers resources, methods, integrations, the five authorizer types, usage plans and CORS in depth, and this article assumes you have seen them. The compute behind most integrations, and its own failure modes, are in AWS Lambda Errors, Timeouts & Cold Starts: The Troubleshooting Playbook — many an API Gateway 502/504 is really a Lambda problem, and the two playbooks are meant to be read together. When the front door is an ALB instead, the sibling reference is ALB 5xx Errors Decoded: A 502 / 503 / 504 Troubleshooting Playbook, and the decision of which front door you should be on in the first place is AWS Load Balancers and API Gateway: ALB, NLB and API Gateway Compared.

Before the deep dive, fix the mental model of where each failure lives, so you look in the right layer first:

Pipeline layer What lives here Failure classes it causes First place to look
Edge / WAF AWS WAF Web ACL, TLS, region/edge endpoint 403 WAF_FILTERED, TLS errors WAF sampled requests; $context.wafResponseCode
Routing / domain Resource + method match, custom domain, base-path mapping 403 MISSING_AUTHENTICATION_TOKEN, 404 $context.resourcePath; base-path mapping; is the stage deployed?
Authorization IAM, Cognito, Lambda authorizer, JWT (HTTP) 401 UNAUTHORIZED, 403 ACCESS_DENIED, 500 AUTHORIZER_FAILURE $context.authorizer.error, $context.authorizer.status
Throttle / metering Account/stage/method throttle, usage plan + API key 429 THROTTLED, 429 QUOTA_EXCEEDED, 403 INVALID_API_KEY Access log status=429; usage-plan config
Validation Request validators, JSON-schema models, media types 400 BAD_REQUEST_BODY/PARAMETERS, 415, 413 $context.error.message; the request validator
Integration Lambda proxy, HTTP, VPC link, AWS service 502 (shape), 503 (unavailable), 504 (timeout), 500 (config) $context.integrationStatus, $context.integrationLatency, execution logs
Observability Execution logs, access logs, metrics, X-Ray You can’t confirm anything Account CloudWatch role set? Access logging on?

Core concepts

The request pipeline — every error has a birthplace

An API Gateway request is not handled in one step; it flows through an ordered pipeline, and the first stage that rejects it decides the status code. Memorize this order, because it tells you which layer to inspect and, just as importantly, which layers the request never reached.

# Pipeline stage What it does Rejects with Reached only if…
1 Edge / WAF TLS terminate; AWS WAF Web ACL evaluation 403 WAF_FILTERED Always (if a Web ACL is attached)
2 Route match Match path + method to a resource/method 403 MISSING_AUTHENTICATION_TOKEN, 404 RESOURCE_NOT_FOUND Request passed WAF
3 Authorizer IAM / Cognito / Lambda / JWT identity check 401 UNAUTHORIZED, 403 ACCESS_DENIED, 500 AUTHORIZER_FAILURE A route matched
4 API key + usage plan Validate x-api-key, check plan membership 403 INVALID_API_KEY apiKeyRequired is set
5 Throttle Account → stage → method → plan token buckets 429 THROTTLED, 429 QUOTA_EXCEEDED Auth + key passed
6 Request validation Required params; JSON-schema body model 400 BAD_REQUEST_PARAMETERS/BODY, 415, 413 Not throttled
7 Integration request VTL mapping template → backend request 500 API_CONFIGURATION_ERROR Validation passed
8 Integration Call Lambda / HTTP / VPC link / AWS service 502, 503, 504 INTEGRATION_TIMEOUT Mapping succeeded
9 Integration response Map backend status/body → method response 502 (malformed proxy shape) Integration returned
10 Gateway response Render the final 4xx/5xx (body + CORS headers) — (it renders the error) Any earlier stage failed

Two consequences fall straight out of this order. First, a 403 at stage 2 means stages 3–10 never ran — so there is no authorizer log, no integration latency, nothing downstream to inspect; the request died at the door. Second, the “gateway response” at stage 10 is where the body and headers of every error are decided, which is why customizing Gateway Responses is how you fix both ugly error bodies and the missing-CORS-on-errors problem that makes a clean 401 look like a browser CORS failure.

The two logging systems — you need both, and one is off by default

API Gateway has two independent CloudWatch logging systems, and confusing them wastes incidents. Execution logging is the verbose, per-request internal trace (method request headers, the endpoint URI, the integration response, the failure reason) written to a log group API Gateway names for you. Access logging is the one-line-per-request structured record you define with $context variables, written to a log group you name — this is the one you build dashboards and metric filters on.

Aspect Execution logging Access logging
What it captures Full internal trace: request/response, integration, errors One structured line per request, fields you choose
Log group API-Gateway-Execution-Logs_{api-id}/{stage} (auto) Any log group ARN you set
Turned on by Stage methodSettings: loggingLevel = INFO/ERROR, dataTraceEnabled Stage accessLogSettings: destinationArn + format
Granularity Per method (or */* for all) Per stage
Best for Deep single-request forensics (the 502 reason string) Dashboards, metric filters, throttle counting, $context
Gotcha dataTraceEnabled logs full payloads — sensitive + costly Needs a valid format; empty/invalid format = silent no-logs
Shared prerequisite Both need the account-level CloudWatch Logs role ARN set once per region Same — no role = no logs, no error

That last row is the single most common “logging is broken” cause: API Gateway can only write to CloudWatch Logs if you have set an account-level IAM role (cloudwatchRoleArn) once per region. Miss it and you enable logging, see nothing, and assume the API isn’t erroring — when it is, invisibly.

# One-time per region: let API Gateway write to CloudWatch Logs (the classic missing step)
aws iam create-role --role-name apigw-cloudwatch \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"apigateway.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name apigw-cloudwatch \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
aws apigateway update-account \
  --patch-operations op=replace,path=/cloudwatchRoleArn,value=arn:aws:iam::111122223333:role/apigw-cloudwatch
# Terraform: the account role, plus a stage with BOTH logging systems on
resource "aws_api_gateway_account" "this" {
  cloudwatch_role_arn = aws_iam_role.apigw_cw.arn
}

resource "aws_api_gateway_stage" "prod" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  deployment_id = aws_api_gateway_deployment.d.id
  stage_name    = "prod"

  access_log_settings {                       # access logging (you pick the fields)
    destination_arn = aws_cloudwatch_log_group.access.arn
    format = jsonencode({
      requestId        = "$context.requestId"
      status           = "$context.status"
      responseType     = "$context.error.responseType"
      errorMessage     = "$context.error.message"
      integrationStatus= "$context.integrationStatus"
      integrationError = "$context.integration.error"
      authorizerError  = "$context.authorizer.error"
      latency          = "$context.responseLatency"
      integrationLat   = "$context.integrationLatency"
      wafCode          = "$context.wafResponseCode"
      apiKeyId         = "$context.identity.apiKeyId"
      sourceIp         = "$context.identity.sourceIp"
    })
  }
}

resource "aws_api_gateway_method_settings" "all" {  # execution logging (verbose trace)
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = aws_api_gateway_stage.prod.stage_name
  method_path = "*/*"
  settings {
    logging_level      = "INFO"
    data_trace_enabled = true   # ⚠️ logs full payloads — dev only
    metrics_enabled    = true
  }
}

REST APIs vs HTTP APIs — the error behaviour is genuinely different

The same request can produce different errors depending on whether you are on a REST API (apigateway, v1) or an HTTP API (apigatewayv2, v2). This table is load-bearing: several “why is my HTTP API behaving differently” incidents are answered by one row.

Behaviour REST API (v1) HTTP API (v2)
Unmatched route 403 {"message":"Missing Authentication Token"} 404 {"message":"Not Found"}
Integration timeout 29 s default max (raisable above 29 s via quota request) 30 s max, not raisable
Gateway Responses (custom 4xx/5xx bodies) Full support, 20+ responseTypes Not supported — fixed error bodies
API keys + usage plans Yes (rate/burst/quota per key) No native usage plans / API keys
Request/response mapping (VTL) Yes No (parameter mapping only)
Request validation (JSON-schema) Yes No
AWS WAF Yes (attach Web ACL to a stage) No (front with CloudFront + WAF)
Authorizer failure (Lambda) 500 AUTHORIZER_FAILURE 500 (simple response false403)
JWT authorizer via Cognito authorizer Native JWT: 401 bad token, 403 bad scope
Access log $context Rich, $context.error.responseType etc. Similar set, some fields differ/renamed
Cost Higher (per million + features) ~70% cheaper per million

The practical upshot: HTTP APIs give you fewer knobs and therefore fewer error origins, but also fewer diagnostic surfaces — you cannot customize a Gateway Response or count throttles with a usage plan. Most of this playbook is written for REST APIs (the richer, more error-prone surface); HTTP-API-specific behaviour is called out per section.

403 Forbidden: six different failures wearing one status code

The 403 is the most over-triggered, most misdiagnosed status in all of API Gateway, because at least six unrelated failures all surface as 403. The responseType and the exact body message disambiguate them — never debug a 403 without reading both. Here is the complete field:

responseType Body message Born in Root cause Confirm Fix
MISSING_AUTHENTICATION_TOKEN Missing Authentication Token Routing Path/method has no route, or stage not deployed, or wrong stage in URL $context.error.responseType; get-resources has no match; is there a deployment? Create the resource+method; deploy the stage; fix the URL
ACCESS_DENIED User: ... is not authorized to perform: execute-api:Invoke Auth (IAM) IAM identity/resource policy denies execute-api:Invoke Body names the ARN; check resource policy + caller IAM Grant execute-api:Invoke on the method ARN; fix resource policy
INVALID_SIGNATURE The request signature ... does not match Auth (IAM) SigV4 signing wrong (clock skew, wrong secret/region/service) responseType=INVALID_SIGNATURE; check clock + creds Re-sign correctly; sync clock; correct region/service in the signer
INVALID_API_KEY Forbidden API keys apiKeyRequired=true and key missing/invalid, or not in a usage plan on this stage responseType=INVALID_API_KEY; is key in a plan bound to the stage? Send valid x-api-key; attach the key to a usage plan on the stage
WAF_FILTERED Forbidden Security AWS WAF Web ACL rule blocked the request $context.wafResponseCode; WAF sampled requests Tune/allowlist the WAF rule; fix the false positive
ACCESS_DENIED (custom domain) Missing Authentication Token / Forbidden Custom domain Base-path mapping missing or points at the wrong stage Request works on execute-api URL, 403 on custom domain Create/repair the base-path mapping
ACCESS_DENIED (private API) ... not authorized ... Private API Resource policy or VPC-endpoint policy denies the aws:SourceVpce responseType=ACCESS_DENIED from inside the VPC only Allow the aws:SourceVpce/aws:SourceVpc in the resource policy
EXPIRED_TOKEN The security token ... has expired Auth (IAM) Temporary AWS credentials expired responseType=EXPIRED_TOKEN Refresh the credentials / role session

Missing Authentication Token — the 403 that is really a 404

This is the one that has cost the industry the most collective hours. 403 {"message":"Missing Authentication Token"} is API Gateway’s response when it cannot match the request path and method to any route — and it phrases a routing miss as an auth error for historical, security-through-obscurity reasons (it declines to confirm whether a route exists). The four real causes, in order of how often they bite:

Cause What actually happened Confirm Fix
Wrong path or method POST /order but only /orders exists, or GET where only POST is defined aws apigateway get-resources shows no matching path/method Correct the client URL/verb, or add the resource+method
Stage not deployed You created/changed methods but never created a deployment No deployment for the stage, or an old one aws apigateway create-deployment --stage-name prod
Wrong stage in the URL Calling /dev when it’s deployed to /prod (or vice-versa) The other stage works Use the correct stage path segment
Missing base-path mapping Custom domain has no mapping for that base path Works on the raw execute-api URL Add the base-path mapping to the domain

The tell that separates this from a genuine auth failure: there is no authorizer in the picture at all. If you have not attached an authorizer and you are getting “Missing Authentication Token,” it is a route/deploy problem 100% of the time. And the cruelest variant is after a change: you edit a method in the console or via CLI, test it, and it 403s — because API Gateway changes are inert until you deploy them to a stage. The mutation exists on the API but not on the stage the URL points at.

# Confirm the route exists, then (the usual fix) deploy the stage
aws apigateway get-resources --rest-api-id abcd1234 \
  --query 'items[].{path:path,methods:keys(resourceMethods||`{}`)}'
aws apigateway create-deployment --rest-api-id abcd1234 --stage-name prod

Private APIs — the three access controls behind one 403

A private API (endpoint type PRIVATE, reached only through an interface VPC endpoint) adds two more places a 403 can be born, and they fail so similarly that people fix the wrong one. Check all three, in order:

Control Governs 403 when it’s wrong Fix
Resource policy (on the API) Which VPC endpoints / principals may invoke No aws:SourceVpce (or principal) allow statement Allow the specific aws:SourceVpce/aws:SourceVpc
VPC endpoint policy (on the execute-api VPCE) What that endpoint is allowed to reach Endpoint policy denies the API or execute-api:Invoke Allow execute-api:Invoke on the API ARN
Security group (on the VPCE network interfaces) Network reachability to the endpoint Port 443 not open from the caller’s subnet Open 443 inbound from the client SG/CIDR

The tell that it’s a private-API 403 rather than an ordinary one: it happens only from inside the VPC (the request never leaves to the public execute-api edge), and the resource policy is almost always the culprit — a private API with no resource policy denies everything by default.

401 Unauthorized: the authorizer said no

A 401 in API Gateway is specifically the authorizer (or the native JWT authorizer on HTTP APIs) rejecting the request’s identity — and it is worth separating cleanly from the authorizer denying (which is a 403) and the authorizer erroring (which is a 500). One authorizer produces all three depending on what it returns.

Outcome Status responseType What the authorizer did Confirm
Token missing / fails identity source 401 UNAUTHORIZED TOKEN authorizer got no/badly-shaped token, or returned the literal Unauthorized $context.authorizer.status; body Unauthorized
Explicit Deny policy 403 ACCESS_DENIED Authorizer returned an IAM policy with Effect: Deny Body: not authorized to access this resource
Authorizer threw / timed out 500 AUTHORIZER_FAILURE The authorizer Lambda errored or exceeded its timeout $context.authorizer.error populated
Authorizer misconfigured 500 AUTHORIZER_CONFIGURATION_ERROR Bad authorizer setup (e.g. bad role, unreachable) responseType=AUTHORIZER_CONFIGURATION_ERROR
Cognito: invalid/expired JWT 401 UNAUTHORIZED Cognito user-pool authorizer rejected the token $context.authorizer.error; token exp
HTTP API JWT: bad token 401 (fixed) JWT missing/expired/wrong iss/aud HTTP-API access log; JWT claims
HTTP API JWT: insufficient scope 403 (fixed) Token valid but lacks the route’s required scope Route’s authorizationScopes vs token scope

Which authorizer you chose decides how a rejection looks, so knowing each type’s failure signature is half the diagnosis:

Authorizer type Identifies via 401 when… 403 when… 500 when…
IAM (AWS_IAM) SigV4 signature — (bad signature is a 403) execute-api:Invoke denied; signature/policy rejects
Cognito user pool Cognito JWT in Authorization Token missing/expired/invalid Valid token, scope/group not allowed Pool/config unreachable
Lambda TOKEN One header token + an identity-source regex Returns Unauthorized or regex miss Returns a Deny policy Function throws / times out
Lambda REQUEST Any headers/query/path/context Returns Unauthorized Returns a Deny policy Function throws / times out
JWT (HTTP API) OAuth2/OIDC JWT (iss/aud) Bad/expired/missing JWT Valid JWT, missing required scope Misconfigured issuer/JWKS

The clean mental split for JWT/OAuth: 401 = we don’t know who you are (authentication failed); 403 = we know who you are, but you can’t do this (authorization failed). HTTP APIs implement this distinction natively and correctly. For a REST-API Lambda authorizer, you control it: return the string Unauthorized (or throw) for a 401, return a Deny policy for a 403, and make sure the function never simply errors — an unhandled exception becomes a 500 AUTHORIZER_FAILURE, which reads to the client as “the API is broken,” not “you’re not allowed.”

# Test a REST-API authorizer in isolation — see exactly what it returns before blaming it
aws apigateway test-invoke-authorizer --rest-api-id abcd1234 \
  --authorizer-id auth123 \
  --headers Authorization=Bearer_badtoken
# Look at the returned policy (Allow/Deny) and the log; a thrown error here = your 500

429 Too Many Requests: the token bucket and four throttle layers

A 429 means the request was rejected by a rate limiter, not by your code. API Gateway throttles with a token-bucket algorithm at four layers, and a 429 can come from any of them. Understanding the bucket is the whole game: the burst is the bucket’s capacity (how many tokens can exist at once), the rate is how many tokens refill per second, each request removes one token, and an empty bucket returns 429 {"message":"Too Many Requests"} with responseType=THROTTLED. A separate, longer-window quota counter (per day/week/month, usage plans only) returns 429 with responseType=QUOTA_EXCEEDED when spent.

Throttle layer Scope Default responseType Set via
Account All APIs in the account, per Region 10,000 rps steady, 5,000 burst (soft, raisable) THROTTLED Service Quotas request
Stage (default method) Every method on a stage Inherits account THROTTLED Stage throttle / methodSettings */*
Per-method One resource+method on a stage Inherits stage THROTTLED methodSettings on {resource}/{METHOD}
Usage plan (per API key) A key’s traffic to a stage Whatever you set THROTTLED (rate/burst) + QUOTA_EXCEEDED (quota) create-usage-plan --throttle + --quota

The layers are evaluated most-specific-first, so the effective limit is the tightest one that applies to the request. A common surprise: a per-method throttle of 5 rps caps that method even though the stage and account allow thousands — someone set it months ago and forgot. The token-bucket parameters, precisely:

Parameter Meaning Empty/exhausted result Where it applies
Rate limit (rateLimit) Steady-state refill, requests/second Sustained overage → 429 THROTTLED All four layers
Burst limit (burstLimit) Bucket capacity — max simultaneous A spike beyond it → 429 THROTTLED All four layers
Quota (limit + period) Total requests per DAY/WEEK/MONTH Counter spent → 429 QUOTA_EXCEEDED Usage plans only

The layers are checked most-specific-first, and the first ceiling the request crosses is the one that throttles it — which is why a forgotten per-method limit can throttle a method while the stage and account sit idle:

Order Limit evaluated Scope If exceeded
1 Usage-plan per-method override One key, one method 429 THROTTLED
2 Usage-plan rate/burst + quota One key, all methods 429 THROTTLED / QUOTA_EXCEEDED
3 Per-method stage throttle All callers, one method 429 THROTTLED
4 Stage default, then account (10k/5k) All callers, all methods 429 THROTTLED

And here is the single most important 429 gotcha: API Gateway has no dedicated throttle metric. The AWS/ApiGateway namespace emits Count, 4XXError, 5XXError, Latency, IntegrationLatency, CacheHitCount and CacheMissCount — and 429s are counted inside 4XXError, indistinguishable from 400s and 403s at the metric level. To see throttling specifically you must go to the access log, filter status = 429, and split on $context.error.responseType (THROTTLED vs QUOTA_EXCEEDED). Build a CloudWatch Logs metric filter on that if you want a graph. Teams waste hours looking for a throttle metric that does not exist.

# Tighten a per-method throttle (REST): all methods, or one method
aws apigateway update-stage --rest-api-id abcd1234 --stage-name prod \
  --patch-operations \
    op=replace,path=/*/*/throttling/rateLimit,value=100 \
    op=replace,path=/*/*/throttling/burstLimit,value=50

# A usage plan with a deliberately tiny quota (partner tier)
aws apigateway create-usage-plan --name partner-basic \
  --throttle rateLimit=10,burstLimit=5 \
  --quota limit=1000,period=DAY \
  --api-stages apiId=abcd1234,stage=prod

# CONFIRM a 429 from access logs (there is no ThrottleCount metric)
aws logs filter-log-events --log-group-name /apigw/access/orders-prod \
  --filter-pattern '{ $.status = "429" }' \
  --query 'events[].message' --output text
# Terraform: stage-level default throttle + a metered usage plan
resource "aws_api_gateway_method_settings" "throttle" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = aws_api_gateway_stage.prod.stage_name
  method_path = "*/*"
  settings {
    throttling_rate_limit  = 100
    throttling_burst_limit = 50
    metrics_enabled        = true
  }
}

resource "aws_api_gateway_usage_plan" "partner_basic" {
  name = "partner-basic"
  api_stages {
    api_id = aws_api_gateway_rest_api.api.id
    stage  = aws_api_gateway_stage.prod.stage_name
  }
  throttle_settings { rate_limit = 10  burst_limit = 5 }
  quota_settings   { limit = 1000  period = "DAY" }
}

5xx: 502, 503, 504 and 500 — where the server broke

A 5xx from API Gateway is almost always about the integration — the backend it called on your behalf — not about AWS being down. The four codes are precise, and the difference between them tells you exactly where to look. Read $context.integrationStatus and $context.integrationLatency first; they separate “the backend answered badly” from “the backend never answered in time.”

Status responseType Meaning Root cause Confirm Fix
500 API_CONFIGURATION_ERROR API Gateway couldn’t process the integration config Broken VTL mapping template; bad stage variable; wrong integration URI Execution log names the template/line; $context.error.message Fix the mapping template / stage variable
500 AUTHORIZER_FAILURE The authorizer errored or timed out Authorizer Lambda threw / exceeded timeout $context.authorizer.error set Fix the authorizer code/perms/timeout
502 (DEFAULT_5XX) Bad gateway — integration returned something unusable Lambda proxy returned a malformed shape; non-JSON body where JSON expected integrationStatus=200 but status=502; log: Malformed Lambda proxy response Return { statusCode, headers, body:"<string>" }
503 (DEFAULT_5XX) Service unavailable Backend down; VPC-link NLB has no healthy target; integration overloaded integrationStatus=503/-; backend/VPC-link health Restore backend; fix VPC-link target health
504 INTEGRATION_TIMEOUT Gateway timeout — integration exceeded the limit Backend slower than the 29 s ceiling (REST) / 30 s (HTTP) integrationLatency ≈ 29000; log: Execution failed due to a timeout error Speed up backend; async/202 pattern; raise the 29 s quota
504 INTEGRATION_FAILURE Integration returned an unrecoverable error Integration connection reset / unreachable endpoint $context.integration.error; integrationStatus=- Fix connectivity; retry/backoff at the backend

Which 5xx you get, and whose fault it is, depends on the integration type — specifically on who is responsible for shaping the response:

Integration type Who shapes the response Typical 5xx failure
Lambda proxy (AWS_PROXY) Your handler must return {statusCode, headers, body} 502 on a malformed / bare-object shape
Lambda (non-proxy) An integration-response VTL template you write 500 API_CONFIGURATION_ERROR on a broken template
HTTP / HTTP_PROXY The backend’s status is passed through 502/503/504 from the backend or the connection
AWS service (e.g. SQS, DynamoDB) A mapping template → the service API 500 on a bad template; the service error mapped through

The 502 that is really a 200 — malformed Lambda proxy response

This is the 5xx that fools everyone, because your Lambda succeeded. In a Lambda proxy integration, API Gateway hands the whole request to Lambda and expects a very specific envelope back:

Field Required? Type If you get it wrong
statusCode Yes integer Missing → 502 Malformed Lambda proxy response
body Yes (for a body) string (JSON-stringified, not an object) Object instead of string → 502
headers Optional object of string values Non-string values can break rendering
isBase64Encoded Optional boolean Wrong for binary → corrupted/502 payloads
multiValueHeaders Optional object of string arrays Malformed → 502
(returning a bare object) return {"ok":true}502, not 200

When the handler returns the wrong shape — the classic being a bare return {"ok": true} instead of return {"statusCode": 200, "body": json.dumps({"ok": True})}Lambda runs perfectly and returns 200 to API Gateway, which then rejects the shape and synthesizes a 502 to the client. The fingerprint is unmistakable once you know it: in the access log, $context.integrationStatus = 200 while $context.status = 502, integrationLatency is normal, the Lambda Errors metric is flat, and the execution log says Execution failed due to configuration error: Malformed Lambda proxy response. You will debug the wrong service for an hour if you trust the Lambda metrics; trust the integrationStatus vs status split instead.

The 504 at exactly 29 seconds — the integration timeout

API Gateway will wait a maximum of 29 seconds (REST APIs, default) for an integration to respond before it gives up and returns a 504 with responseType=INTEGRATION_TIMEOUT. This ceiling is independent of your Lambda’s own timeout (which can be up to 900 s) — Lambda keeps running and billing after API Gateway has already 504’d the client, which is a real cost and correctness trap. The fingerprint: $context.integrationLatency pinned at ~29,000 ms, the execution log line Execution failed due to a timeout error, and — the tell that it’s the gateway not the backend giving up — the backend (Lambda/EC2) log shows the work continuing past the 504. As of 2024, REST APIs can request a quota increase above 29 s, but HTTP APIs are hard-capped at 30 s and not raisable. The right fix is rarely “wait longer”: it is to make the call fast, or to switch to an asynchronous pattern — return 202 Accepted immediately, do the work in the background (Step Functions, SQS + Lambda), and let the client poll or receive a webhook.

Gateway Responses: customizing 4xx/5xx bodies and CORS-on-errors

Every error above is ultimately rendered by a Gateway Response — the pipeline’s final stage. On REST APIs you can customize the body, status code and headers of each responseType, which is how you turn a terse {"message":"Forbidden"} into a structured error your clients can parse, and — far more importantly — how you put CORS headers on error responses. This is the reference every senior keeps bookmarked: the full responseType set with default status codes.

responseType Default status Class Fires when
ACCESS_DENIED 403 Auth IAM/authorizer explicit Deny; resource-policy deny
API_CONFIGURATION_ERROR 500 Config Invalid mapping template, bad stage var, bad integration
AUTHORIZER_CONFIGURATION_ERROR 500 Auth API Gateway can’t reach/parse the authorizer
AUTHORIZER_FAILURE 500 Auth Authorizer Lambda threw or timed out
BAD_REQUEST_PARAMETERS 400 Validation Required query/header/path parameter missing
BAD_REQUEST_BODY 400 Validation Body fails the JSON-schema model
DEFAULT_4XX (4xx) Fallback Any 4xx with no specific type set — add CORS here
DEFAULT_5XX (5xx) Fallback Any 5xx with no specific type set — add CORS here
EXPIRED_TOKEN 403 Auth Temporary AWS credentials expired (IAM auth)
INTEGRATION_FAILURE 504 Integration Integration returned an unrecoverable error
INTEGRATION_TIMEOUT 504 Integration Integration exceeded the 29 s ceiling
INVALID_API_KEY 403 Keys apiKeyRequired but key missing/invalid/not in a plan
INVALID_SIGNATURE 403 Auth SigV4 signature mismatch (IAM auth)
MISSING_AUTHENTICATION_TOKEN 403 Routing Route (resource+method) not found / not deployed
QUOTA_EXCEEDED 429 Throttle Usage-plan quota (day/week/month) exhausted
REQUEST_TOO_LARGE 413 Payload Request body over the 10 MB limit
RESOURCE_NOT_FOUND 404 Routing Resource explicitly not found
THROTTLED 429 Throttle Account/stage/method/plan rate+burst bucket empty
UNAUTHORIZED 401 Auth Authorizer returned Unauthorized / token failed identity source
UNSUPPORTED_MEDIA_TYPE 415 Validation Content-Type not accepted by the model/mapping
WAF_FILTERED 403 Security AWS WAF Web ACL blocked the request

The CORS-on-errors trap

Here is a bug that generates a flood of “the API is down” reports when it isn’t: CORS headers you configured on your method/integration responses are NOT applied to Gateway Responses. So when API Gateway returns a 401, 403, 429 or 500 from an early pipeline stage (before your integration ran), the response has no Access-Control-Allow-Origin header — and a browser therefore reports it as a CORS error, hiding the real 401/429 from your frontend entirely. The developer sees “CORS policy blocked” and debugs CORS for hours; the real problem is an expired token or a throttle. The fix is to add the CORS headers to the DEFAULT_4XX and DEFAULT_5XX Gateway Responses so every error carries them:

# Put CORS headers on ALL 4xx errors so the browser sees the real status, not a CORS error
aws apigateway put-gateway-response --rest-api-id abcd1234 \
  --response-type DEFAULT_4XX \
  --response-parameters '{"gatewayresponse.header.Access-Control-Allow-Origin":"'"'"'*'"'"'"}'
# Terraform: CORS on default errors + a structured 429 body
resource "aws_api_gateway_gateway_response" "cors_4xx" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  response_type = "DEFAULT_4XX"
  response_parameters = {
    "gatewayresponse.header.Access-Control-Allow-Origin"  = "'*'"
    "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization,x-api-key'"
  }
}

resource "aws_api_gateway_gateway_response" "throttled" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  response_type = "THROTTLED"
  status_code   = "429"
  response_templates = {
    "application/json" = "{\"error\":$context.error.messageString,\"type\":\"$context.error.responseType\",\"requestId\":\"$context.requestId\"}"
  }
  response_parameters = {
    "gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
    "gatewayresponse.header.Retry-After"                 = "'1'"
  }
}

Reading the signal: access-log $context, execution logs, and the metrics

You never fix an API Gateway problem you haven’t confirmed on a field or metric first. The access log is your primary instrument — but only if it carries the right $context variables. These are the fields that actually resolve incidents, and what each one tells you.

$context field What it is What it confirms
$context.status Final status returned to the client The class (403/429/502/504)
$context.error.responseType The Gateway Response type The exact sub-cause (THROTTLED vs QUOTA_EXCEEDED, etc.)
$context.error.message API Gateway’s error message string Human-readable reason
$context.error.messageString Quoted version (for JSON templates) Use in Gateway Response templates
$context.integrationStatus Status the integration returned 200 here + 502 in status = malformed proxy shape
$context.integration.status Same, newer form Integration’s own status
$context.integration.error Error message from the integration Why the integration failed
$context.integrationErrorMessage Integration error (legacy field) Backend error text
$context.integrationLatency Backend-only latency (ms) ~29,000 = the 29 s timeout
$context.responseLatency Total end-to-end latency (ms) Compare with integrationLatency
$context.authorizer.error Authorizer error message The authorizer threw/failed
$context.authorizer.status Authorizer’s returned status 401/403/500 from auth
$context.authorizer.latency Authorizer invocation time Slow/timing-out authorizer
$context.wafResponseCode WAF’s verdict 403 blocks came from WAF
$context.identity.apiKeyId The API key used Which key hit a quota
$context.identity.sourceIp Caller IP Who is generating the errors
$context.identity.userArn IAM caller ARN Which principal was denied

When the access log tells you the class but not the why, drop into the execution log for that request — its lines are verbose but predictable, and a handful of them decide the diagnosis:

Execution-log line (excerpt) What it tells you
Method request path: {...} / Method request headers: The request as API Gateway parsed it (routing + validation)
Endpoint request URI: ... The integration URL it actually called
Received response. Status: 200 The integration’s own status — compare with the final status
Execution failed due to configuration error: Malformed Lambda proxy response The 502 — Lambda returned a bad proxy shape
Execution failed due to a timeout error The 504 — the 29 s integration timeout fired
Lambda execution failed with status 200 due to customer function error: ... The Lambda threw; API Gateway received the error payload
Method completed with status: 502 The final status returned to the client

Latency vs IntegrationLatency — where did the time go?

Two latency metrics, and their difference is a diagnostic in itself. Latency is the total time from API Gateway receiving the request to returning the response; IntegrationLatency is only the backend portion. Subtract them and you get API Gateway’s own overhead — authorizer, mapping templates, throttling checks.

What you observe It means Do this
IntegrationLatency high, ≈ Latency The backend is slow — 504 risk at 29 s Speed up/scale the integration; async pattern
Latency high, IntegrationLatency low API Gateway overhead — usually a slow authorizer or heavy VTL Cache the authorizer (TTL); simplify mapping templates
Both low, but 5xx Not latency — it’s a shape/config error Check integrationStatus vs status (the 502)
IntegrationLatency pinned ~29,000 ms The 29 s integration timeout fired 504; make the call fast or go async

The CloudWatch metrics themselves are few — know them cold, and know their gaps:

Metric Namespace Confirms Read it as
Count AWS/ApiGateway Traffic baseline Denominator for error rate
4XXError AWS/ApiGateway Client-side errors (incl. 401/403/404/429) 429s hide here — split via access logs
5XXError AWS/ApiGateway Server-side errors (500/502/503/504) Rising → integration/authorizer problem
Latency AWS/ApiGateway Total round-trip time p99 near 29 s → 504 imminent
IntegrationLatency AWS/ApiGateway Backend-only time Compare with Latency for the decode above
CacheHitCount / CacheMissCount AWS/ApiGateway REST cache effectiveness Low hit rate → cache key/TTL wrong
(no ThrottleCount) There is none — count 429s from access logs
# Split latency: is the delay in the backend or in API Gateway's overhead?
aws cloudwatch get-metric-statistics --namespace AWS/ApiGateway \
  --metric-name IntegrationLatency --dimensions Name=ApiName,Value=orders Name=Stage,Value=prod \
  --start-time "$(date -u -v-1H +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 300 --statistics Average p99

# Confirm 5xx and split by responseType from access logs (Logs Insights)
aws logs start-query --log-group-name /apigw/access/orders-prod \
  --start-time $(date -u -v-1H +%s) --end-time $(date -u +%s) \
  --query-string 'fields @timestamp, status, responseType, integrationStatus | filter status >= 500 | stats count() by responseType, integrationStatus'

Keep this access-log query cookbook — each turns a vague “it’s erroring” into a confirmed layer in one paste (run against your access-log group in Logs Insights):

Goal Logs Insights query
Throttles (the missing metric) filter status="429" | stats count() by responseType (splits THROTTLED vs QUOTA_EXCEEDED)
The malformed-proxy 502 filter status="502" and integrationStatus="200" | stats count() by bin(5m)
The 29-second 504 filter responseType="INTEGRATION_TIMEOUT" | stats count(), avg(integrationLat) by bin(5m)
403 breakdown filter status="403" | stats count() by responseType (routing vs IAM vs key vs WAF)
Authorizer failures filter authorizerError != "" | stats count() by authorizerError
Slow authorizer (overhead) filter latency > 1000 and integrationLat < 200 | stats count() by bin(5m)

A fast triage decision table for the first 60 seconds of an incident:

If you see… It’s probably… Do this first
403, no authorizer attached Route/deploy miss (MISSING_AUTHENTICATION_TOKEN) Check the path/method; deploy the stage
403 Forbidden, key in use API key not in a plan on this stage Attach the key to a usage plan bound to the stage
403, only on the custom domain Base-path mapping Repair the base-path mapping
401 UNAUTHORIZED Bad/expired token Fix the token / authorizer identity source
429, 4XXError up but no throttle graph Throttle (no metric exists) Filter access logs status=429; raise limits
502, Lambda Errors flat Malformed proxy shape integrationStatus=200 + status=502 → fix the envelope
504, IntegrationLatency ≈ 29 s 29 s integration timeout Speed up backend / go async
500, $context.authorizer.error set Authorizer threw Fix the authorizer code/timeout
5xx, integrationStatus = real 5xx The backend returned it Debug the backend, not API Gateway
Browser “CORS blocked” on error Missing CORS on Gateway Responses Add CORS to DEFAULT_4XX/DEFAULT_5XX

Architecture at a glance

The diagram traces the real path a request takes through API Gateway and pins each error class to the exact hop where it is born. Read left to right: a client (SigV4-signed, JWT-bearing, or API-key-carrying) hits the API Gateway edge, where WAF and the authorizer run first (401/403 origin), then the throttle and usage-plan token bucket (429 origin), then the integration to Lambda proxy (the 502 malformed-shape origin) or a backend behind a VPC link (the 504 timeout origin) — and every outcome is confirmed from CloudWatch metrics and the access-log $context on the right. The six numbered badges mark where 403/401, 429, 502, 504 and the confirm step each bite; the legend narrates every one as status · confirm · fix.

API Gateway error-origin map showing a client with SigV4, JWT and API-key credentials calling an Amazon API Gateway REST API on the prod stage, where a WAF and authorizer stage generates 401 UNAUTHORIZED and 403 ACCESS_DENIED and MISSING_AUTHENTICATION_TOKEN errors, a throttle-and-usage-plan stage running a 10,000 rps by 5,000 burst token bucket generates 429 THROTTLED and QUOTA_EXCEEDED, an integration stage where a Lambda proxy integration bounded by a 29-second timeout generates a 502 when the function returns a malformed proxy response shape while integrationStatus stays 200, and a backend behind a VPC link generates a 504 INTEGRATION_TIMEOUT, all converging on a CloudWatch metrics plus access-log $context observability zone that carries 4XXError, 5XXError, Latency versus IntegrationLatency and error.responseType, with six numbered badges marking the 403 routing miss, the 401/403 authorizer, the 429 throttle, the 502 malformed proxy shape, the 504 integration timeout, and the confirm-from-telemetry step

Real-world scenario

PetalCourier, a mid-size logistics startup on ap-south-1, ran a REST API behind API Gateway fronting three Lambda proxy integrations: a synchronous rate-quote endpoint, a label-generation endpoint that called a slow third-party carrier API, and a partner webhook metered by usage plan. Over one peak-season Monday all three broke in ways that, on the surface, looked like “API Gateway is flaky.” The on-call engineer’s runbook note afterwards read: read the status and the responseType first — the same “it’s 5xx’ing” meant three different services.

The rate-quote endpoint started returning 502 to a slice of users right after a deploy. The Lambda Errors metric was flat and Duration was healthy, so the team spent forty minutes convinced it was a networking blip. The access log told the truth in one line: status=502 while integrationStatus=200. A refactor had changed the handler’s happy path to return quote (a bare dict) instead of the proxy envelope return {"statusCode": 200, "body": json.dumps(quote)}. Lambda ran perfectly and returned 200 to API Gateway, which rejected the shape and synthesized the 502 — the execution log’s Malformed Lambda proxy response confirmed it. The fix was three lines restoring the envelope; the lesson was to trust integrationStatus over the Lambda metrics.

The label-generation endpoint threw 504 under load. IntegrationLatency was pinned at 29,000 ms and the execution log said Execution failed due to a timeout error, while the Lambda’s own CloudWatch log showed the carrier call still running at 45 seconds — Lambda kept working (and billing) long after API Gateway had 504’d the client. Raising the Lambda timeout did nothing, because the ceiling was API Gateway’s 29 seconds, not Lambda’s. The real fix was architectural: they switched label generation to an asynchronous 202 pattern — the endpoint now drops the job on SQS and returns 202 Accepted with a job ID in well under a second, a worker Lambda calls the carrier without a 29-second gun to its head, and the client polls a status endpoint. p99 on the front door fell from 29 s (timing out) to ~120 ms.

The partner webhook was the subtle one. A partner reported “random failures,” but the API dashboards looked fine5XXError was zero and 4XXError only mildly elevated. Because API Gateway has no throttle metric, the 429s were invisible, buried inside 4XXError alongside ordinary 400s. The engineer filtered the access log for status=429 and found responseType=QUOTA_EXCEEDED: the partner’s usage plan had a limit=10000, period=DAY quota, and their new nightly reconciliation batch blew through it by 09:00. Compounding it, the partner’s frontend showed a generic “network error” because the 429 Gateway Response carried no CORS header — the browser reported a CORS failure, masking the real cause. The fix was two-fold: raise the quota to match the partner’s real volume and add Retry-After plus CORS headers to the THROTTLED/QUOTA_EXCEEDED Gateway Responses so the partner’s client could back off intelligently instead of seeing a phantom CORS error.

Advantages and disadvantages

Understanding API Gateway’s error model is the difference between it being a robust, observable front door and being an opaque source of misleading 4xx/5xx. The same richness that gives you fine-grained control also creates more layers that can each fail.

Advantages (when you operate it right) Disadvantages (the traps to manage)
Every error has a precise responseType you can confirm Six different failures all surface as 403
Access-log $context pinpoints the exact layer Two logging systems, one off by default (account role)
integrationStatus vs status cleanly isolates the 502 Malformed proxy shape 502s look like a backend problem
Gateway Responses customize bodies + CORS on errors Missing CORS on errors masks the real 401/429 as a “CORS bug”
Four throttle layers give precise rate control No ThrottleCount metric — 429s hide in 4XXError
Managed TLS, WAF, auth, caching at the edge The 29 s integration timeout is a hard ceiling on sync work
REST + HTTP APIs cover rich and cheap/fast needs REST and HTTP APIs behave differently on the same failure

The advantages dominate for public and partner-facing APIs that need auth, metering, request validation and a managed edge — exactly where you want a precise, confirmable error surface. The disadvantages dominate when teams treat API Gateway as a dumb passthrough: they never set the account CloudWatch role (so they’re blind), never customize Gateway Responses (so errors are terse and CORS-broken), and never learn the integrationStatus/status split (so every 502 is a witch-hunt). The skill is configuring the observability and the error rendering before the incident, so that when the page fires you can name the layer in seconds.

Hands-on lab

You will reproduce and fix three classic failures on a free-tier-friendly account in ap-south-1: a 502 (a Lambda proxy that returns a malformed shape), a 504 (a backend that sleeps past the 29 s integration timeout), and a 429 (a deliberately tiny usage-plan quota). You will confirm each from access logs and metrics, then fix it. Everything here is within the API Gateway, Lambda and CloudWatch free tiers (a few pennies of logs at most); teardown is at the end. Run in CloudShell (Bash) where aws is pre-authenticated.

Step 0 — Variables, the account CloudWatch role, and an execution role.

export AWS_DEFAULT_REGION=ap-south-1
ACCT=$(aws sts get-caller-identity --query Account --output text)

# The account-level role so access/execution logs actually get written (once per region)
aws iam create-role --role-name apigw-cw-lab \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"apigateway.amazonaws.com"},"Action":"sts:AssumeRole"}]}' >/dev/null
aws iam attach-role-policy --role-name apigw-cw-lab \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
aws apigateway update-account \
  --patch-operations op=replace,path=/cloudwatchRoleArn,value=arn:aws:iam::$ACCT:role/apigw-cw-lab >/dev/null

# A Lambda execution role
ROLE_ARN=$(aws iam create-role --role-name apigw-lab-fn \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
  --query Role.Arn --output text)
aws iam attach-role-policy --role-name apigw-lab-fn \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
sleep 10

Step 1 — Deploy a Lambda that can misbehave on demand. One handler that returns a good proxy shape normally, a bad (bare object) shape when asked, and sleeps when asked — so we can trigger all three failures.

mkdir -p fn && cat > fn/app.py <<'PY'
import json, time
def handler(event, context):
    qs = (event.get("queryStringParameters") or {})
    if qs.get("mode") == "bad":
        return {"ok": True}                       # BARE object -> malformed proxy shape -> 502
    if qs.get("mode") == "slow":
        time.sleep(35)                            # > 29 s -> API Gateway 504
        return {"statusCode": 200, "body": "late"}
    return {"statusCode": 200, "body": json.dumps({"ok": True})}   # correct envelope
PY
( cd fn && zip -q ../fn.zip app.py )
FN_ARN=$(aws lambda create-function --function-name apigw-lab \
  --runtime python3.12 --handler app.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --timeout 40 --query FunctionArn --output text)

Step 2 — Build a REST API with a Lambda proxy integration and access logging.

API=$(aws apigateway create-rest-api --name apigw-lab --query id --output text)
ROOT=$(aws apigateway get-resources --rest-api-id $API --query 'items[0].id' --output text)
RES=$(aws apigateway create-resource --rest-api-id $API --parent-id $ROOT --path-part quote --query id --output text)
aws apigateway put-method --rest-api-id $API --resource-id $RES --http-method GET --authorization-type NONE
aws apigateway put-integration --rest-api-id $API --resource-id $RES --http-method GET \
  --type AWS_PROXY --integration-http-method POST \
  --uri arn:aws:apigateway:$AWS_DEFAULT_REGION:lambda:path/2015-03-31/functions/$FN_ARN/invocations
aws lambda add-permission --function-name apigw-lab --statement-id apigw \
  --action lambda:InvokeFunction --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:$AWS_DEFAULT_REGION:$ACCT:$API/*/GET/quote"

# Access-log group + deploy the prod stage WITH access logging
LG=$(aws logs create-log-group --log-group-name /apigw/access/apigw-lab 2>/dev/null; \
     aws logs describe-log-groups --log-group-name-prefix /apigw/access/apigw-lab \
       --query 'logGroups[0].arn' --output text)
aws apigateway create-deployment --rest-api-id $API --stage-name prod >/dev/null
aws apigateway update-stage --rest-api-id $API --stage-name prod --patch-operations \
  op=replace,path=/accessLogSettings/destinationArn,value=${LG%:*} \
  op=replace,path=/accessLogSettings/format,value='{"status":"$context.status","responseType":"$context.error.responseType","integrationStatus":"$context.integrationStatus","integrationLat":"$context.integrationLatency","message":"$context.error.message"}'
URL="https://$API.execute-api.$AWS_DEFAULT_REGION.amazonaws.com/prod/quote"
curl -s "$URL"     # -> {"ok": true}  (the correct envelope works)

Step 3 — Reproduce the 502 (malformed proxy shape).

curl -s -o /dev/null -w "%{http_code}\n" "$URL?mode=bad"    # -> 502
sleep 8
aws logs filter-log-events --log-group-name /apigw/access/apigw-lab \
  --filter-pattern '{ $.status = "502" }' --query 'events[-1].message' --output text

Expected: the client gets 502, and the access-log line shows "status":"502" while "integrationStatus":"200"the fingerprint: Lambda returned 200, API Gateway rejected the shape. Confirm the reason string in the execution log:

aws logs filter-log-events \
  --log-group-name API-Gateway-Execution-Logs_$API/prod \
  --filter-pattern "Malformed" --query 'events[-1].message' --output text
# -> "...Execution failed due to configuration error: Malformed Lambda proxy response"

Step 4 — Fix the 502. Restore the proxy envelope (already the default path — mode=bad is the bug). Prove the fix:

curl -s -o /dev/null -w "%{http_code}\n" "$URL"             # -> 200

The real-world fix is exactly this: ensure every return path is {"statusCode": <int>, "body": "<string>"}body a JSON string, not an object.

Step 5 — Reproduce the 504 (29-second integration timeout).

time curl -s -o /dev/null -w "%{http_code}\n" "$URL?mode=slow"   # -> 504 at ~29s
sleep 8
aws logs filter-log-events --log-group-name /apigw/access/apigw-lab \
  --filter-pattern '{ $.status = "504" }' --query 'events[-1].message' --output text

Expected: 504 returned at ~29 s (not 35 s — API Gateway gave up first), and the access log shows "responseType":"INTEGRATION_TIMEOUT" with "integrationLat" near 29000. Note the Lambda kept running to 35 s — check its own log group /aws/lambda/apigw-lab; the invocation completed after the client already got a 504.

Step 6 — Fix the 504. The correct fix is to make the call fast or go asynchronous (return 202 and poll). For the lab, demonstrate the diagnosis and confirm the fast path is fine:

curl -s -o /dev/null -w "%{http_code}\n" "$URL"             # -> 200 (well under 29s)

Step 7 — Reproduce a 429 (tiny usage-plan quota). Require an API key on the method, create a plan with a quota of 2/day, and exceed it.

aws apigateway update-method --rest-api-id $API --resource-id $RES --http-method GET \
  --patch-operations op=replace,path=/apiKeyRequired,value=true
aws apigateway create-deployment --rest-api-id $API --stage-name prod >/dev/null
KEY=$(aws apigateway create-api-key --name lab-key --enabled --query id --output text)
KEYVAL=$(aws apigateway get-api-key --api-key $KEY --include-value --query value --output text)
PLAN=$(aws apigateway create-usage-plan --name lab-plan \
  --throttle rateLimit=10,burstLimit=5 --quota limit=2,period=DAY \
  --api-stages apiId=$API,stage=prod --query id --output text)
aws apigateway create-usage-plan-key --usage-plan-id $PLAN --key-id $KEY --key-type API_KEY >/dev/null
sleep 5
for i in 1 2 3 4; do
  curl -s -o /dev/null -w "req $i -> %{http_code}\n" -H "x-api-key: $KEYVAL" "$URL"
done

Expected: requests 1–2 return 200, requests 3–4 return 429. Confirm the sub-cause from the access log — it is QUOTA_EXCEEDED, not THROTTLED:

aws logs filter-log-events --log-group-name /apigw/access/apigw-lab \
  --filter-pattern '{ $.status = "429" }' --query 'events[*].message' --output text
# -> "responseType":"QUOTA_EXCEEDED"

Note that 4XXError in CloudWatch ticked up but there is no throttle metric — the access log is the only place the 429 is visible with its cause.

Step 8 — Fix the 429. Raise the quota (or issue the partner a higher tier), and add CORS + Retry-After so browser clients back off instead of seeing a CORS error:

aws apigateway update-usage-plan --usage-plan-id $PLAN \
  --patch-operations op=replace,path=/quota/limit,value=100000
aws apigateway put-gateway-response --rest-api-id $API --response-type THROTTLED --status-code 429 \
  --response-parameters '{"gatewayresponse.header.Access-Control-Allow-Origin":"'"'"'*'"'"'","gatewayresponse.header.Retry-After":"'"'"'1'"'"'"}'
aws apigateway create-deployment --rest-api-id $API --stage-name prod >/dev/null

Validation checklist. You reproduced three distinct classes and confirmed each from its own fingerprint:

Step Failure reproduced The fingerprint The fix you applied
3–4 502 malformed proxy shape status=502 while integrationStatus=200; log Malformed Lambda proxy response Return {statusCode, body:"<string>"}
5–6 504 integration timeout responseType=INTEGRATION_TIMEOUT, integrationLat≈29000, 504 at ~29 s Speed up / async 202 pattern
7–8 429 usage-plan quota status=429, responseType=QUOTA_EXCEEDED, no throttle metric Raise quota; add Retry-After + CORS

Teardown (⚠️ do this — the API, Lambda and log groups are tiny but real).

aws apigateway delete-usage-plan-key --usage-plan-id $PLAN --key-id $KEY 2>/dev/null || true
aws apigateway delete-usage-plan --usage-plan-id $PLAN
aws apigateway delete-api-key --api-key $KEY
aws apigateway delete-rest-api --rest-api-id $API
aws lambda delete-function --function-name apigw-lab
aws logs delete-log-group --log-group-name /apigw/access/apigw-lab 2>/dev/null || true
aws logs delete-log-group --log-group-name API-Gateway-Execution-Logs_$API/prod 2>/dev/null || true
aws iam detach-role-policy --role-name apigw-lab-fn --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name apigw-lab-fn
aws iam detach-role-policy --role-name apigw-cw-lab --policy-arn arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
aws iam delete-role --role-name apigw-cw-lab

Cost note. Everything here is within the API Gateway (1M free calls for 12 months), Lambda (1M requests) and CloudWatch free tiers. The only spend is a fraction of a rupee for log ingestion; deleting the log groups above stops it. There is no NAT gateway or provisioned resource left running.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First the scannable master table you can read at 03:20, then the three nastiest failures in full. Every row is a real failure with the exact status, the responseType, the layer it belongs to, the precise field or metric to confirm it, and the fix.

# Status + responseType Layer Root cause Confirm (access-log field / metric) Fix
1 403 MISSING_AUTHENTICATION_TOKEN Routing Path/method has no route, or stage not deployed No matching get-resources; no recent deployment Add resource+method; create-deployment to the stage
2 403 MISSING_AUTHENTICATION_TOKEN Custom domain Base-path mapping missing/wrong Works on execute-api URL, 403 on domain Create/repair the base-path mapping
3 403 ACCESS_DENIED Auth (IAM) Identity/resource policy denies execute-api:Invoke Body names the ARN; $context.identity.userArn Grant execute-api:Invoke on the method ARN; fix resource policy
4 403 INVALID_SIGNATURE Auth (IAM) SigV4 wrong (clock skew, wrong region/service/secret) $context.error.responseType; check clock/creds Re-sign; sync clock; correct signer region/service
5 403 Forbidden (INVALID_API_KEY) Keys Key missing/invalid, or not in a plan on this stage responseType=INVALID_API_KEY; plan↔stage link Send x-api-key; attach key to a usage plan on the stage
6 403 WAF_FILTERED Security A WAF Web ACL rule blocked it $context.wafResponseCode; WAF sampled requests Tune/allowlist the rule; fix the false positive
7 403 ACCESS_DENIED (private API) Private API Resource/VPC-endpoint policy denies the SourceVpce 403 only from inside the VPC Allow aws:SourceVpce/aws:SourceVpc in the resource policy
8 401 UNAUTHORIZED Auth Missing/expired/invalid token; TOKEN identity source failed $context.authorizer.status=401; body Unauthorized Fix the token / identity-source regex / Cognito app client
9 403 ACCESS_DENIED (authorizer) Auth Lambda authorizer returned a Deny policy Body: not authorized to access this resource Fix the authorizer’s policy logic
10 500 AUTHORIZER_FAILURE Auth Authorizer Lambda threw or timed out $context.authorizer.error populated Fix authorizer code/perms/timeout; add error handling
11 429 THROTTLED Throttle Token bucket empty (account/stage/method/plan) Access log status=429 responseType=THROTTLED Raise the tightest applicable rate + burst
12 429 QUOTA_EXCEEDED Usage plan Daily/weekly/monthly quota spent responseType=QUOTA_EXCEEDED; $context.identity.apiKeyId Raise the quota; issue a higher tier / new key
13 502 (integrationStatus=200) Integration Malformed Lambda proxy response shape status=502 + integrationStatus=200; log Malformed Lambda proxy response Return {statusCode, headers, body:"<string>"}
14 502 (integrationStatus=-) Integration Backend closed/garbled the connection; oversized headers $context.integration.error; backend logs Fix backend response; keep-alive/header size
15 503 Integration Backend down; VPC-link NLB has no healthy target integrationStatus=503/-; VPC-link target health Restore backend; fix VPC-link/NLB health checks
16 504 INTEGRATION_TIMEOUT Integration Backend slower than the 29 s ceiling integrationLatency≈29000; log timeout error Speed up backend; async 202; raise the 29 s quota (REST)
17 500 API_CONFIGURATION_ERROR Config Broken VTL mapping template / bad stage variable Execution log names the template; $context.error.message Fix the mapping template / stage variable
18 400 BAD_REQUEST_BODY Validation Body fails the JSON-schema model $context.error.message; request validator on Fix the payload or the model
19 413 REQUEST_TOO_LARGE Payload Request body over the 10 MB limit responseType=REQUEST_TOO_LARGE Upload via S3 presigned URL; chunk the payload
20 Browser “CORS blocked” on any error Gateway response CORS headers absent on Gateway Responses Real status is 401/403/429 with no Allow-Origin Add CORS to DEFAULT_4XX/DEFAULT_5XX
21 Logging enabled but empty Observability Account CloudWatch role never set apigateway get-account has no cloudwatchRoleArn Set the account-level role once per region

The three that cause the most damage, expanded:

A. The 502 that is really a 200 — the malformed Lambda proxy response. A Lambda proxy endpoint starts returning 502 on its happy path, usually right after a code change, and the team debugs the wrong service because the Lambda Errors metric is flat and Duration is healthy. The cause is that the handler returned the wrong shape: a bare object (return quote) instead of the required proxy envelope {"statusCode": 200, "headers": {...}, "body": "<JSON string>"}. Lambda executed perfectly and returned 200 to API Gateway, which then rejected the shape it got and synthesized a 502 to the client. Confirm: the access log shows $context.status = 502 while $context.integrationStatus = 200 — that split is the unambiguous fingerprint — and the execution log reads Execution failed due to configuration error: Malformed Lambda proxy response. Fix: make every return path (including error and early-return paths) emit the full envelope with body as a JSON string, not an object; a shared response helper (respond(status, obj)) prevents the class entirely. This is also why non-proxy (custom) integrations exist — they map the response for you via a template — but proxy is the common default, so the contract is yours to honour.

B. The 504 at exactly 29 seconds — the integration timeout you can’t raise your way out of. A synchronous endpoint that calls a slow backend (a third-party API, a heavy query) starts returning 504 under load. The instinctive fix — raise the Lambda/backend timeout — does nothing, because the ceiling is API Gateway’s 29-second integration timeout, not the backend’s. The tell is $context.integrationLatency pinned near 29000 ms, the execution log line Execution failed due to a timeout error, and — the proof it’s the gateway giving up, not the backend — the backend’s own log shows the work continuing past the 504 (Lambda keeps running and billing after the client is gone). Confirm: IntegrationLatency p99 at ~29 s in CloudWatch, responseType=INTEGRATION_TIMEOUT in the access log. Fix: the durable answer is almost never “wait longer.” Make the call fast (cache, index, parallelize), or switch to an asynchronous pattern — return 202 Accepted with a job ID immediately, do the slow work in a worker (SQS + Lambda, Step Functions), and have the client poll a status endpoint or receive a webhook. Only if the work is genuinely, irreducibly long and synchronous should you request the REST-API quota increase above 29 s — and HTTP APIs cannot do even that (hard 30 s cap).

C. The 403 that’s really a missing deployment — Missing Authentication Token. The dashboard lights up with 4XXError and clients report a wall of 403 {"message":"Missing Authentication Token"} on an endpoint that “worked yesterday” — and the instinct is to debug authorizers and API keys, which is exactly backwards. This 403 is what API Gateway returns when it cannot match the request’s path+method to a route, phrased (for historical, don’t-confirm-what-exists reasons) as an auth error. In a running system the overwhelmingly common trigger is a change that was never deployed: someone edited a method, an integration, or a mapping template — in the console, via CLI, or in Terraform — and never created a deployment to the stage the URL points at, so the mutation lives on the API but not on prod. Confirm (fast): first, is there an authorizer attached at all? If not, it is routing/deploy 100% of the time — stop looking at auth. Then aws apigateway get-resources to prove the path+method actually exists, and check for a recent create-deployment on the stage; if the raw execute-api URL works but a custom domain 403s, it is a base-path mapping, not auth. Fix: aws apigateway create-deployment --stage-name prod (or, in IaC, an aws_api_gateway_deployment with a redeploy trigger), correct the client path/verb, or repair the base-path mapping. The senior habit that eliminates the whole class: treat “deploy the stage” as the last step of every API change, and wire a redeploy trigger into your IaC so a method change can never silently fail to ship. (The parallel invisible-failure trap to keep in your peripheral vision: a 429 has no metric — it hides inside 4XXError — so when 4xx climbs but nothing in the console explains it, filter the access log for status=429 before assuming it’s a 403.)

Best practices

Security notes

Cost & sizing

API Gateway bills primarily on requests (REST is pricier than HTTP APIs), plus data transfer, optional caching (REST, per GB-hour of cache), and the CloudWatch Logs you generate. The error classes above have direct cost consequences: a 504 still bills the backend for its full run (Lambda keeps executing past the 29 s cut-off), verbose dataTraceEnabled execution logs can dwarf request costs, and a retry storm from unhandled 429s multiplies request charges.

Cost driver How it’s billed Right-size by Rough figure
REST API requests Per million calls Move suitable APIs to HTTP APIs ~$3.50 per million (varies by region)
HTTP API requests Per million calls Default for simple proxy/JWT APIs ~$1.00–1.17 per million (~70% cheaper)
Caching (REST) Per GB-hour of cache size Cache only cacheable GETs; right TTL Sized 0.5–237 GB; pay while enabled
CloudWatch Logs Per GB ingested + stored Access log only; avoid dataTrace in prod; set retention Verbose execution logs add up fast
Data transfer out Per GB Compress; cache; keep payloads small Standard AWS egress rates
Backend (Lambda/EC2) Its own billing Async pattern so 504s don’t bill full runs A 35 s Lambda bills 35 s even after a 29 s 504

Sizing rules of thumb: prefer HTTP APIs unless you need REST-only features (usage plans, mapping templates, request validation, Gateway Responses, WAF) — the per-million saving is large at scale. Enable REST caching only for genuinely cacheable read endpoints and watch the hit rate (CacheHitCount/CacheMissCount); a cache with a bad key or TTL costs money and returns stale data. Set log retention on both the access and execution log groups (they default to never-expire) and keep dataTraceEnabled off in production — unbounded verbose logs are the sneaky line item, not the requests. In INR terms, a modest partner-facing REST API (a few million calls/month, small payloads, access logging on) typically runs in the low hundreds to low thousands of rupees per month before free tier; the expensive mistakes are always-on caching, dataTrace logging left on, and backends that keep billing behind 504s.

Interview & exam questions

Q1. A client gets 403 {"message":"Missing Authentication Token"} on a brand-new endpoint. Walk the diagnosis. Despite the wording, this is almost never an auth problem — it means API Gateway couldn’t match the path+method to a route, or the stage was never deployed. Confirm with get-resources (does the route exist?) and check for a recent deployment. If no authorizer is attached, it’s a routing/deploy miss 100% of the time; the fix is create-deployment or correcting the URL/method. (DVA-C02, SOA-C02)

Q2. Distinguish a 401, a 403 authorizer Deny, and a 500 from the same Lambda authorizer. 401 UNAUTHORIZED = the authorizer returned “Unauthorized” or the token failed the identity source (unauthenticated). 403 ACCESS_DENIED = the authorizer returned an IAM policy with Effect: Deny (authenticated but not allowed). 500 AUTHORIZER_FAILURE = the authorizer Lambda threw or timed out. Confirm via $context.authorizer.status/$context.authorizer.error. (DVA-C02)

Q3. Your Lambda proxy endpoint returns 502 but the Lambda Errors metric is flat. What happened? The handler returned a malformed proxy shape — a bare object instead of {statusCode, body:"<string>"}. Lambda succeeded and returned 200 to API Gateway, which rejected the shape and synthesized a 502. The fingerprint is $context.integrationStatus=200 with $context.status=502, plus Malformed Lambda proxy response in the execution log. Fix the envelope. (DVA-C02, SAA-C03)

Q4. An endpoint 504s at ~29 seconds even after you raised the Lambda timeout to 60 s. Why? The 29-second limit is API Gateway’s integration timeout, independent of the Lambda timeout; API Gateway gives up at 29 s while Lambda keeps running (and billing). responseType=INTEGRATION_TIMEOUT, integrationLatency≈29000. Fix by making the call fast or switching to an async 202-and-poll pattern; REST APIs can request a quota increase above 29 s, HTTP APIs cannot. (DVA-C02, SOA-C02)

Q5. How do you see how many requests are being throttled? You can’t from a metric — API Gateway has no ThrottleCount, and 429s are counted inside 4XXError. You confirm throttling from the access log: filter $context.status=429 and split on $context.error.responseType (THROTTLED = rate/burst bucket; QUOTA_EXCEEDED = usage-plan quota). Build a CloudWatch Logs metric filter to graph and alarm on it. (SOA-C02)

Q6. Explain the token-bucket throttle and its four layers. Burst is the bucket capacity, rate is the refill/second, each request removes a token, an empty bucket returns 429 THROTTLED. Limits apply at four layers — account (10,000 rps/5,000 burst default, per region), stage default, per-method, and usage-plan (per key) — and the tightest applicable limit wins. Usage plans add a separate quota counter (QUOTA_EXCEEDED). (DVA-C02, SAA-C03)

Q7. A valid API key still returns 403 Forbidden. Cause? The key exists and is enabled but isn’t associated with a usage plan bound to the stage being called (or apiKeyRequired is set and the key header is absent/misnamed). responseType=INVALID_API_KEY. Fix by attaching the key to a usage plan on that stage and sending it as x-api-key. (DVA-C02)

Q8. Users report the site is broken with “CORS errors,” but the API is up. What’s likely happening? An early-pipeline error (401/403/429) is being returned as a Gateway Response, which does not inherit the CORS headers you set on method/integration responses — so the browser sees a missing Access-Control-Allow-Origin and reports a CORS failure that masks the real status. Fix by adding CORS headers to DEFAULT_4XX/DEFAULT_5XX Gateway Responses. (DVA-C02, SOA-C02)

Q9. Latency is high but IntegrationLatency is low. Where’s the problem? The delay is in API Gateway’s own overhead, not the backend — typically a slow Lambda authorizer or heavy VTL mapping templates. Cache the authorizer result (TTL) and simplify the templates. If instead both are high and close, the backend is the bottleneck (and a 504 is coming at 29 s). (SOA-C02)

Q10. How do REST and HTTP APIs differ on an unmatched route and on the integration timeout? An unmatched route returns 403 Missing Authentication Token on REST APIs but 404 Not Found on HTTP APIs. The integration timeout is 29 s (raisable via quota) on REST and a hard 30 s (not raisable) on HTTP APIs. HTTP APIs also lack Gateway Responses, usage plans and native WAF. (DVA-C02, SAA-C03)

Q11. Logging is enabled on the stage but no logs appear. Why? The account-level CloudWatch Logs role (cloudwatchRoleArn) hasn’t been set for the region, so API Gateway can’t write to CloudWatch — silently. Set it once per region with update-account; then both execution and access logging work. (SOA-C02)

Q12. A 5xx shows integrationStatus=503 (a real code, not -). Where do you debug? The backend returned the 503 and API Gateway forwarded it — this is not an API Gateway problem. Debug the integration (the app, the VPC-link NLB target health), not the gateway. Chasing API Gateway settings here wastes the incident. (DVA-C02, SAA-C03)

Quick check

  1. A new endpoint returns 403 Missing Authentication Token with no authorizer attached. What are the two most likely causes, and the confirming step for each?
  2. Your Lambda proxy endpoint 502s but Lambda Errors is flat. What single pair of $context fields confirms the cause, and what’s the fix?
  3. Why can’t you find a throttle metric in CloudWatch, and where do you confirm a 429 instead?
  4. An endpoint 504s at ~29 s and raising the Lambda timeout doesn’t help. What’s the ceiling, and what’s the durable fix?
  5. The frontend reports “CORS errors” but the API is healthy. What’s really happening, and what do you change?

Answers

  1. A wrong path/method, or a stage that was never deployed. Confirm the route with aws apigateway get-resources; confirm the deploy by checking for a recent create-deployment on that stage. With no authorizer in play, it’s always routing/deploy, never auth.
  2. $context.integrationStatus = 200 while $context.status = 502 — Lambda returned 200 but with a malformed proxy shape (a bare object). Fix: return the full envelope {statusCode, headers, body:"<JSON string>"} on every path.
  3. API Gateway emits no ThrottleCount metric; 429s are folded into 4XXError. Confirm from the access log: filter status=429 and split on responseType (THROTTLED vs QUOTA_EXCEEDED); build a metric filter to graph it.
  4. API Gateway’s 29-second integration timeout (independent of the Lambda timeout; Lambda keeps running and billing). Fix by making the call fast or moving to an async 202-and-poll pattern; only request the REST quota increase above 29 s if the work is irreducibly long and synchronous.
  5. An early-pipeline error (401/403/429) is returned as a Gateway Response with no CORS headers, so the browser reports a CORS failure that hides the real status. Add Access-Control-Allow-Origin (and friends) to the DEFAULT_4XX/DEFAULT_5XX Gateway Responses.

Glossary

Term Definition
responseType The Gateway Response category (e.g. MISSING_AUTHENTICATION_TOKEN, THROTTLED, INTEGRATION_TIMEOUT) that names the exact sub-cause of a 4xx/5xx; read via $context.error.responseType.
Gateway Response The final pipeline stage that renders an error’s status, body and headers; customizable per responseType on REST APIs.
Missing Authentication Token The 403 body API Gateway returns when it can’t match the path+method to a route — usually a wrong path/method or an undeployed stage, not an auth problem.
Lambda proxy integration An integration where API Gateway passes the whole request to Lambda and expects a {statusCode, headers, body} envelope back; a wrong shape yields a 502.
Malformed proxy response A Lambda proxy return that isn’t the required envelope (e.g. a bare object); Lambda returns 200 but API Gateway synthesizes a 502.
Integration timeout The maximum time API Gateway waits for the backend — 29 s (REST, raisable) / 30 s (HTTP, fixed); exceeding it yields 504 INTEGRATION_TIMEOUT.
integrationStatus The status the integration returned to API Gateway ($context.integrationStatus); 200 here with status=502 is the malformed-shape fingerprint.
Token bucket The throttle algorithm: burst = bucket capacity, rate = refill/second; an empty bucket returns 429 THROTTLED.
Usage plan A REST-API construct binding API keys to rate, burst and quota limits per stage; quota exhaustion yields 429 QUOTA_EXCEEDED.
Account throttle The per-region default limit across all APIs — 10,000 rps steady, 5,000 burst (soft, raisable).
Execution logging Verbose per-request internal trace written to an API-Gateway-named log group; best for single-request forensics like the 502 reason string.
Access logging One structured line per request built from $context variables into a log group you choose; your primary dashboard/metric-filter source.
$context The variable namespace available in access-log formats and mapping templates ($context.status, $context.error.responseType, $context.integrationLatency, etc.).
Base-path mapping The mapping on a custom domain from a base path to an API+stage; a missing/wrong one 403s while the raw execute-api URL works.
REST API vs HTTP API v1 (feature-rich: Gateway Responses, usage plans, VTL, WAF; 29 s) vs v2 (cheaper/faster, fewer features; 404 on unmatched route; 30 s fixed).

Next steps

AWSAPI GatewayServerlessTroubleshooting4034295xxCloudWatch
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading