Skip to content

fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses#9114

Open
Hardikrepo wants to merge 2 commits into
aws:developfrom
Hardikrepo:fix/gatewayresponses-local-apigw
Open

fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses#9114
Hardikrepo wants to merge 2 commits into
aws:developfrom
Hardikrepo:fix/gatewayresponses-local-apigw

Conversation

@Hardikrepo

@Hardikrepo Hardikrepo commented Jul 6, 2026

Copy link
Copy Markdown

fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses

sam local start-api never read the GatewayResponses property on AWS::Serverless::Api, so responses generated by the local emulator itself (Lambda Authorizer denials, missing identity sources, unhandled Lambda exceptions) never carried any of the headers a customer configured there. Real API Gateway applies GatewayResponses (including CORS headers) to exactly these cases, so local behavior diverged from deployed behavior with no way to work around it.

Root cause: SamApiProvider extracted Cors/Auth/StageName from the Api resource but silently dropped GatewayResponses, and none of the ServiceErrorResponses factory methods accepted extra headers, so there was no path for configured headers to reach the Flask response object.

Fix:

  • SamApiProvider now extracts GatewayResponses' ResponseParameters.Headers per response type (matching the same gatewayresponse.header.X / quoted-string schema samtranslator uses), stored on the Api model.
  • Api.get_gateway_response_headers(response_type, status_code) resolves headers for a specific response type, falling back to DEFAULT_4XX/DEFAULT_5XX the same way API Gateway itself does when a specific type isn't configured.
  • ServiceErrorResponses methods accept an optional headers dict and merge it onto the Flask response.
  • LocalApigwService wires the ACCESS_DENIED, UNAUTHORIZED, and DEFAULT_5XX response types into the corresponding authorizer/lambda-failure error paths in _request_handler.

Only ResponseParameters.Headers is wired up locally for now (not ResponseTemplates body overrides or StatusCode overrides, and not applied to the 404/405 MISSING_AUTHENTICATION_TOKEN path) — scoped to the headers/CORS problem reported in the issue. Happy to extend if maintainers want broader coverage in this PR.

Verified with real Flask test-client requests (authorizer deny -> 403 with configured CORS header; unhandled Lambda exception -> 500 with configured header), plus unit tests for extraction, fallback resolution, and header propagation.

Which issue(s) does this change fix?

#9064

Why is this change necessary?

GatewayResponses is a documented SAM template property specifically for customizing API Gateway error responses (e.g. adding CORS headers to authorizer/4xx/5xx errors), but sam local start-api silently ignored it. This meant local testing could not reproduce a common, documented deployed-API behavior, and there was no local workaround for CORS-blocked error responses.

How does it address the issue?

It parses GatewayResponses from the AWS::Serverless::Api resource the same way Cors/Auth are already parsed, resolves the correct response type (with DEFAULT_4XX/DEFAULT_5XX fallback, matching real API Gateway semantics) for each local error path, and merges the configured headers onto the Flask response returned by the local emulator.

What side effects does this change have?

None for existing users: if GatewayResponses isn't set on the template, Api.gateway_responses extraction returns an empty mapping and get_gateway_response_headers returns {}, so error responses are unchanged. The change only adds headers when GatewayResponses is explicitly configured.

Mandatory Checklist

PRs will only be reviewed after checklist is complete

  • Review the generative AI contribution guidelines
  • Add input/output type hints to new functions/methods
  • Write design document if needed (Do I need to write a design document?) — not needed, this is a scoped bugfix to existing, documented behavior
  • Write/update unit tests
  • Write/update integration tests — not added; existing local-apigw integration tests didn't cover GatewayResponses and I didn't have a Docker environment available to validate new ones end-to-end. Happy to add if maintainers want.
  • Write/update functional tests if needed
  • make pr passes — ran black, ruff (lint), and the full tests/unit/local + tests/unit/commands/local suites manually (no regressions; some pre-existing unrelated Windows-path failures exist on develop before this change too); did not run the full integration suite (test-all) due to no local Docker/AWS environment
  • make update-reproducible-reqs if dependencies were changed — no dependency changes
  • Write documentation — no new user-facing property/flag; GatewayResponses is already documented at the SAM template level

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

…mbda-exception error responses

sam local start-api never read the GatewayResponses property on
AWS::Serverless::Api, so responses generated by the local emulator itself
(Lambda Authorizer denials, missing identity sources, unhandled Lambda
exceptions) never carried any of the headers a customer configured there.
Real API Gateway applies GatewayResponses (including CORS headers) to
exactly these cases, so local behavior diverged from deployed behavior
with no way to work around it.

Root cause: SamApiProvider extracted Cors/Auth/StageName from the Api
resource but silently dropped GatewayResponses, and none of the
ServiceErrorResponses factory methods accepted extra headers, so there
was no path for configured headers to reach the Flask response object.

Fix:
- SamApiProvider now extracts GatewayResponses' ResponseParameters.Headers
  per response type (matching the same "gatewayresponse.header.X"/quoted
  string schema samtranslator uses), stored on the Api model.
