Skip to content

Commit 8319886

Browse files
committed
docs(event_handler): finish API Gateway WebSocket resolver docs
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.
1 parent 7a58a7b commit 8319886

6 files changed

Lines changed: 241 additions & 1 deletion

File tree

docs/core/event_handler/api_gateway_websocket.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ status: new
66

77
Event Handler for Amazon API Gateway WebSocket APIs.
88

9+
```mermaid
10+
stateDiagram-v2
11+
direction LR
12+
EventSource: WebSocket client
13+
Gateway: Amazon API Gateway
14+
LambdaInit: Lambda invocation
15+
EventHandler: Event Handler
16+
EventHandlerResolver: Route event based on route key
17+
YourLogic: Run your registered route handler
18+
EventHandlerResolverBuilder: Normalize response (statusCode/body)
19+
LambdaResponse: Lambda response
20+
21+
EventSource --> Gateway: $connect, messages, $disconnect
22+
Gateway --> LambdaInit: Route selection
23+
24+
LambdaInit --> EventHandler
25+
26+
state EventHandler {
27+
[*] --> EventHandlerResolver: app.resolve(event, context)
28+
EventHandlerResolver --> YourLogic
29+
YourLogic --> EventHandlerResolverBuilder
30+
}
31+
32+
EventHandler --> LambdaResponse
33+
```
34+
935
## Key Features
1036

1137
* Route WebSocket events by route key with dedicated `$connect`, `$disconnect`, and `$default` decorators
@@ -185,6 +211,109 @@ Register handlers for specific exception types with `@app.exception_handler`; it
185211
???+ note "Unhandled exceptions never reach the client"
186212
An exception with no registered handler is logged and becomes a bare `{"statusCode": 500}`. If it propagated to the Lambda runtime instead, the runtime's error response — exception type, message, and stack trace — would be delivered to the connected client.
187213

214+
### Accessing the event and Lambda context
215+
216+
Inside handlers and middlewares, `app.current_event` is the current `APIGatewayWebSocketEvent` ([Event Source Data Classes](../../utilities/data_classes.md){target="_blank"} utility) and `app.lambda_context` is the Lambda context.
217+
218+
Use `app.append_context` / `app.context` to share data between middlewares and handlers within a single invocation — the context is cleared after each `resolve`.
219+
220+
=== "accessing_websocket_event_and_context.py"
221+
222+
```python hl_lines="9 13 14"
223+
--8<-- "examples/event_handler_api_gateway_websocket/src/accessing_websocket_event_and_context.py"
224+
```
225+
226+
1. Route, connection, and identity details live in `request_context`.
227+
2. `json_body` parses the message body; `body` and `decoded_body` are also available.
228+
3. The standard Lambda context object.
229+
230+
### Sending messages to connected clients
231+
232+
Returned bodies only reply to the **calling** client, on routes with a route response. To push a message to a client at any time — including after the invocation that received the request has long finished — call the API Gateway Management API's `PostToConnection` with the connection ID, against the endpoint the `callback_url` property gives you.
233+
234+
Capture `connection_id` and `callback_url` together at `$connect` and persist them: that record is everything **any** process needs to push to that client later.
235+
236+
The example shows both sending situations. Inside the resolver, `broadcast` pushes to every stored connection from the same invocation, building the client from the current event.
237+
238+
Outside it, `submitReport` acknowledges a long-running request immediately, and a **separate Lambda function** — for example the final state of a Step Functions workflow — pushes the finished result to the one client that asked, using the stored `callback_url`. Progress updates are that same call made mid-work.
239+
240+
A stale connection ID raises `GoneException` — the client is no longer connected, so treat it as a signal to remove that connection from your store. The posting function also needs the `execute-api:ManageConnections` IAM permission on `arn:aws:execute-api:{region}:{account}:{api-id}/{stage}/POST/@connections/*`.
241+
242+
=== "working_with_post_to_connection.py"
243+
244+
```python hl_lines="2 16 28 35 41"
245+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_post_to_connection.py"
246+
```
247+
248+
1. The only thing the WebSocket resolver and the pushing function share.
249+
2. Captured at `$connect`: the only moment you get for free, and all anyone needs to post to this client later.
250+
3. Acknowledge immediately; the result is pushed by another function when it is ready. For the ack to reach the client, configure a route response on `submitReport`.
251+
4. Sending from inside the resolver: the current event provides the endpoint directly.
252+
253+
=== "working_with_post_to_connection_report_ready.py"
254+
255+
```python hl_lines="18 23"
256+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_post_to_connection_report_ready.py"
257+
```
258+
259+
1. The stored `callback_url` rebuilds the Management API client in any execution environment.
260+
2. The client disconnected before the result was ready — remove it from the store.
261+
262+
=== "my_connection_store.py"
263+
264+
```python
265+
--8<-- "examples/event_handler_api_gateway_websocket/src/my_connection_store.py"
266+
```
267+
268+
???+ note "Custom domain names"
269+
`callback_url` is built from the event's domain name. When clients connect through a custom domain whose API mapping path differs from the stage name, construct the endpoint yourself instead: `https://{api_id}.execute-api.{region}.amazonaws.com/{stage}`.
270+
271+
## Event Handler workflow
272+
273+
### Connection lifecycle
274+
275+
Connection accepted, message exchanged, connection closed.
276+
277+
<center>
278+
```mermaid
279+
sequenceDiagram
280+
participant Client as WebSocket client
281+
participant Gateway as Amazon API Gateway
282+
participant Lambda as Lambda (Event Handler)
283+
Client->>Gateway: Upgrade request (wss://)
284+
Gateway->>Lambda: $connect event
285+
Lambda-->>Gateway: {"statusCode": 200}
286+
Gateway-->>Client: 101 Switching Protocols
287+
Client->>Gateway: {"action": "orderUpdate", ...}
288+
Gateway->>Lambda: orderUpdate event (route selection expression)
289+
Lambda-->>Gateway: {"statusCode": 200, "body": "..."}
290+
Gateway-->>Client: body (only with a route response configured)
291+
Client->>Gateway: Close frame
292+
Gateway->>Lambda: $disconnect event (best effort)
293+
Lambda-->>Gateway: {"statusCode": 200}
294+
```
295+
</center>
296+
297+
### Rejected connection
298+
299+
Connection rejected by the `$connect` handler.
300+
301+
<center>
302+
```mermaid
303+
sequenceDiagram
304+
participant Client as WebSocket client
305+
participant Gateway as Amazon API Gateway
306+
participant Lambda as Lambda (Event Handler)
307+
Client->>Gateway: Upgrade request (wss://)
308+
Gateway->>Lambda: $connect event
309+
Lambda-->>Gateway: {"statusCode": 401}
310+
Gateway-->>Client: HTTP 401 (upgrade refused)
311+
Note over Gateway,Lambda: $disconnect may still be delivered for this connection
312+
Gateway->>Lambda: $disconnect event
313+
Lambda-->>Gateway: {"statusCode": 200}
314+
```
315+
</center>
316+
188317
## Testing your code
189318

190319
Test your handlers by passing a WebSocket event payload to `app.resolve()` — no mocks of API Gateway needed.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
app = APIGatewayWebSocketResolver()
5+
6+
7+
@app.on_default()
8+
def default():
9+
request_context = app.current_event.request_context # (1)!
10+
return {
11+
"connectionId": request_context.connection_id,
12+
"routeKey": request_context.route_key,
13+
"message": app.current_event.json_body, # (2)!
14+
"remainingTimeMs": app.lambda_context.get_remaining_time_in_millis(), # (3)!
15+
}
16+
17+
18+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
19+
return app.resolve(event, context)

examples/event_handler_api_gateway_websocket/src/getting_started_with_testing_event.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@
4646
"apiId": "asasasas"
4747
},
4848
"isBase64Encoded": false
49-
}
49+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Illustrative connection store. In production, back these functions with a durable store
2+
# such as a DynamoDB table: the WebSocket resolver and the functions that push results run
3+
# in different Lambda execution environments and cannot share process memory.
4+
5+
_connections: dict[str, str] = {}
6+
7+
8+
def save(connection_id: str, callback_url: str) -> None:
9+
_connections[connection_id] = callback_url
10+
11+
12+
def get_callback_url(connection_id: str) -> str:
13+
return _connections[connection_id]
14+
15+
16+
def all_connection_ids() -> list[str]:
17+
return list(_connections)
18+
19+
20+
def delete(connection_id: str) -> None:
21+
_connections.pop(connection_id, None)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import boto3
2+
import my_connection_store # (1)!
3+
4+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
5+
from aws_lambda_powertools.utilities.typing import LambdaContext
6+
7+
app = APIGatewayWebSocketResolver()
8+
9+
10+
def start_report_generation(report_id: str, connection_id: str) -> None: ... # e.g. start a Step Functions execution
11+
12+
13+
@app.on_connect()
14+
def connect():
15+
request_context = app.current_event.request_context
16+
my_connection_store.save(request_context.connection_id, request_context.callback_url) # (2)!
17+
18+
19+
@app.on_disconnect()
20+
def disconnect():
21+
my_connection_store.delete(app.current_event.request_context.connection_id)
22+
23+
24+
@app.route("submitReport")
25+
def submit_report():
26+
request = app.current_event.json_body
27+
start_report_generation(request["reportId"], app.current_event.request_context.connection_id)
28+
return {"status": "accepted"} # (3)!
29+
30+
31+
@app.route("broadcast")
32+
def broadcast():
33+
client = boto3.client(
34+
"apigatewaymanagementapi",
35+
endpoint_url=app.current_event.request_context.callback_url, # (4)!
36+
)
37+
message: str = app.current_event.json_body["message"]
38+
for connection_id in my_connection_store.all_connection_ids():
39+
try:
40+
client.post_to_connection(ConnectionId=connection_id, Data=message.encode())
41+
except client.exceptions.GoneException:
42+
my_connection_store.delete(connection_id)
43+
44+
45+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
46+
return app.resolve(event, context)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# A separate Lambda function, in a different execution environment from the WebSocket
2+
# resolver — invoked when the work completes, e.g. as the final state of a Step Functions
3+
# workflow. The connection store is the only thing the two functions share.
4+
5+
import json
6+
7+
import boto3
8+
import my_connection_store
9+
10+
from aws_lambda_powertools import Logger
11+
from aws_lambda_powertools.utilities.typing import LambdaContext
12+
13+
logger = Logger()
14+
15+
16+
def lambda_handler(event: dict, context: LambdaContext) -> None:
17+
connection_id = event["connectionId"]
18+
callback_url = my_connection_store.get_callback_url(connection_id) # (1)!
19+
20+
client = boto3.client("apigatewaymanagementapi", endpoint_url=callback_url)
21+
try:
22+
client.post_to_connection(ConnectionId=connection_id, Data=json.dumps(event["report"]).encode())
23+
except client.exceptions.GoneException: # (2)!
24+
logger.info("Client disconnected before the report was ready", connection_id=connection_id)
25+
my_connection_store.delete(connection_id)

0 commit comments

Comments
 (0)