Skip to content

Commit 4860576

Browse files
committed
feat: support for local Event invocation type
1 parent 75bc6c4 commit 4860576

10 files changed

Lines changed: 437 additions & 70 deletions

File tree

samcli/commands/local/lib/local_lambda.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,43 @@ def __init__(
9696
self.container_host_interface = container_host_interface
9797
self.extra_hosts = extra_hosts
9898

99+
def get_function(self, function_identifier: str) -> Function:
100+
"""
101+
Get a Lambda function by identifier, raising FunctionNotFound if not found.
102+
103+
Parameters
104+
----------
105+
function_identifier : str
106+
Identifier of the Lambda function, it can be logicalID, function name or full path
107+
108+
Returns
109+
-------
110+
Function
111+
The Lambda function configuration
112+
113+
Raises
114+
------
115+
InvalidFunctionNameException
116+
When the function identifier doesn't match AWS Lambda's validation pattern
117+
FunctionNotFound
118+
When we cannot find a function with the given identifier
119+
"""
120+
# Normalize function identifier from ARN if provided
121+
normalized_function_identifier = normalize_sam_function_identifier(function_identifier)
122+
123+
# Generate the correct configuration based on given inputs
124+
function = self.provider.get(normalized_function_identifier)
125+
126+
if not function:
127+
all_function_full_paths = [f.full_path for f in self.provider.get_all()]
128+
available_function_message = "{} not found. Possible options in your template: {}".format(
129+
function_identifier, all_function_full_paths
130+
)
131+
LOG.info(available_function_message)
132+
raise FunctionNotFound("Unable to find a Function with name '{}'".format(function_identifier))
133+
134+
return function
135+
99136
def invoke(
100137
self,
101138
function_identifier: str,
@@ -138,19 +175,8 @@ def invoke(
138175
FunctionNotfound
139176
When we cannot find a function with the given name
140177
"""
141-
# Normalize function identifier from ARN if provided
142-
normalized_function_identifier = normalize_sam_function_identifier(function_identifier)
143-
144-
# Generate the correct configuration based on given inputs
145-
function = self.provider.get(normalized_function_identifier)
146-
147-
if not function:
148-
all_function_full_paths = [f.full_path for f in self.provider.get_all()]
149-
available_function_message = "{} not found. Possible options in your template: {}".format(
150-
function_identifier, all_function_full_paths
151-
)
152-
LOG.info(available_function_message)
153-
raise FunctionNotFound("Unable to find a Function with name '{}'".format(function_identifier))
178+
# Get the function configuration
179+
function = self.get_function(function_identifier)
154180

155181
LOG.debug("Found one Lambda function with name '%s'", function_identifier)
156182
if function.packagetype == ZIP:

samcli/local/lambda_service/lambda_error_responses.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import json
44
from collections import OrderedDict
5+
from typing import Any, Dict
6+
7+
from flask import Response
58

69
from samcli.local.services.base_local_service import BaseLocalService
710

@@ -40,7 +43,7 @@ class LambdaErrorResponses:
4043
CONTENT_TYPE_HEADER_KEY = "Content-Type"
4144

4245
@staticmethod
43-
def resource_not_found(function_name):
46+
def resource_not_found(function_name: str) -> Response:
4447
"""
4548
Creates a Lambda Service ResourceNotFound Response
4649
@@ -66,7 +69,7 @@ def resource_not_found(function_name):
6669
)
6770

6871
@staticmethod
69-
def invalid_request_content(message):
72+
def invalid_request_content(message: str) -> Response:
7073
"""
7174
Creates a Lambda Service InvalidRequestContent Response
7275
@@ -89,7 +92,7 @@ def invalid_request_content(message):
8992
)
9093

9194
@staticmethod
92-
def validation_exception(message):
95+
def validation_exception(message: str) -> Response:
9396
"""
9497
Creates a Lambda Service ValidationException Response
9598
@@ -112,7 +115,7 @@ def validation_exception(message):
112115
)
113116

114117
@staticmethod
115-
def unsupported_media_type(content_type):
118+
def unsupported_media_type(content_type: str) -> Response:
116119
"""
117120
Creates a Lambda Service UnsupportedMediaType Response
118121
@@ -137,7 +140,7 @@ def unsupported_media_type(content_type):
137140
)
138141

139142
@staticmethod
140-
def generic_service_exception(*args):
143+
def generic_service_exception(*args: Any) -> Response:
141144
"""
142145
Creates a Lambda Service Generic ServiceException Response
143146
@@ -160,7 +163,7 @@ def generic_service_exception(*args):
160163
)
161164

