Skip to content

Commit 05039de

Browse files
committed
fix(local-lambda): allow '/' in durable execution and callback URI labels
After aws-durable-execution-sdk-python-testing#216, DurableExecutionArn values are minted as <uuid>/<invocation-id> and boto percent-encodes the '/' as %2F in the URI label. Werkzeug decodes %2F back to '/' during routing, so the path no longer matches a single <string> segment and every Get/History/Stop request 404'd before reaching the handler with PathNotFoundLocally. The same shape applies to base64-encoded CallbackId values which contain '/'. Switch the six ARN/CallbackId URI labels from the default <string> converter 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 old UUID-only ARNs. Adds TestDurableRoutingPercentEncodedSlash regression tests that drive the real Flask app via test_client with %2F-encoded paths across all six routes plus a UUID-only legacy form, asserting routing reaches the correct handler with the decoded value. Verified locally: - make pr (init, schema, black-check, lint, test-all): green (9248 unit tests pass, 25 skipped, 28 subtests pass, coverage 94.17%) - tests/integration/local/start_lambda/test_start_lambda_durable.py all 18 tests pass (was 17 failures pre-fix), exercising the new ARN format end-to-end through real Docker emulator Fixes #9039
1 parent f46d86e commit 05039de

2 files changed

Lines changed: 105 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: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,42 +64,42 @@ def test_create_service_endpoints(self, flask_mock, error_handling_mock):
6464

6565
# Verify durable functions endpoints were added
6666
app_mock.add_url_rule.assert_any_call(
67-
"/2025-12-01/durable-executions/<durable_execution_arn>",
67+
"/2025-12-01/durable-executions/<path:durable_execution_arn>",
6868
endpoint="get_durable_execution",
6969
view_func=service._get_durable_execution_handler,
7070
methods=["GET"],
7171
)
7272

7373
app_mock.add_url_rule.assert_any_call(
74-
"/2025-12-01/durable-executions/<durable_execution_arn>/history",
74+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/history",
7575
endpoint="get_durable_execution_history",
7676
view_func=service._get_durable_execution_history_handler,
7777
methods=["GET"],
7878
)
7979

8080
app_mock.add_url_rule.assert_any_call(
81-
"/2025-12-01/durable-executions/<durable_execution_arn>/stop",
81+
"/2025-12-01/durable-executions/<path:durable_execution_arn>/stop",
8282
endpoint="stop_durable_execution",
8383
view_func=service._stop_durable_execution_handler,
8484
methods=["POST"],
8585
)
8686

8787
app_mock.add_url_rule.assert_any_call(
88-
"/2025-12-01/durable-execution-callbacks/<callback_id>/succeed",
88+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/succeed",
8989
endpoint="send_callback_success",
9090
view_func=service._send_callback_success_handler,
9191
methods=["POST"],
9292
)
9393

9494
app_mock.add_url_rule.assert_any_call(
95-
"/2025-12-01/durable-execution-callbacks/<callback_id>/fail",
95+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/fail",
9696
endpoint="send_callback_failure",
9797
view_func=service._send_callback_failure_handler,
9898
methods=["POST"],
9999
)
100100

