Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ def _process_publish_event_sync_resolver(
try:
result_return = resolver["func"](payload=event.get("payload"))
results.append({"id": event.get("id"), "payload": result_return})
except UnauthorizedException:
# Authorization failures must fail the whole request, same as the aggregate path
raise
except Exception as error:
logger.debug(f"Failed to process event number {idx}")
error_return = {"id": event.get("id"), "error": self._format_error_response(error)}
Expand Down Expand Up @@ -314,6 +317,12 @@ async def _call_publish_event_async_resolver(
# this will become useful when we support exception handling in AppSync resolver
# Aggregate results and exceptions, then filter them out
results = await asyncio.gather(*tasks, return_exceptions=True)

# Authorization failures must fail the whole request, same as the aggregate path
for result in results:
if isinstance(result, UnauthorizedException):
raise result

response_async.extend(
[
(
Expand Down
19 changes: 16 additions & 3 deletions aws_lambda_powertools/event_handler/middlewares/async_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ def run_middleware() -> None:
# If middleware raised before calling next, propagate immediately
if not next_app_holder:
thread.join()
raise middleware_error_holder[0]
if middleware_error_holder:
raise middleware_error_holder[0]
# Middleware short-circuited without calling next: return its own response
return middleware_result_holder[0] if middleware_result_holder else None

# Resolve the async next_handler on the event-loop
real_response = await next_handler(next_app_holder[0])
Expand Down Expand Up @@ -159,8 +162,18 @@ async def __call__(self, app: ApiGatewayResolver) -> dict | tuple | Response:

extra_kwargs = _resolve_extra_middleware_kwargs(app, self.current_middleware)

if inspect.iscoroutinefunction(self.current_middleware):
return await self.current_middleware(app, self.next_middleware, **extra_kwargs)
# Class-based middlewares expose their logic through `handler`, which may be a coroutine
# function even though the instance itself is not
middleware_handler = getattr(self.current_middleware, "handler", None)
is_async_middleware = inspect.iscoroutinefunction(
self.current_middleware,
) or inspect.iscoroutinefunction(middleware_handler)

if is_async_middleware:
result = self.current_middleware(app, self.next_middleware, **extra_kwargs)
if inspect.iscoroutine(result):
result = await result
return result

loop = asyncio.get_running_loop()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import pytest

from aws_lambda_powertools.event_handler import AppSyncEventsResolver
from aws_lambda_powertools.event_handler.events_appsync.exceptions import UnauthorizedException
from tests.functional.utils import load_event


class LambdaContext:
def __init__(self):
self.function_name = "test-func"
self.memory_limit_in_mb = 128
self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"

def get_remaining_time_in_millis(self) -> int:
return 1000


@pytest.fixture(scope="module")
def lambda_context() -> LambdaContext:
return LambdaContext()


@pytest.fixture(scope="module")
def mock_event():
return load_event("appSyncEventsEvent.json")


def test_publish_events_throw_unauthorized_exception_without_aggregate(lambda_context, mock_event):
"""UnauthorizedException raised in a per-event resolver must not be swallowed.

When aggregate=False, each event is handled independently and any other exception is
turned into a per-event error payload. Authorization failures are the exception: they
must fail the whole request, otherwise a denied caller receives a successful response.
"""
# GIVEN a publish event with a single event
mock_event["info"]["operation"] = "PUBLISH"
mock_event["info"]["channel"]["path"] = "/default/test"
mock_event["events"] = [
{"id": "123", "payload": {"data": "test data"}},
]

# GIVEN an AppSyncEventsResolver with a per-event resolver that denies access
app = AppSyncEventsResolver()

@app.on_publish(path="/default/*")
def handle_events(payload):
raise UnauthorizedException

# WHEN we resolve the event
# THEN the exception must propagate to the caller
with pytest.raises(UnauthorizedException):
app.resolve(mock_event, lambda_context)


def test_async_publish_events_throw_unauthorized_exception_without_aggregate(lambda_context, mock_event):
"""UnauthorizedException raised in an async per-event resolver must not be swallowed."""
# GIVEN a publish event with a single event
mock_event["info"]["operation"] = "PUBLISH"
mock_event["info"]["channel"]["path"] = "/default/test"
mock_event["events"] = [
{"id": "123", "payload": {"data": "test data"}},
]

# GIVEN an AppSyncEventsResolver with an async per-event resolver that denies access
app = AppSyncEventsResolver()

@app.async_on_publish(path="/default/*")
async def handle_events(payload):
raise UnauthorizedException

# WHEN we resolve the event
# THEN the exception must propagate to the caller
with pytest.raises(UnauthorizedException):
app.resolve(mock_event, lambda_context)


def test_publish_events_throw_unauthorized_exception_with_multiple_events(lambda_context, mock_event):
"""A single denied event must fail the whole batch, even when other events succeed."""
# GIVEN a publish event with two events
mock_event["info"]["operation"] = "PUBLISH"
mock_event["info"]["channel"]["path"] = "/default/test"
mock_event["events"] = [
{"id": "123", "payload": {"data": "test data"}},
{"id": "456", "payload": {"data": "more test data"}},
]

# GIVEN an AppSyncEventsResolver that allows the first event and denies the second one
app = AppSyncEventsResolver()
calls = []

@app.on_publish(path="/default/*")
def handle_events(payload):
calls.append(payload)
if len(calls) == 2:
raise UnauthorizedException
return {"processed": True}

# WHEN we resolve the event
# THEN the exception must propagate to the caller
with pytest.raises(UnauthorizedException):
app.resolve(mock_event, lambda_context)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
ProxyEventType,
Response,
)
from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler, NextMiddleware
from aws_lambda_powertools.event_handler.middlewares.async_utils import AsyncMiddlewareFrame, wrap_middleware_async
from tests.functional.utils import load_event

Expand Down Expand Up @@ -87,3 +87,47 @@ async def next_handler(app: ApiGatewayResolver):
# THEN the exception propagates
with pytest.raises(ValidationError, match="invalid request"):
asyncio.run(frame(app))


class AsyncClassMiddleware(BaseMiddlewareHandler):
async def handler(self, app: ApiGatewayResolver, next_middleware: NextMiddleware) -> Response:
response = await next_middleware(app)
response.headers["x-async-middleware"] = "true"
return response


def test_class_based_async_middleware_is_awaited():
# GIVEN a class-based middleware whose handler is a coroutine function
app = _make_app()

async def next_handler(app: ApiGatewayResolver):
await asyncio.sleep(0)
return Response(200, content_types.TEXT_HTML, "ok")

frame = AsyncMiddlewareFrame(current_middleware=AsyncClassMiddleware(), next_middleware=next_handler)

# WHEN calling the frame
response = asyncio.run(frame(app))

# THEN the handler is awaited instead of returning a coroutine
assert isinstance(response, Response)
assert response.headers["x-async-middleware"] == "true"


def test_wrap_middleware_async_sync_short_circuit_returns_response():
# GIVEN a sync middleware that returns a response without calling next()
app = _make_app()

def short_circuit_middleware(app, next_middleware):
return Response(401, content_types.APPLICATION_JSON, '{"message": "denied"}')

async def next_handler(app):
raise AssertionError("next middleware should not be called")

wrapped = wrap_middleware_async(short_circuit_middleware, next_handler)

# WHEN calling the wrapped middleware
response = asyncio.run(wrapped(app))

# THEN its own response is returned instead of raising IndexError
assert response.status_code == 401
Loading