162165
@staticmethod
163-
def not_implemented_locally(message):
166+
def not_implemented_locally(message: str) -> Response:
164167
"""
165168
Creates a Lambda Service NotImplementedLocally Response
166169
@@ -183,7 +186,7 @@ def not_implemented_locally(message):
183186
)
184187

185188
@staticmethod
186-
def generic_path_not_found(*args):
189+
def generic_path_not_found(*args: Any) -> Response:
187190
"""
188191
Creates a Lambda Service Generic PathNotFound Response
189192
@@ -208,7 +211,7 @@ def generic_path_not_found(*args):
208211
)
209212

210213
@staticmethod
211-
def generic_method_not_allowed(*args):
214+
def generic_method_not_allowed(*args: Any) -> Response:
212215
"""
213216
Creates a Lambda Service Generic MethodNotAllowed Response
214217
@@ -233,13 +236,13 @@ def generic_method_not_allowed(*args):
233236
)
234237

235238
@staticmethod
236-
def container_creation_failed(message):
239+
def container_creation_failed(message: str) -> Response:
237240
"""
238241
Creates a Container Creation Failed response
239242
Parameters
240243
----------
241-
args list
242-
List of arguments Flask passes to the method
244+
message str
245+
Message to be added to the body of the response
243246
Returns
244247
-------
245248
Flask.Response
@@ -256,7 +259,7 @@ def container_creation_failed(message):
256259
)
257260

258261
@staticmethod
259-
def _construct_error_response_body(error_type, error_message):
262+
def _construct_error_response_body(error_type: str, error_message: str) -> str:
260263
"""
261264
Constructs a string to be used in the body of the Response that conforms
262265
to the structure of the Lambda Service Responses
@@ -278,7 +281,7 @@ def _construct_error_response_body(error_type, error_message):
278281

279282
# Durable Functions Error Responses
280283
@staticmethod
281-
def durable_execution_not_found(execution_arn):
284+
def durable_execution_not_found(execution_arn: str) -> Response:
282285
"""Creates a ResourceNotFound response for durable executions"""
283286
exception_tuple = LambdaErrorResponses.ResourceNotFoundException
284287
return BaseLocalService.service_response(
@@ -290,7 +293,7 @@ def durable_execution_not_found(execution_arn):
290293
)
291294

292295
@staticmethod
293-
def _construct_headers(error_type):
296+
def _construct_headers(error_type: str) -> Dict[str, str]:
294297
"""
295298
Constructs Headers for the Local Lambda Error Response
296299

samcli/local/lambda_service/local_lambda_http_service.py

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
import io
44
import json
55
import logging
6+
from concurrent.futures import ThreadPoolExecutor
67
from datetime import datetime
8+
from typing import Dict, Optional, Tuple
79
from urllib.parse import unquote
810

9-
from flask import Flask, request
11+
from flask import Flask, Response, request
1012
from werkzeug.routing import BaseConverter
1113