101101
app_mock.add_url_rule.assert_any_call(
102-
"/2025-12-01/durable-execution-callbacks/<callback_id>/heartbeat",
102+
"/2025-12-01/durable-execution-callbacks/<path:callback_id>/heartbeat",
103103
endpoint="send_callback_heartbeat",
104104
view_func=service._send_callback_heartbeat_handler,
105105
methods=["POST"],
@@ -1313,3 +1313,96 @@ def test_invoke_request_handler_combines_headers_with_durable_execution_arn(
13131313
),
13141314
}
13151315
service_response_mock.assert_called_once_with("hello world", expected_headers, 200)
1316+
1317+
1318+
class TestDurableRoutingPercentEncodedSlash(TestCase):
1319+
"""
1320+
Regression tests for #9039: Werkzeug's default ``<string>`` URL converter
1321+
rejects path segments containing ``/`` (decoded from ``%2F``), so a
1322+
``DurableExecutionArn`` of the form ``<uuid>/<invocation-id>`` 404'd before
1323+
reaching the handler. The fix is the ``<path:...>`` converter.
1324+
1325+
These tests drive the real Flask app via its test client to assert that
1326+
boto-encoded ARNs (``%2F`` for ``/``) reach the correct handler with the
1327+
decoded value, and that the more-specific trailing-literal rule (``/history``
1328+
or ``/stop``) wins over the bare ``<path:arn>`` rule.
1329+
"""
1330+
1331+
SLASH_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579/2a8bc667-bc0b-4b5e-8c78-26db9c5b4e33"
1332+
ENCODED_SLASH_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579%2F2a8bc667-bc0b-4b5e-8c78-26db9c5b4e33"
1333+
1334+
@patch("samcli.local.lambda_service.local_lambda_http_service.LocalLambdaHttpService._construct_error_handling")
1335+
def _build_service(self, error_handling_mock):
1336+
lambda_runner_mock = Mock()
1337+
service = LocalLambdaHttpService(lambda_runner=lambda_runner_mock, port=3000, host="127.0.0.1")
1338+
service.create()
1339+
return service
1340+
1341+
@patch.object(LocalLambdaHttpService, "_get_durable_execution_handler")
1342+
def test_get_durable_execution_routes_arn_with_slash(self, handler_mock):
1343+
handler_mock.return_value = ("ok", 200)
1344+
service = self._build_service()
1345+
client = service._app.test_client()
1346+
1347+
response = client.get(f"/2025-12-01/durable-executions/{self.ENCODED_SLASH_ARN}")
1348+
1349+
# Pre-fix: 404 PathNotFoundLocally because <string> won't match a segment with "/"
1350+
self.assertEqual(response.status_code, 200)
1351+
handler_mock.assert_called_once_with(durable_execution_arn=self.SLASH_ARN)
1352+
1353+
@patch.object(LocalLambdaHttpService, "_get_durable_execution_history_handler")
1354+
def test_get_durable_execution_history_routes_arn_with_slash(self, handler_mock):
1355+
handler_mock.return_value = ("ok", 200)
1356+
service = self._build_service()
1357+
client = service._app.test_client()
1358+
1359+
response = client.get(f"/2025-12-01/durable-executions/{self.ENCODED_SLASH_ARN}/history")
1360+
1361+
self.assertEqual(response.status_code, 200)
1362+
# Trailing literal /history must win over the bare <path:arn> rule.
1363+
handler_mock.assert_called_once_with(durable_execution_arn=self.SLASH_ARN)
1364+
1365+
@patch.object(LocalLambdaHttpService, "_stop_durable_execution_handler")
1366+
def test_stop_durable_execution_routes_arn_with_slash(self, handler_mock):
1367+
handler_mock.return_value = ("ok", 200)
1368+
service = self._build_service()
1369+
client = service._app.test_client()
1370+
1371+
response = client.post(f"/2025-12-01/durable-executions/{self.ENCODED_SLASH_ARN}/stop")
1372+
1373+
self.assertEqual(response.status_code, 200)
1374+
handler_mock.assert_called_once_with(durable_execution_arn=self.SLASH_ARN)
1375+
1376+
@parameterized.expand(
1377+
[
1378+
("succeed", "_send_callback_success_handler"),
1379+
("fail", "_send_callback_failure_handler"),
1380+
("heartbeat", "_send_callback_heartbeat_handler"),
1381+
]
1382+
)
1383+
def test_callback_routes_id_with_slash(self, action, handler_attr):
1384+
# Base64 callback IDs contain '/' which boto percent-encodes the same way.
1385+
decoded_id = "abc/def=="
1386+
encoded_id = "abc%2Fdef=="
1387+
with patch.object(LocalLambdaHttpService, handler_attr) as handler_mock:
1388+
handler_mock.return_value = ("ok", 200)
1389+
service = self._build_service()
1390+
client = service._app.test_client()
1391+
1392+
response = client.post(f"/2025-12-01/durable-execution-callbacks/{encoded_id}/{action}")
1393+
1394+
self.assertEqual(response.status_code, 200)
1395+
handler_mock.assert_called_once_with(callback_id=decoded_id)
1396+
1397+
@patch.object(LocalLambdaHttpService, "_get_durable_execution_handler")
1398+
def test_get_durable_execution_still_matches_uuid_only_arn(self, handler_mock):
1399+
# Backwards-compatibility: the old single-UUID form must still route.
1400+
handler_mock.return_value = ("ok", 200)
1401+
service = self._build_service()
1402+
client = service._app.test_client()
1403+
1404+
legacy_arn = "9a1bc86c-40b9-4688-86b4-d7ecaca41579"
1405+
response = client.get(f"/2025-12-01/durable-executions/{legacy_arn}")
1406+
1407+
self.assertEqual(response.status_code, 200)
1408+
handler_mock.assert_called_once_with(durable_execution_arn=legacy_arn)

0 commit comments

Comments
 (0)