- Api.get_gateway_response_headers(response_type, status_code) resolves
  headers for a specific response type, falling back to DEFAULT_4XX/
  DEFAULT_5XX the same way API Gateway itself does when a specific type
  isn't configured.
- ServiceErrorResponses methods accept an optional headers dict and merge
  it onto the Flask response.
- LocalApigwService wires the ACCESS_DENIED, UNAUTHORIZED, and DEFAULT_5XX
  response types into the corresponding authorizer/lambda-failure error
  paths in _request_handler.

Fixes aws#9064.

Verified with real Flask test-client requests (authorizer deny -> 403
with configured CORS header; unhandled Lambda exception -> 500 with
configured header), plus unit tests for extraction, fallback resolution,
and header propagation.
@Hardikrepo Hardikrepo requested a review from a team as a code owner July 6, 2026 07:20
@github-actions github-actions Bot added area/local/start-api sam local start-api command area/local/invoke sam local invoke command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Jul 6, 2026
@Hardikrepo Hardikrepo changed the title fix(local-apigw): apply GatewayResponses headers to authorizer and La… fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses Jul 6, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 8f25f26..54f5006
Files: 9
Comments: 2


Comments on lines outside the diff:

[samcli/local/apigw/local_apigw_service.py:747] [BUG] The PR wraps some 5xx endpoint error paths with GatewayResponses headers but leaves sibling paths in the same try/except block unwrapped, producing inconsistent behavior for the same feature:

except FunctionNotFound:
   endpoint_service_error = ServiceErrorResponses.lambda_not_found_response()   # 502, NOT wrapped
except UnsupportedInlineCodeError:
   endpoint_service_error = ServiceErrorResponses.not_implemented_locally(...)  # 501, NOT wrapped
except LambdaResponseParseException:
   endpoint_service_error = ServiceErrorResponses.lambda_body_failure_response(
       headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 500)        # wrapped
   )
except DockerContainerCreationFailedException as ex:
   endpoint_service_error = ServiceErrorResponses.container_creation_failed(ex.message)  # 501, NOT wrapped
except MissingFunctionNameException as ex:
   endpoint_service_error = ServiceErrorResponses.lambda_failure_response(
       f"Failed to execute endpoint. Got an invalid function name ({str(ex)})",
       headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502),       # wrapped
   )

lambda_not_found_response() returns HTTP 502 (same as the wrapped MissingFunctionNameException path), and container_creation_failed() returns HTTP 501. From a user's perspective these are indistinguishable server-side failures, so a CORS header configured on DEFAULT_5XX will appear on some 502s but not on others — exactly the divergence-from-cloud symptom this PR is meant to fix (issue #9064). Real API Gateway applies DEFAULT_5XX to any 5xx that isn't otherwise mapped. Consider wrapping these two paths (and reusing ServiceErrorResponses._add_headers/adding a headers= kwarg to lambda_not_found_response and container_creation_failed as needed) so all 5xx endpoint errors are handled uniformly.

for response_type, response in gateway_responses.items():
if not isinstance(response, dict):
LOG.warning(
"Ignoring GatewayResponse '%s' on resource '%s', must be an object", response_type, logical_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ERROR_HANDLING] ```python
response_headers = response.get("ResponseParameters", {}).get("Headers", {})


This chain crashes with AttributeError: 'NoneType' object has no attribute 'get' if the template contains ResponseParameters: with no value (which the YAML parser turns into None, not a missing key). dict.get(key, default) returns None when the key is present but the value is None — the default only applies when the key is missing. Example that will crash sam local start-api:

```yaml
GatewayResponses:
 UNAUTHORIZED:
   ResponseParameters:

The surrounding code is defensive (isinstance(response, dict), isinstance(response_headers, dict)) so the same defensiveness should apply here. Suggested fix:

response_parameters = response.get("ResponseParameters") or {}
if not isinstance(response_parameters, dict):
   continue
response_headers = response_parameters.get("Headers") or {}
if not isinstance(response_headers, dict):
   continue

- Fix crash: response.get("ResponseParameters", {}).get("Headers", {})
  raised AttributeError when a template set "ResponseParameters:" with
  no value, since YAML parses that as an explicit None (not a missing
  key), so the dict.get default was never used. Guard both
  ResponseParameters and Headers with "or {}" and re-validate they're
  dicts before use.
- Fix inconsistency: lambda_not_found_response (502),
  not_implemented_locally (501), container_creation_failed (501), and
  tenant_id_validation_error (400) were left out of the GatewayResponses
  wiring even though they're sibling error paths in the same
  _request_handler try/except block as the ones that were wired up.
  From a caller's perspective these are indistinguishable 4xx/5xx
  failures, so a customer's DEFAULT_4XX/DEFAULT_5XX GatewayResponse
  would apply to some of their local error responses but not others -
  the same kind of divergence-from-cloud-behavior this fix is meant to
  resolve. All endpoint-side error paths now resolve headers the same
  way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/local/invoke sam local invoke command area/local/start-api sam local start-api command area/local/start-invoke pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant