Skip to content

Commit d97adde

Browse files
authored
fix(local-lambda): accept documented Lambda DurableExecutionArn shape (#9040)
The Lambda public API documents DurableExecutionArn as arn:([a-zA-Z0-9-]+):lambda:([a-zA-Z0-9-]+):(\d{12}) :function:([a-zA-Z0-9_-]+) :(\$LATEST(?:\.PUBLISHED)?|[0-9]+) /durable-execution/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+) (https://docs.aws.amazon.com/lambda/latest/api/API_GetDurableExecution.html) That shape contains characters that boto's REST-JSON serializer percent-encodes inside non-greedy URI labels: '/' -> %2F, ':' -> %3A, '$' -> %24. Werkzeug decodes those back to their literal forms before route matching, so the default <string> converter (which does not match '/') cannot match the resulting multi-segment path. Every Get / History / Stop and every callback succeed / fail / heartbeat 404'd with PathNotFoundLocally before reaching the handler when sam-cli was given the ARN shape the API itself documents. Switch the six DurableExecutionArn / CallbackId URI labels in LocalLambdaHttpService from <string> to <path:...>. Werkzeug picks the right rule via trailing-literal specificity (.../<path:arn>/history beats .../<path:arn>). The existing unquote() calls in handlers stay -- idempotent on already-decoded values. Backwards-compatible with the legacy UUID-only shape. Adds TestDurableArnShapeCompatibility, which drives the real Flask app via test_client across three ARN shapes (the documented one, the transitional <uuid>/<invocation-id> currently emitted by the durable-functions emulator, and the legacy bare UUID) for all six routes plus the three callback actions, asserting routing reaches the correct handler with the decoded value. Verified locally: - make pr (init, schema, black-check, lint, test-all): green on Python 3.11.11 (9253 unit tests pass, 25 skipped, 28 subtests pass, coverage 94.16%) - tests/integration/local/start_lambda/test_start_lambda_durable.py: all 18 tests pass (was 17 failures pre-fix), end-to-end through the public.ecr.aws/durable-functions/aws-durable-execution-emulator Docker image Fixes #9039
1 parent 3c1153a commit d97adde

2 files changed

Lines changed: 140 additions & 12 deletions

File tree

samcli/local/lambda_service/local_lambda_http_service.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,43 +96,43 @@ def create(self):
9696

9797
# Durable functions endpoints
9898
self._app.add_url_rule(
99-
"/2025-12-01/durable-executions/<durable_execution_arn>",
99+
"/2025-12-01/durable-executions/<path:durable_execution_arn>",
100100
endpoint="get_durable_execution",
101101
view_func=self._get_durable_execution_handler,
102102
methods=["GET"],
103103
)
104104

105105
self._app.add_url_rule(
106-
"/2025-12-01/durable-executions/<durable_execution_arn>/history",
106+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/history",
107107
endpoint="get_durable_execution_history",
108108
view_func=self._get_durable_execution_history_handler,
109109
methods=["GET"],
110110
)
111111

112112
self._app.add_url_rule(
113-
"/2025-12-01/durable-executions/<durable_execution_arn>/stop",
113+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/stop",
114114
endpoint="stop_durable_execution",
115115
view_func=self._stop_durable_execution_handler,
116116
methods=["POST"],
117117
)
118118

119119
# Callback endpoints
120120
self._app.add_url_rule(
121-
"/2025-12-01/durable-execution-callbacks/<callback_id>/succeed",
121+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/succeed",
122122
endpoint="send_callback_success",
123123
view_func=self._send_callback_success_handler,
124124
methods=["POST"],
125125
)
126126

127127
self._app.add_url_rule(
128-
"/2025-12-01/durable-execution-callbacks/<callback_id>/fail",
128+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/fail",
129129
endpoint="send_callback_failure",
130130
view_func=self._send_callback_failure_handler,
131131
methods=["POST"],
132132
)
133133

134134
self._app.add_url_rule(
135-
"/2025-12-01/durable-execution-callbacks/<callback_id>/heartbeat",
135+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/heartbeat",
136136
endpoint="send_callback_heartbeat",
137137
view_func=self._send_callback_heartbeat_handler,
138138
methods=["POST"],

tests/unit/local/lambda_service/test_local_lambda_http_service.py

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime
44
from unittest import TestCase
55
from unittest.mock import ANY, Mock, call, patch
6+
from urllib.parse import quote
67

78
from parameterized import parameterized
89

@@ -64,42 +65,42 @@ def test_create_service_endpoints(self, flask_mock, error_handling_mock):
6465

6566
# Verify durable functions endpoints were added
6667
app_mock.add_url_rule.assert_any_call(
67-
"/2025-12-01/durable-executions/<durable_execution_arn>",
68+
"/2025-12-01/durable-executions/<path:durable_execution_arn>",
6869
endpoint="get_durable_execution",
6970
view_func=service._get_durable_execution_handler,
7071
methods=["GET"],
7172
)
7273

7374
app_mock.add_url_rule.assert_any_call(
74-
"/2025-12-01/durable-executions/<durable_execution_arn>/history",
75+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/history",
7576
endpoint="get_durable_execution_history",
7677
view_func=service._get_durable_execution_history_handler,
7778
methods=["GET"],
7879
)
7980

8081
app_mock.add_url_rule.assert_any_call(
81-
"/2025-12-01/durable-executions/<durable_execution_arn>/stop",
82+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/stop",
8283
endpoint="stop_durable_execution",
8384
view_func=service._stop_durable_execution_handler,
8485
methods=["POST"],
8586
)
8687

8788
app_mock.add_url_rule.assert_any_call(
88-
"/2025-12-01/durable-execution-callbacks/<callback_id>/succeed",
89+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/succeed",
8990
endpoint="send_callback_success",
9091
view_func=service._send_callback_success_handler,
9192
methods=["POST"],
9293
)
9394

9495
app_mock.add_url_rule.assert_any_call(
95-
"/2025-12-01/durable-execution-callbacks/<callback_id>/fail",
96+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/fail",
9697
endpoint="send_callback_failure",
9798
view_func=service._send_callback_failure_handler,
9899
methods=["POST"],
99100
)
100101

101102
app_mock.add_url_rule.assert_any_call(
102-
"/2025-12-01/durable-execution-callbacks/<callback_id>/heartbeat",
103+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/heartbeat",
103104
endpoint="send_callback_heartbeat",
104105
view_func=service._send_callback_heartbeat_handler,
105106
methods=["POST"],
@@ -1313,3 +1314,130 @@ def test_invoke_request_handler_combines_headers_with_durable_execution_arn(
13131314
),
13141315
}
13151316
service_response_mock.assert_called_once_with("hello world", expected_headers, 200)
1317+
1318+
1319+
class TestDurableArnShapeCompatibility(TestCase):
1320+
"""
1321+
Routing must accept the documented Lambda ``DurableExecutionArn`` shape.
1322+
1323+
Per the Lambda public API docs, ``DurableExecutionArn`` matches::
1324+
1325+
arn:([a-zA-Z0-9-]+):lambda:([a-zA-Z0-9-]+):(\\d{12})
1326+
:function:([a-zA-Z0-9_-]+)
1327+
:(\\$LATEST(?:\\.PUBLISHED)?|[0-9]+)
1328+
/durable-execution/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)
1329+
1330+
https://docs.aws.amazon.com/lambda/latest/api/API_GetDurableExecution.html
1331+
1332+
The shape contains characters that boto's REST-JSON serializer
1333+
percent-encodes inside a non-greedy URI label: ``/`` -> ``%2F``,
1334+
``:`` -> ``%3A``, ``$`` -> ``%24``. Werkzeug decodes those back to
1335+
their literal forms before route matching, so the default ``<string>``
1336+
converter (which does not match ``/``) cannot match the resulting
1337+
multi-segment path. Switching the route to ``<path:...>`` accepts the
1338+
documented shape end-to-end and remains backwards-compatible with the
1339+
legacy UUID-only shape and the transitional ``<uuid>/<invocation-id>``
1340+
shape currently emitted by the durable-functions emulator.
1341+
1342+
These tests drive the real Flask app via its test client to assert
1343+
routing reaches the correct handler with the decoded value, and that
1344+
the trailing-literal rules (``/history`` and ``/stop``) win over the
1345+
bare ``<path:arn>`` rule on the same prefix.
1346+
"""
1347+
1348+
DOC_ARN = (
1349+
"arn:aws:lambda:us-east-1:123456789012"
1350+
":function:myDurableFunction:$LATEST"
1351+
"/durable-execution/myExecutionName/01H8X7Y8Z9ABCDEFGHIJKLMNOP"
1352+
)
1353+
PLACEHOLDER_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579/2a8bc667-bc0b-4b5e-8c78-26db9c5b4e33"
1354+
LEGACY_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579"
1355+
1356+
@patch("samcli.local.lambda_service.local_lambda_http_service.LocalLambdaHttpService._construct_error_handling")
1357+
def _build_service(self, error_handling_mock):
1358+
lambda_runner_mock = Mock()
1359+
service = LocalLambdaHttpService(lambda_runner=lambda_runner_mock, port=3000, host="127.0.0.1")
1360+
service.create()
1361+
return service
1362+
1363+
@parameterized.expand(
1364+
[
1365+
("doc_shape", DOC_ARN),
1366+
("placeholder", PLACEHOLDER_ARN),
1367+
("legacy_uuid_only", LEGACY_ARN),
1368+
]
1369+
)
1370+
@patch.object(LocalLambdaHttpService, "_get_durable_execution_handler")
1371+
def test_get_durable_execution_routes_arn(self, _name, arn, handler_mock):
1372+
handler_mock.return_value = ("ok", 200)
1373+
service = self._build_service()
1374+
client = service._app.test_client()
1375+
1376+
encoded = quote(arn, safe="")
1377+
response = client.get(f"/2025-12-01/durable-executions/{encoded}")
1378+
1379+
# Pre-fix: 404 PathNotFoundLocally because <string> cannot match a
1380+
# segment containing decoded "/" (or ":" / "$").
1381+
self.assertEqual(response.status_code, 200)
1382+
handler_mock.assert_called_once_with(durable_execution_arn=arn)
1383+
1384+
@parameterized.expand(
1385+
[
1386+
("doc_shape", DOC_ARN),
1387+
("placeholder", PLACEHOLDER_ARN),
1388+
("legacy_uuid_only", LEGACY_ARN),
1389+
]
1390+
)
1391+
@patch.object(LocalLambdaHttpService, "_get_durable_execution_history_handler")
1392+
def test_get_durable_execution_history_routes_arn(self, _name, arn, handler_mock):
1393+
handler_mock.return_value = ("ok", 200)
1394+
service = self._build_service()
1395+
client = service._app.test_client()
1396+
1397+
encoded = quote(arn, safe="")
1398+
response = client.get(f"/2025-12-01/durable-executions/{encoded}/history")
1399+
1400+
self.assertEqual(response.status_code, 200)
1401+
# Trailing literal /history must win over the bare <path:arn> rule.
1402+
handler_mock.assert_called_once_with(durable_execution_arn=arn)
1403+
1404+
@parameterized.expand(
1405+
[
1406+
("doc_shape", DOC_ARN),
1407+
("placeholder", PLACEHOLDER_ARN),
1408+
("legacy_uuid_only", LEGACY_ARN),
1409+
]
1410+
)
1411+
@patch.object(LocalLambdaHttpService, "_stop_durable_execution_handler")
1412+
def test_stop_durable_execution_routes_arn(self, _name, arn, handler_mock):
1413+
handler_mock.return_value = ("ok", 200)
1414+
service = self._build_service()
1415+
client = service._app.test_client()
1416+
1417+
encoded = quote(arn, safe="")
1418+
response = client.post(f"/2025-12-01/durable-executions/{encoded}/stop")
1419+
1420+
self.assertEqual(response.status_code, 200)
1421+
handler_mock.assert_called_once_with(durable_execution_arn=arn)
1422+
1423+
@parameterized.expand(
1424+
[
1425+
("succeed", "_send_callback_success_handler"),
1426+
("fail", "_send_callback_failure_handler"),
1427+
("heartbeat", "_send_callback_heartbeat_handler"),
1428+
]
1429+
)
1430+
def test_callback_routes_id_with_slash(self, action, handler_attr):
1431+
# Base64 callback IDs contain '/' which boto percent-encodes the
1432+
# same way as the doc-shape ARN's slashes.
1433+
decoded_id = "abc/def=="
1434+
encoded_id = quote(decoded_id, safe="")
1435+
with patch.object(LocalLambdaHttpService, handler_attr) as handler_mock:
1436+
handler_mock.return_value = ("ok", 200)
1437+
service = self._build_service()
1438+
client = service._app.test_client()
1439+
1440+
response = client.post(f"/2025-12-01/durable-execution-callbacks/{encoded_id}/{action}")
1441+
1442+
self.assertEqual(response.status_code, 200)
1443+
handler_mock.assert_called_once_with(callback_id=decoded_id)

0 commit comments

Comments
 (0)