Skip to content

feat(event_handler): add API Gateway WebSocket resolver#8338

Open
Iamrodos wants to merge 7 commits into
aws-powertools:developfrom
Iamrodos:feat/websocket-event-handler
Open

feat(event_handler): add API Gateway WebSocket resolver#8338
Iamrodos wants to merge 7 commits into
aws-powertools:developfrom
Iamrodos:feat/websocket-event-handler

Conversation

@Iamrodos

Copy link
Copy Markdown
Contributor

Issue number: closes #1165

Summary

Changes

Implements the design agreed in RFC discussion #5905. Adds a standalone APIGatewayWebSocketResolver for Amazon API Gateway WebSocket APIs, built on the existing APIGatewayWebSocketEvent data class: route decorators (on_connect/on_disconnect/on_default, plus route("customKey") with exact route-key matching), response normalization to the statusCode/body shape API Gateway expects, middleware support, Router/include_router for splitting routes across files, and exception handling with a catch-all that returns a bare 500 so exception details never reach connected clients over the socket.

This PR stands on the shoulders of giants: the package structure and Router mechanics mirror the AppSync Events resolver (#6558), middleware reuses the shared BaseMiddlewareHandler/NextMiddleware framework, and exception handling completes the shared ExceptionHandlerManager pattern that AppSync Events left groundwork for. Most of the hard problems were already standardised elsewhere in Event Handler — the new work is mapping WebSocket semantics onto those rails.

The first commit also rounds out the WebSocket data class with two small properties the resolver builds on: authorizer (the context API Gateway injects on every invocation when a Lambda authorizer is configured) and callback_url (the Management API endpoint for pushing messages to clients).

Docs page with getting-started examples, SAM template, authentication patterns, and a send-to-client recipe included, following the AppSync Events page as the template.

User experience

Before:

import json


def lambda_handler(event, context):
    route_key = event["requestContext"]["routeKey"]

    if route_key == "$connect":
        if "Authorization" not in event.get("headers", {}):
            return {"statusCode": 401}
        return {"statusCode": 200}
    elif route_key == "orderUpdate":
        try:
            order = json.loads(event["body"])
            return {"statusCode": 200, "body": json.dumps({"orderId": order["orderId"]})}
        except Exception:
            # any unhandled exception here leaks the runtime's error JSON
            # (type, message, stack trace) straight to the connected client
            return {"statusCode": 500}
    else:
        return {"statusCode": 400}

After:

from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
from aws_lambda_powertools.utilities.typing import LambdaContext

app = APIGatewayWebSocketResolver()


@app.on_connect()
def connect():
    if "Authorization" not in app.current_event.headers:
        return None, 401  # reject the WebSocket upgrade
    return None  # 200: accept


@app.route("orderUpdate")
def order_update():
    order = app.current_event.json_body
    return {"orderId": order["orderId"]}  # unhandled exceptions become a bare 500


def lambda_handler(event: dict, context: LambdaContext) -> dict:
    return app.resolve(event, context)

Open questions for review

  1. Sugar decorator namingon_connect/on_disconnect/on_default chosen for consistency with AppSync Events' on_publish/on_subscribe; trivial to rename.
  2. Unknown route key behaviour — currently PowertoolsUserWarning + {"statusCode": 400} (a miss means the API has a route configured with no handler registered; surfacing beats silence). Alternatives: raise, or return 200 silently.
  3. Shared middleware infrastructure — two related observations from the build: (a) NextMiddleware's EventHandlerInstance TypeVar is bound to ApiGatewayResolver, so users annotating WebSocket middlewares with it fail mypy (runtime is fine; examples use unannotated params). A one-line bound widening would fix it. (b) This resolver imports MiddlewareFrame from api_gateway.py — worth lifting into event_handler/middlewares/ as shared infrastructure? Happy to do either here or as follow-ups.

Possible follow-ups

Offering these as separate PRs if there's appetite — none block this one:

  • A thin WebSocketConnectionManager helper for the send-to-client path: post(connection_id, data), GoneException handled (typed error or a post_if_connected returning False), lazy boto3 import, built from the event or an endpoint. The docs recipe in this PR is the seed. Tension acknowledged: Powertools avoids re-wrapping the SDK; the counter-precedent is parameters/idempotency, which wrap boto3 where boilerplate reduction is real — GoneException handling is exactly that.
  • E2E suite — a CDK-based tests/e2e/event_handler_websocket/ suite (real WebSocket API + wss:// client) could assert what functional tests physically cannot: route responses delivering bodies, rejected $connect refusing the upgrade at the protocol level, a real Lambda authorizer's context on message routes, and a genuine GoneException. The REST event handler's e2e suite is the precedent; offering since e2e is maintainer-run infra.
  • Data classes docs entrydocs/utilities/data_classes.md has no APIGatewayWebSocketEvent entry (pre-existing gap); happy to add a row.

Checklist

If your change doesn't seem to apply, please leave them unchecked.

  • Meet tenets criteria
  • I have performed a self-review of this change
  • Changes have been tested — including end-to-end over a real WebSocket against a local API Gateway emulator (17 protocol-level checks); harness and transcript in the first comment below
  • Changes are documented
  • PR title follows conventional commit semantics

Acknowledgment

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@Iamrodos Iamrodos requested a review from a team as a code owner July 13, 2026 13:35
@Iamrodos Iamrodos requested a review from hjgraca July 13, 2026 13:35
@boring-cyborg boring-cyborg Bot added documentation Improvements or additions to documentation event_handlers tests labels Jul 13, 2026
@powertools-for-aws-oss-automation powertools-for-aws-oss-automation Bot added the size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. label Jul 13, 2026
Iamrodos added 6 commits July 13, 2026 23:58
…bSocket event

Groundwork for the API Gateway WebSocket event handler (aws-powertools#1165), kept as
a separate commit so the data-class changes are reviewable on their own.

The resolver's auth and send-to-client patterns need both properties:
the repo already supports authoring WebSocket Lambda authorizers
(APIGatewayAuthorizerResponseWebSocket); this adds the consuming side —
the authorizer context API Gateway injects into every invocation for a
connection — plus the Management API callback URL for replying to clients.
Standalone resolver for API Gateway WebSocket APIs (aws-powertools#1165), built on
APIGatewayWebSocketEvent and mirroring the AppSync Events package
structure: route()/on_connect/on_disconnect/on_default registration
with exact route-key matching, response normalization to the
statusCode/body shape API Gateway expects, and exception handling via
the shared ExceptionHandlerManager with a catch-all that returns a bare
500 so exception details never reach connected clients.

Includes functional tests (full coverage), docs page with getting
started examples and SAM template, and mkdocs nav.
…resolver

Reuses the shared middleware framework (MiddlewareFrame,
BaseMiddlewareHandler) with a WebSocket terminal adapter: global
middlewares registered via use() run first, then route-level
middlewares=[...], then the handler. Return values — including
short-circuits that skip next_middleware — go through the same
response normalization as handler returns, and middleware exceptions
follow the existing exception-handling path. Global middlewares also
run around the 400 for unmatched route keys, matching the REST
resolver's not-found behaviour.

Includes middleware tests (ordering, short-circuit, isolation across
routes, exception paths, class-based middleware) and docs: Middleware
plus Authentication patterns (Lambda authorizer first, then
authenticate/inject_user middleware with a bring-your-own connection
store).
…lver

include_router merges a Router's routes, global middlewares,
exception handlers, and context into the resolver, so route keys can
be split across files. Router handlers access the request through the
router instance (router.current_event), mirroring the AppSync Events
and REST resolver conventions. On route key collisions the
router-registered handler wins, matching AppSync Events.

Includes tests for dispatch, context pointer sharing, middleware and
exception-handler merging and ordering, route-key precedence, plus
docs with a two-file example.
Adds the overview and workflow mermaid diagrams, accessing the event
and Lambda context, and the sending-messages recipe: a connection
store capturing connection_id and callback_url at $connect, an
in-resolver broadcast, and a separate function pushing a result to
the requesting client later — with GoneException handling,
ManageConnections IAM, and a custom domain caveat.
@Iamrodos Iamrodos force-pushed the feat/websocket-event-handler branch from aad0bae to 073e08e Compare July 13, 2026 13:58
@powertools-for-aws-oss-automation powertools-for-aws-oss-automation Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Jul 13, 2026
RouteResponseKey: $default

WebSocketStage:
Type: AWS::ApiGatewayV2::Stage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 72c6b67. AccessLogSettings added to the stage.

Adds botocore timeouts to Management API clients, moves client
construction out of the handler, and enables function and access
logging in the getting-started SAM template. Example code, it turns
out, is held to production standards — fair enough.
@powertools-for-aws-oss-automation powertools-for-aws-oss-automation Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Jul 13, 2026
@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.68%. Comparing base (5147309) to head (72c6b67).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #8338      +/-   ##
===========================================
+ Coverage    96.63%   96.68%   +0.04%     
===========================================
  Files          296      302       +6     
  Lines        14765    14950     +185     
  Branches      1245     1255      +10     
===========================================
+ Hits         14268    14454     +186     
+ Misses         362      361       -1     
  Partials       135      135              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Iamrodos

Copy link
Copy Markdown
Contributor Author

Local test exerciser — while building this I used a small API Gateway WebSocket emulator to drive the resolver over a real socket: genuine upgrade handshake, route selection, route responses, and the Management API (with a real GoneException). Attaching it (zip: the emulator, a demo app, a scripted client) so reviewers can poke the resolver in a couple of minutes. It's a dev tool — not proposed for inclusion.

pip install aiohttp websockets boto3
python ws_local_harness.py     # emulator serving the demo app
python ws_harness_client.py    # 17 scripted checks

A real session (> sent, < received), against demo routes like:

@app.route("orderUpdate")
def order_update():
    order = app.current_event.json_body
    if "orderId" not in order:
        raise ValueError("missing orderId")  # handled by @app.exception_handler(ValueError)
    return OrderReceipt(orderId=order["orderId"], status="received", price=Decimal("19.99"), ...)


@app.route("boom")
def boom():
    raise RuntimeError("secret detail that must never reach the client")
> {"action": "orderUpdate", "orderId": 42}
< {"orderId":42,"status":"received","price":"19.99","connectionId":"90a85d7d..."}
> {"action": "orderUpdate"}
< {"error":"missing orderId"}
> {"action": "boom"}
< (nothing -- the resolver returned a bare {"statusCode": 500}; no details cross the socket)
> {"hello": "no action key"}
< {"echo":"{\"hello\": \"no action key\"}"}
Full checklist — 17/17
PASS  connect accepted with Cookie header (socket open)
PASS  custom route: dataclass + Decimal body via shared Encoder  ({'orderId': 42, 'status': 'received', 'price': '19.99'})
PASS  unknown action falls through to $default
PASS  middleware order: global (use) before route-level  ({'chain': ['global', 'route']})
PASS  middleware short-circuits unauthenticated profile  ({'error': 'unauthenticated'})
PASS  duplicate route registration: last wins  ({'greeting': 'from last registration'})
PASS  registered exception handler response returned  ({'error': 'missing orderId'})
PASS  handler exception leaks nothing to client  (no frame received)
PASS  submitReport acknowledged immediately  ({'status': 'accepted'})
PASS  separate function pushes result via stored callback_url  ({'reportId': 'r-1', 'rows': 42})
PASS  boto3 post_to_connection delivers to live connection
      ($disconnect close code + duplicate re-dispatch: harness log shows both dispatches with statusCode 200)
PASS  boto3 GoneException after close
PASS  rejected $connect refuses upgrade (case-insensitive header)  (HTTP 401)
PASS  cookie-based $connect rejection  (HTTP 403)
PASS  inject_user middleware provides identity from store  ({'user': 'alice'})
PASS  authorizer denies $connect without token  (HTTP 403)
PASS  authorizer context injected on message route  ({'principalId': 'user-42', 'tenantId': 'tenant-a', 'plan': 'premium'})

17/17 checks passed

ws_local_harness.zip

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation event_handlers size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: add support for working with AWS Websocket API Gateway

2 participants