Skip to content

Lambda Handler

The app module in lambda/app.py is the Powertools-based request handler wired into API Gateway. It owns the /greeting route, Pydantic validation, and the cross-cutting concerns (idempotency, feature flags, metrics, tracing).

API reference

app

Serverless App Lambda handler — the handler layer.

This module initializes the AWS Powertools resolver and the AWS provider clients (SSM, AppConfig feature flags, the DynamoDB idempotency layer) with a shared retry config, validates the environment at cold start, routes the API Gateway event, and translates the service layer's domain errors into HTTP responses. The business logic lives in :mod:service and the data contracts in :mod:models, so this file stays focused on wiring and the HTTP boundary.

It demonstrates the Powertools utilities a production handler leans on: structured logging, X-Ray tracing, CloudWatch metrics, idempotency, SSM parameters, feature flags, Pydantic-backed request/response validation (with an OpenAPI spec generated at documentation-build time — see scripts/generate_openapi.py), and Event Source Data Classes.

get_greeting()

Handle GET /greeting requests.

Extracts request metadata from the typed API Gateway event, delegates the business logic to :func:service.build_greeting, and maps a service-layer :class:service.GreetingUnavailableError to an HTTP 500.

Returns:

Name Type Description
GreetingResponse GreetingResponse

Validated response model with a message field.

Source code in lambda/app.py
@app.get(
    "/greeting",
    summary="Return a greeting",
    description=(
        "Returns the greeting string configured in SSM Parameter Store. "
        "When the `enhanced_greeting` AppConfig feature flag is enabled for "
        "the caller's source IP, the response includes the feature flag's "
        "configured suffix. Requires an `Idempotency-Key` header; requests "
        "without one are rejected with 400 before this route runs."
    ),
    response_description="A JSON object containing the resolved greeting.",
    tags=["Greeting"],
    # Error responses documented explicitly — the generated OpenAPI spec
    # otherwise covers only the happy path, leaving the 400 (missing
    # Idempotency-Key, enforced outside the resolver) and the 500 (SSM
    # failure) invisible to spec consumers and breaking-change tooling.
    responses={
        200: {
            "description": "The resolved greeting.",
            "content": {"application/json": {"model": GreetingResponse}},
        },
        400: {
            "description": "Missing Idempotency-Key header.",
            "content": {"application/json": {"model": MissingIdempotencyKeyResponse}},
        },
        409: {
            "description": "A request with the same Idempotency-Key is still in progress.",
            "content": {"application/json": {"model": IdempotencyInProgressResponse}},
        },
        500: {
            "description": "Greeting could not be fetched from SSM Parameter Store.",
            "content": {"application/json": {"model": InternalErrorResponse}},
        },
    },
)
@tracer.capture_method(capture_response=False)
def get_greeting() -> GreetingResponse:
    """Handle GET /greeting requests.

    Extracts request metadata from the typed API Gateway event, delegates the
    business logic to :func:`service.build_greeting`, and maps a service-layer
    :class:`service.GreetingUnavailableError` to an HTTP 500.

    Returns:
        GreetingResponse: Validated response model with a ``message`` field.
    """
    # Access typed event data via Event Source Data Classes
    event: APIGatewayProxyEvent = app.current_event
    source_ip = event.request_context.identity.source_ip
    user_agent = event.request_context.identity.user_agent
    request_id = event.request_context.request_id

    # Tenant context as a first-class observability signal. Today there is no
    # auth, so this is always "anonymous" (see _resolve_tenant_id); the value
    # goes live the day a fork puts a tenant claim on the request. Tagging all
    # three signals here means logs, EMF records, and the X-Ray trace can be
    # sliced per tenant without a retrofit. Powertools propagates appended log
    # keys across Logger instances of the same service, and shares one metric
    # store, so the service layer's records and metrics inherit tenant_id too —
    # no need to thread it through build_greeting.
    # Metadata, NOT add_dimension: a dimension changes the EMF dimension set to
    # {service, tenant_id}, and CloudWatch matches custom metrics on the EXACT
    # dimension set — every consumer that addresses these metrics by {service}
    # alone (the GreetingRequests dashboard widget and the
    # FeatureFlagEvaluationFailure AppConfig rollback alarm in
    # infrastructure/backend_app.py) silently stops seeing data. Metadata rides
    # the same EMF blob, so per-tenant queries still work in Logs Insights
    # (`filter tenant_id = ...`), without forking the metric identity or paying
    # the per-distinct-value custom-metric billing a dimension incurs. A fork
    # that wants true per-tenant metric *streams* must add tenant_id to those
    # consumers' dimensions_map in the same commit — both sides of the
    # telemetry contract move together (pinned by the EMF dimension-set unit
    # test in tests/unit/test_handler.py).
    tenant_id = _resolve_tenant_id(event)
    logger.append_keys(tenant_id=tenant_id)
    tracer.put_annotation(key="tenant_id", value=tenant_id)
    metrics.add_metadata(key="tenant_id", value=tenant_id)

    logger.info(
        "Request received",
        source_ip=source_ip,
        user_agent=user_agent,
        request_id=request_id,
    )

    # Pass source_ip + user_agent as flag-evaluation context so AppConfig rules
    # can match on them (the route's description promises IP-based gating). A
    # GetParameterError on the SSM read surfaces from the service as a domain
    # GreetingUnavailableError; map it to the 500 the OpenAPI spec documents.
    try:
        message = build_greeting(
            ssm_provider=ssm_provider,
            feature_flags=feature_flags,
            param_name=GREETING_PARAM_NAME,
            flag_context={"source_ip": source_ip, "user_agent": user_agent},
        )
    except GreetingUnavailableError as exc:
        raise InternalServerError("Failed to fetch greeting") from exc

    return GreetingResponse(message=message)