1214
from samcli.commands.local.cli_common.durable_context import DurableContext
1315
from samcli.commands.local.lib.exceptions import TenantIdValidationError, UnsupportedInlineCodeError
16+
from samcli.lib.utils.invocation_type import EVENT
1417
from samcli.lib.utils.name_utils import InvalidFunctionNameException, normalize_sam_function_identifier
1518
from samcli.lib.utils.stream_writer import StreamWriter
1619
from samcli.local.docker.exceptions import DockerContainerCreationFailedException
@@ -67,6 +70,7 @@ def __init__(self, lambda_runner, port, host, stderr=None, ssl_context=None):
6770
super().__init__(lambda_runner.is_debugging(), port=port, host=host, ssl_context=ssl_context)
6871
self.lambda_runner = lambda_runner
6972
self.stderr = stderr
73+
self.executor = ThreadPoolExecutor()
7074

7175
def create(self):
7276
"""
@@ -240,32 +244,36 @@ def _invoke_request_handler(self, function_name):
240244
# Extract durable execution name from headers
241245
durable_execution_name = flask_request.headers.get("X-Amz-Durable-Execution-Name")
242246

243-
stdout_stream_string = io.StringIO()
244-
stdout_stream_bytes = io.BytesIO()
245-
stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True)
247+
arguments = {
248+
"function_name": function_name,
249+
"request_data": request_data,
250+
"invocation_type": invocation_type,
251+
"durable_execution_name": durable_execution_name,
252+
"tenant_id": tenant_id,
253+
}
254+
255+
headers = {"Content-Type": "application/json"}
256+
257+
if invocation_type == EVENT:
258+
# Validate function exists before submitting async task
259+
if validation_error := self._validate_function_for_invocation(function_name):
260+
return validation_error
261+
262+
self.executor.submit(self._invoke_async_lambda, **arguments)
263+
return self.service_response("", headers, 202)
246264

247265
try:
248-
# Normalize function name from ARN if provided
249-
normalized_function_name = normalize_sam_function_identifier(function_name)
250-
251-
invoke_headers = self.lambda_runner.invoke(
252-
normalized_function_name,
253-
request_data,
254-
invocation_type=invocation_type,
255-
durable_execution_name=durable_execution_name,
256-
tenant_id=tenant_id,
257-
stdout=stdout_stream_writer,
258-
stderr=self.stderr,
259-
)
266+
invoke_headers, stdout_stream_string, stdout_stream_bytes = self._invoke_lambda(**arguments)
260267
except (InvalidFunctionNameException, TenantIdValidationError) as e:
261268
LOG.error("Validation error: %s", str(e))
262269
return LambdaErrorResponses.validation_exception(str(e))
263270
except UnsupportedInvocationType as e:
264-
LOG.warning("invocation-type: %s is not supported. RequestResponse is only supported.", invocation_type)
271+
LOG.warning(
272+
"invocation-type: %s is not supported. Event and RequestResponse are only supported.", invocation_type
273+
)
265274
return LambdaErrorResponses.not_implemented_locally(str(e))
266275
except FunctionNotFound:
267-
LOG.debug("%s was not found to invoke.", normalized_function_name)
268-
return LambdaErrorResponses.resource_not_found(normalized_function_name)
276+
return self._handle_function_not_found(function_name)
269277
except UnsupportedInlineCodeError:
270278
return LambdaErrorResponses.not_implemented_locally(
271279
"Inline code is not supported for sam local commands. Please write your code in a separate file."
@@ -278,20 +286,94 @@ def _invoke_request_handler(self, function_name):
278286
)
279287

280288
# Prepare headers
281-
headers = {"Content-Type": "application/json"}
282289
if invoke_headers and isinstance(invoke_headers, dict):
283290
headers.update(invoke_headers)
284291

285292
if is_lambda_user_error_response:
286293
headers["x-amz-function-error"] = "Unhandled"
287-
return self.service_response(lambda_response, headers, 200)
288-
289-
# For async invocations (Event type), return 202
290-
if invocation_type == "Event":
291-
return self.service_response("", headers, 202)
292294

293295
return self.service_response(lambda_response, headers, 200)
294296

297+
def _validate_function_for_invocation(self, function_name: str) -> Optional[Response]:
298+
"""
299+
Validates that a function exists and can be invoked.
300+
301+
Parameters
302+
----------
303+
function_name : str
304+
Name or ARN of the function to validate
305+
306+
Returns
307+
-------
308+
Flask.Response or None
309+
Error response if validation fails, None if validation succeeds
310+
"""
311+
try:
312+
self.lambda_runner.get_function(function_name)
313+
return None
314+
except FunctionNotFound:
315+
return self._handle_function_not_found(function_name)
316+
except InvalidFunctionNameException as e:
317+
LOG.error("Validation error: %s", str(e))
318+
return LambdaErrorResponses.validation_exception(str(e))
319+
320+
def _handle_function_not_found(self, function_name: str) -> Response:
321+
"""
322+
Handles FunctionNotFound exception by returning appropriate error response.
323+
324+
Parameters
325+
----------
326+
function_name : str
327+
Name or ARN of the function that was not found
328+
329+
Returns
330+
-------
331+
Flask.Response
332+
Error response for function not found
333+
"""
334+
normalized_function_name = normalize_sam_function_identifier(function_name)
335+
LOG.debug("%s was not found to invoke.", normalized_function_name)
336+
return LambdaErrorResponses.resource_not_found(normalized_function_name)
337+
338+
def _invoke_async_lambda(self, function_name: str, **kwargs) -> None:
339+
"""
340+
Wrapper for _invoke_lambda that runs in an async context (Event invocation type)
341+
"""
342+
try:
343+
self._invoke_lambda(function_name=function_name, **kwargs)
344+
except Exception as e:
345+
LOG.error("Async invocation failed for function %s: %s", function_name, str(e), exc_info=True)
346+
347+
def _invoke_lambda(
348+
self,
349+
function_name: str,
350+
request_data: str,
351+
invocation_type: str,
352+
durable_execution_name: Optional[str],
353+
tenant_id: Optional[str],
354+
) -> Tuple[Optional[Dict[str, str]], io.StringIO, io.BytesIO]:
355+
"""
356+
Invokes a Lambda function and returns the result
357+
"""
358+
359+
stdout_stream_string = io.StringIO()
360+
stdout_stream_bytes = io.BytesIO()
361+
stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True)
362+
363+
normalized_function_name = normalize_sam_function_identifier(function_name)
364+
365+
invoke_headers = self.lambda_runner.invoke(
366+
normalized_function_name,
367+
request_data,
368+
invocation_type=invocation_type,
369+
durable_execution_name=durable_execution_name,
370+
tenant_id=tenant_id,
371+
stdout=stdout_stream_writer,
372+
stderr=self.stderr,
373+
)
374+
375+
return invoke_headers, stdout_stream_string, stdout_stream_bytes
376+
295377
def _get_durable_execution_handler(self, durable_execution_arn):
296378
"""
297379
Handler for GET /2025-12-01/durable-executions/{DurableExecutionArn}

samcli/local/lambdafn/runtime.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from samcli.lib.telemetry.metric import capture_parameter
1515
from samcli.lib.utils.file_observer import LambdaFunctionObserver
16+
from samcli.lib.utils.invocation_type import EVENT, REQUEST_RESPONSE
1617
from samcli.lib.utils.packagetype import ZIP
1718
from samcli.local.docker.container import Container, ContainerContext
1819
from samcli.local.docker.container_analyzer import ContainerAnalyzer
@@ -305,9 +306,10 @@ def invoke(
305306
)
306307
else:
307308
# Only RequestResponse supported for regular Lambda functions
308-
if invocation_type != "RequestResponse":
309+
if invocation_type not in [EVENT, REQUEST_RESPONSE]:
309310
raise UnsupportedInvocationType(
310-
f"invocation-type: {invocation_type} is not supported. RequestResponse is only supported."
311+
f"invocation-type: {invocation_type} is not supported. "
312+
"Event and RequestResponse are only supported."
311313
)
312314

313315
# The container handles concurrency control internally via its semaphore.

0 commit comments

Comments
 (0)