fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses#9114
Conversation
…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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses
sam local start-apinever read theGatewayResponsesproperty onAWS::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 appliesGatewayResponses(including CORS headers) to exactly these cases, so local behavior diverged from deployed behavior with no way to work around it.Root cause:
SamApiProviderextractedCors/Auth/StageNamefrom theApiresource but silently droppedGatewayResponses, and none of theServiceErrorResponsesfactory methods accepted extra headers, so there was no path for configured headers to reach the Flask response object.Fix:
SamApiProvidernow extractsGatewayResponses'ResponseParameters.Headersper response type (matching the samegatewayresponse.header.X/ quoted-string schemasamtranslatoruses), stored on theApimodel.Api.get_gateway_response_headers(response_type, status_code)resolves headers for a specific response type, falling back toDEFAULT_4XX/DEFAULT_5XXthe same way API Gateway itself does when a specific type isn't configured.ServiceErrorResponsesmethods accept an optionalheadersdict and merge it onto the Flask response.LocalApigwServicewires theACCESS_DENIED,UNAUTHORIZED, andDEFAULT_5XXresponse types into the corresponding authorizer/lambda-failure error paths in_request_handler.Only
ResponseParameters.Headersis wired up locally for now (notResponseTemplatesbody overrides orStatusCodeoverrides, and not applied to the 404/405MISSING_AUTHENTICATION_TOKENpath) — 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?
GatewayResponsesis a documented SAM template property specifically for customizing API Gateway error responses (e.g. adding CORS headers to authorizer/4xx/5xx errors), butsam local start-apisilently 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
GatewayResponsesfrom theAWS::Serverless::Apiresource the same wayCors/Authare already parsed, resolves the correct response type (withDEFAULT_4XX/DEFAULT_5XXfallback, 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
GatewayResponsesisn't set on the template,Api.gateway_responsesextraction returns an empty mapping andget_gateway_response_headersreturns{}, so error responses are unchanged. The change only adds headers whenGatewayResponsesis explicitly configured.Mandatory Checklist
PRs will only be reviewed after checklist is complete
make prpasses — ranblack,ruff(lint), and the fulltests/unit/local+tests/unit/commands/localsuites manually (no regressions; some pre-existing unrelated Windows-path failures exist ondevelopbefore this change too); did not run the full integration suite (test-all) due to no local Docker/AWS environmentmake update-reproducible-reqsif dependencies were changed — no dependency changesGatewayResponsesis already documented at the SAM template levelBy submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.