lambda_handler(event, context)

Lambda entry point.

Resolves the API Gateway event through the router and returns the result. Decorated with Powertools Logger, Tracer, Metrics; the inner function handles Idempotency so the idempotency layer's caller-caused rejections map to meaningful HTTP responses — a missing Idempotency-Key header to a 400, a duplicate request whose original is still executing to a 409 — instead of unhandled 5xx errors. Infrastructure faults (e.g. the persistence layer's DynamoDB errors) deliberately propagate so they surface in the Errors metric and X-Ray rather than being flattened into a client-error shape.

Parameters:

Name Type Description Default
event dict[str, Any]

API Gateway Lambda proxy event.

required
context LambdaContext

Lambda runtime context.

required

Returns:

Name Type Description
dict dict[str, Any]

API Gateway Lambda proxy response.

Source code in lambda/app.py
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST, clear_state=True)
@tracer.capture_lambda_handler(capture_response=False)
@metrics.log_metrics(capture_cold_start_metric=True)
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:
    """Lambda entry point.

    Resolves the API Gateway event through the router and returns the result.
    Decorated with Powertools Logger, Tracer, Metrics; the inner function
    handles Idempotency so the idempotency layer's caller-caused rejections map
    to meaningful HTTP responses — a missing Idempotency-Key header to a 400, a
    duplicate request whose original is still executing to a 409 — instead of
    unhandled 5xx errors. Infrastructure faults (e.g. the persistence layer's
    DynamoDB errors) deliberately propagate so they surface in the Errors
    metric and X-Ray rather than being flattened into a client-error shape.

    Args:
        event: API Gateway Lambda proxy event.
        context: Lambda runtime context.

    Returns:
        dict: API Gateway Lambda proxy response.
    """
    # Header names are case-insensitive on the wire but the idempotency layer's
    # JMESPath is exact-match, so normalize keys to lowercase once here. The
    # resolver is unaffected — Powertools' event data classes already do
    # case-insensitive header lookups. A copy (not in-place mutation) keeps the
    # caller's event untouched. `or {}` covers manual invocations (console test
    # events, aws lambda invoke) that omit the headers map entirely.
    event = {**event, "headers": {k.lower(): v for k, v in (event.get("headers") or {}).items()}}

    # cast() restores the return type after @idempotent erases it. Powertools'
    # app.resolve() is well-typed in .venv-lambda, but the @idempotent wrapper
    # passes return values through as Any; .venv (CDK side, no Powertools)
    # already sees the function as Any. The cast is a no-op at runtime.
    try:
        return cast("dict[str, Any]", _resolve_with_idempotency(event, context))
    except IdempotencyKeyError:
        logger.warning("Request rejected: missing Idempotency-Key header")
        return {
            "statusCode": 400,
            # This response is built outside the Powertools resolver, so it must
            # carry its own CORS header (the resolver adds one to every response
            # it builds) — without it, cross-origin browsers can't read the 400
            # body at all. Keep in sync with CORSConfig.allow_origin above.
            "headers": {
                "Content-Type": "application/json",
                "Access-Control-Allow-Origin": "*",
            },
            "body": '{"message":"Idempotency-Key header is required"}',
            "isBase64Encoded": False,
        }
    except IdempotencyAlreadyInProgressError:
        # Raised when the same Idempotency-Key arrives while the first
        # execution's record is still INPROGRESS — ordinary client behavior
        # (double-click, timeout-retry during a cold start), not a fault.
        # Uncaught it would become an API Gateway 502 with no CORS header and
        # off the OpenAPI contract, and each occurrence would feed the canary
        # alias-errors rollback alarm.
        logger.warning("Request rejected: duplicate request while the original is still in progress")
        return {
            "statusCode": 409,
            # Built outside the resolver — carries its own CORS header, same
            # rule as the 400 above. Keep in sync with CORSConfig.allow_origin.
            "headers": {
                "Content-Type": "application/json",
                "Access-Control-Allow-Origin": "*",
            },
            "body": '{"message":"A request with this Idempotency-Key is still in progress; retry shortly"}',
            "isBase64Encoded": False,
        }