Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions samcli/commands/local/lib/local_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,43 @@ def __init__(
self.container_host_interface = container_host_interface
self.extra_hosts = extra_hosts

def get_function(self, function_identifier: str) -> Function:
"""
Get a Lambda function by identifier, raising FunctionNotFound if not found.

Parameters
----------
function_identifier : str
Identifier of the Lambda function, it can be logicalID, function name or full path

Returns
-------
Function
The Lambda function configuration

Raises
------
InvalidFunctionNameException
When the function identifier doesn't match AWS Lambda's validation pattern
FunctionNotFound
When we cannot find a function with the given identifier
"""
# Normalize function identifier from ARN if provided
normalized_function_identifier = normalize_sam_function_identifier(function_identifier)

# Generate the correct configuration based on given inputs
function = self.provider.get(normalized_function_identifier)

if not function:
all_function_full_paths = [f.full_path for f in self.provider.get_all()]
available_function_message = "{} not found. Possible options in your template: {}".format(
function_identifier, all_function_full_paths
)
LOG.info(available_function_message)
raise FunctionNotFound("Unable to find a Function with name '{}'".format(function_identifier))

return function

def invoke(
self,
function_identifier: str,
Expand Down Expand Up @@ -138,19 +175,8 @@ def invoke(
FunctionNotfound
When we cannot find a function with the given name
"""
# Normalize function identifier from ARN if provided
normalized_function_identifier = normalize_sam_function_identifier(function_identifier)

# Generate the correct configuration based on given inputs
function = self.provider.get(normalized_function_identifier)

if not function:
all_function_full_paths = [f.full_path for f in self.provider.get_all()]
available_function_message = "{} not found. Possible options in your template: {}".format(
function_identifier, all_function_full_paths
)
LOG.info(available_function_message)
raise FunctionNotFound("Unable to find a Function with name '{}'".format(function_identifier))
# Get the function configuration
function = self.get_function(function_identifier)

LOG.debug("Found one Lambda function with name '%s'", function_identifier)
if function.packagetype == ZIP:
Expand Down
31 changes: 17 additions & 14 deletions samcli/local/lambda_service/lambda_error_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import json
from collections import OrderedDict
from typing import Any, Dict

from flask import Response

from samcli.local.services.base_local_service import BaseLocalService

Expand Down Expand Up @@ -40,7 +43,7 @@ class LambdaErrorResponses:
CONTENT_TYPE_HEADER_KEY = "Content-Type"

@staticmethod
def resource_not_found(function_name):
def resource_not_found(function_name: str) -> Response:
"""
Creates a Lambda Service ResourceNotFound Response

Expand All @@ -66,7 +69,7 @@ def resource_not_found(function_name):
)

@staticmethod
def invalid_request_content(message):
def invalid_request_content(message: str) -> Response:
"""
Creates a Lambda Service InvalidRequestContent Response

Expand All @@ -89,7 +92,7 @@ def invalid_request_content(message):
)

@staticmethod
def validation_exception(message):
def validation_exception(message: str) -> Response:
"""
Creates a Lambda Service ValidationException Response

Expand All @@ -112,7 +115,7 @@ def validation_exception(message):
)

@staticmethod
def unsupported_media_type(content_type):
def unsupported_media_type(content_type: str) -> Response:
"""
Creates a Lambda Service UnsupportedMediaType Response

Expand All @@ -137,7 +140,7 @@ def unsupported_media_type(content_type):
)

@staticmethod
def generic_service_exception(*args):
def generic_service_exception(*args: Any) -> Response:
"""
Creates a Lambda Service Generic ServiceException Response

Expand All @@ -160,7 +163,7 @@ def generic_service_exception(*args):
)

@staticmethod
def not_implemented_locally(message):
def not_implemented_locally(message: str) -> Response:
"""
Creates a Lambda Service NotImplementedLocally Response

Expand All @@ -183,7 +186,7 @@ def not_implemented_locally(message):
)

@staticmethod
def generic_path_not_found(*args):
def generic_path_not_found(*args: Any) -> Response:
"""
Creates a Lambda Service Generic PathNotFound Response

Expand All @@ -208,7 +211,7 @@ def generic_path_not_found(*args):
)

@staticmethod
def generic_method_not_allowed(*args):
def generic_method_not_allowed(*args: Any) -> Response:
"""
Creates a Lambda Service Generic MethodNotAllowed Response

Expand All @@ -233,13 +236,13 @@ def generic_method_not_allowed(*args):
)

@staticmethod
def container_creation_failed(message):
def container_creation_failed(message: str) -> Response:
"""
Creates a Container Creation Failed response
Parameters
----------
args list
List of arguments Flask passes to the method
message str
Message to be added to the body of the response
Returns
-------
Flask.Response
Expand All @@ -256,7 +259,7 @@ def container_creation_failed(message):
)

@staticmethod
def _construct_error_response_body(error_type, error_message):
def _construct_error_response_body(error_type: str, error_message: str) -> str:
"""
Constructs a string to be used in the body of the Response that conforms
to the structure of the Lambda Service Responses
Expand All @@ -278,7 +281,7 @@ def _construct_error_response_body(error_type, error_message):

# Durable Functions Error Responses
@staticmethod
def durable_execution_not_found(execution_arn):
def durable_execution_not_found(execution_arn: str) -> Response:
"""Creates a ResourceNotFound response for durable executions"""
exception_tuple = LambdaErrorResponses.ResourceNotFoundException
return BaseLocalService.service_response(
Expand All @@ -290,7 +293,7 @@ def durable_execution_not_found(execution_arn):
)

@staticmethod
def _construct_headers(error_type):
def _construct_headers(error_type: str) -> Dict[str, str]:
"""
Constructs Headers for the Local Lambda Error Response

Expand Down
132 changes: 107 additions & 25 deletions samcli/local/lambda_service/local_lambda_http_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
import io
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Dict, Optional, Tuple
from urllib.parse import unquote

from flask import Flask, request
from flask import Flask, Response, request
from werkzeug.routing import BaseConverter

from samcli.commands.local.cli_common.durable_context import DurableContext
from samcli.commands.local.lib.exceptions import TenantIdValidationError, UnsupportedInlineCodeError
from samcli.lib.utils.invocation_type import EVENT
from samcli.lib.utils.name_utils import InvalidFunctionNameException, normalize_sam_function_identifier
from samcli.lib.utils.stream_writer import StreamWriter
from samcli.local.docker.exceptions import DockerContainerCreationFailedException
Expand Down Expand Up @@ -67,6 +70,7 @@ def __init__(self, lambda_runner, port, host, stderr=None, ssl_context=None):
super().__init__(lambda_runner.is_debugging(), port=port, host=host, ssl_context=ssl_context)
self.lambda_runner = lambda_runner
self.stderr = stderr
self.executor = ThreadPoolExecutor()

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

stdout_stream_string = io.StringIO()
stdout_stream_bytes = io.BytesIO()
stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True)
arguments = {
"function_name": function_name,
"request_data": request_data,
"invocation_type": invocation_type,
"durable_execution_name": durable_execution_name,
"tenant_id": tenant_id,
}

headers = {"Content-Type": "application/json"}

if invocation_type == EVENT:
# Validate function exists before submitting async task
if validation_error := self._validate_function_for_invocation(function_name):
return validation_error

self.executor.submit(self._invoke_async_lambda, **arguments)
return self.service_response("", headers, 202)

try:
# Normalize function name from ARN if provided
normalized_function_name = normalize_sam_function_identifier(function_name)

invoke_headers = self.lambda_runner.invoke(
normalized_function_name,
request_data,
invocation_type=invocation_type,
durable_execution_name=durable_execution_name,
tenant_id=tenant_id,
stdout=stdout_stream_writer,
stderr=self.stderr,
)
invoke_headers, stdout_stream_string, stdout_stream_bytes = self._invoke_lambda(**arguments)
except (InvalidFunctionNameException, TenantIdValidationError) as e:
LOG.error("Validation error: %s", str(e))
return LambdaErrorResponses.validation_exception(str(e))
except UnsupportedInvocationType as e:
LOG.warning("invocation-type: %s is not supported. RequestResponse is only supported.", invocation_type)
LOG.warning(
"invocation-type: %s is not supported. Event and RequestResponse are only supported.", invocation_type
)
return LambdaErrorResponses.not_implemented_locally(str(e))
except FunctionNotFound:
LOG.debug("%s was not found to invoke.", normalized_function_name)
return LambdaErrorResponses.resource_not_found(normalized_function_name)
return self._handle_function_not_found(function_name)
except UnsupportedInlineCodeError:
return LambdaErrorResponses.not_implemented_locally(
"Inline code is not supported for sam local commands. Please write your code in a separate file."
Expand All @@ -278,20 +286,94 @@ def _invoke_request_handler(self, function_name):
)

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

if is_lambda_user_error_response:
headers["x-amz-function-error"] = "Unhandled"
return self.service_response(lambda_response, headers, 200)

# For async invocations (Event type), return 202
if invocation_type == "Event":
return self.service_response("", headers, 202)

return self.service_response(lambda_response, headers, 200)

def _validate_function_for_invocation(self, function_name: str) -> Optional[Response]:
"""
Validates that a function exists and can be invoked.

Parameters
----------
function_name : str
Name or ARN of the function to validate

Returns
-------
Flask.Response or None
Error response if validation fails, None if validation succeeds
"""
try:
self.lambda_runner.get_function(function_name)
return None
except FunctionNotFound:
return self._handle_function_not_found(function_name)
except InvalidFunctionNameException as e:
LOG.error("Validation error: %s", str(e))
return LambdaErrorResponses.validation_exception(str(e))

def _handle_function_not_found(self, function_name: str) -> Response:
"""
Handles FunctionNotFound exception by returning appropriate error response.

Parameters
----------
function_name : str
Name or ARN of the function that was not found

Returns
-------
Flask.Response
Error response for function not found
"""
normalized_function_name = normalize_sam_function_identifier(function_name)
LOG.debug("%s was not found to invoke.", normalized_function_name)
return LambdaErrorResponses.resource_not_found(normalized_function_name)

def _invoke_async_lambda(self, function_name: str, **kwargs) -> None:
"""
Wrapper for _invoke_lambda that runs in an async context (Event invocation type)
"""
try:
self._invoke_lambda(function_name=function_name, **kwargs)
except Exception as e:
LOG.error("Async invocation failed for function %s: %s", function_name, str(e), exc_info=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It makes sense to sink the exceptions like you're doing here, but how does this appear for the user? Do you have a screenshot of what it looks like when you try async invoking a function that has a syntax error (so the sandbox crashes)?

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.

image

There's two cases, one if the invocation fails, with the exception above. Then the case if the user's code raises an exception seen below, where it will log the exception from the container.

image image


def _invoke_lambda(
self,
function_name: str,
request_data: str,
invocation_type: str,
durable_execution_name: Optional[str],
tenant_id: Optional[str],
) -> Tuple[Optional[Dict[str, str]], io.StringIO, io.BytesIO]:
"""
Invokes a Lambda function and returns the result
"""

stdout_stream_string = io.StringIO()
stdout_stream_bytes = io.BytesIO()
stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True)

normalized_function_name = normalize_sam_function_identifier(function_name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we call the get_function() utility you added here instead?


invoke_headers = self.lambda_runner.invoke(
normalized_function_name,
request_data,
invocation_type=invocation_type,
durable_execution_name=durable_execution_name,
tenant_id=tenant_id,
stdout=stdout_stream_writer,
stderr=self.stderr,
)

return invoke_headers, stdout_stream_string, stdout_stream_bytes

def _get_durable_execution_handler(self, durable_execution_arn):
"""
Handler for GET /2025-12-01/durable-executions/{DurableExecutionArn}
Expand Down
6 changes: 4 additions & 2 deletions samcli/local/lambdafn/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from samcli.lib.telemetry.metric import capture_parameter
from samcli.lib.utils.file_observer import LambdaFunctionObserver
from samcli.lib.utils.invocation_type import EVENT, REQUEST_RESPONSE
from samcli.lib.utils.packagetype import ZIP
from samcli.local.docker.container import Container, ContainerContext
from samcli.local.docker.container_analyzer import ContainerAnalyzer
Expand Down Expand Up @@ -305,9 +306,10 @@ def invoke(
)
else:
# Only RequestResponse supported for regular Lambda functions
if invocation_type != "RequestResponse":
if invocation_type not in [EVENT, REQUEST_RESPONSE]:
raise UnsupportedInvocationType(
f"invocation-type: {invocation_type} is not supported. RequestResponse is only supported."
f"invocation-type: {invocation_type} is not supported. "
"Event and RequestResponse are only supported."
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
f"invocation-type: {invocation_type} is not supported. "
"Event and RequestResponse are only supported."
f"invocation-type: {invocation_type} is not supported. "
"Only Event and RequestResponse are supported"

)

# The container handles concurrency control internally via its semaphore.
Expand Down
Loading
Loading