Repository: litestar-org/litestar

Issue:
Bug: Exception in event listener breaks listener
### Description

In a separate production code base, we encountered what I believe to be an issue/behavior that could be fixed/improved and did not find anything other references of it when searching the issues here. 

Basically, if an exception occurs inside of an event listener you will then see
```log
INFO:     ASGI 'lifespan' protocol appears unsupported.
```
 and the listener stream is closed which then prevents any additional events from being picked up. Any time `request.app.emit` is then called you will get `ClosedResourceError`.

I don't think a single exception inside an event execution should prevent all future events from getting picked up or at least the error should be a little more obvious as to what is going on.

I've created an example repo that recreates the issue consistently.

### URL to code causing the issue

https://github.com/bnjmn/litestar-emit-fail-example

### MCVE

See [full example here](https://github.com/bnjmn/litestar-emit-fail-example/tree/main)
```python
from litestar import Litestar, get, Request
from litestar.events import listener

import logging

logger = logging.getLogger(__name__)

@listener("raise_exception")
async def raise_exception_if_odd(value) -> None:
    """Raise an exception to test Emit error."""
    if value is not None and value % 2 != 0:
        raise ValueError(f"{value} is odd")
    else:
        return "The value is even. No exception raised."

@get("/")
async def index() -> str:
    return "Hello, world!"

@get("/check-value/{value:int}")
async def check_value(request: Request, value: int) -> str:
    try:
        request.app.emit("raise_exception", value)
        return f"Checked {value}: No exception raised."
    except ValueError as e:
        return str(e)

app = Litestar([index, check_value], listeners=[raise_exception_if_odd])
```


### Steps to reproduce

1. Follow the steps outlined in the [README](https://github.com/bnjmn/litestar-emit-fail-example/blob/main/README.md#run-it)
```bash
pipenv install
litestar run -d
```
- Go to http://localhost:8000/check-value/2 to see a successful request
- Go to http://localhost:8000/check-value/3 to see an exception raised in the listener
- Attempt to go to http://localhost:8000/check-value/2 again, but you should get ClosedResourceError

### Litestar Version

Recreated on both 2.3.2 and 2.4.1

### Platform

- [ ] Linux
- [X] Mac
- [ ] Windows
- [x] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh Litestar dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/2809">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2809/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2809/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
```
INFO:     Started server process [2169882]
INFO:     Waiting for application startup.
TRACE:    ASGI [1] Started scope={'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}, 'state': {}}
TRACE:    ASGI [1] Receive {'type': 'lifespan.startup'}
TRACE:    ASGI [1] Send {'type': 'lifespan.startup.complete'}
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
TRACE:    127.0.0.1:58366 - HTTP connection made
TRACE:    127.0.0.1:58366 - ASGI [2] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'server': ('127.0.0.1', 8000), 'client': ('127.0.0.1', 58366), 'scheme': 'http', 'root_path': '', 'headers': '<...>', 'state': {}, 'method': 'GET', 'path': '/check-value/2', 'raw_path': b'/check-value/2', 'query_string': b''}
TRACE:    127.0.0.1:58366 - ASGI [2] Send {'type': 'http.response.start', 'status': 200, 'headers': '<...>'}
INFO:     127.0.0.1:58366 - "GET /check-value/2 HTTP/1.1" 200 OK
TRACE:    127.0.0.1:58366 - ASGI [2] Send {'type': 'http.response.body', 'body': '<31 bytes>', 'more_body': False}
TRACE:    127.0.0.1:58366 - ASGI [2] Completed
TRACE:    127.0.0.1:58366 - HTTP connection lost
TRACE:    127.0.0.1:41920 - HTTP connection made
TRACE:    127.0.0.1:41920 - ASGI [3] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'server': ('127.0.0.1', 8000), 'client': ('127.0.0.1', 41920), 'scheme': 'http', 'root_path': '', 'headers': '<...>', 'state': {}, 'method': 'GET', 'path': '/check-value/3', 'raw_path': b'/check-value/3', 'query_string': b''}
TRACE:    127.0.0.1:41920 - ASGI [3] Send {'type': 'http.response.start', 'status': 200, 'headers': '<...>'}
INFO:     127.0.0.1:41920 - "GET /check-value/3 HTTP/1.1" 200 OK
TRACE:    127.0.0.1:41920 - ASGI [3] Send {'type': 'http.response.body', 'body': '<31 bytes>', 'more_body': False}
TRACE:    127.0.0.1:41920 - ASGI [3] Completed
TRACE:    ASGI [1] Send {'type': 'lifespan.startup.failed', 'message': 'Traceback (most recent call last):\n  File "/home/peter/PycharmProjects/litestar/litestar/app.py", line 574, in lifespan\n    yield\n  File "/home/peter/PycharmProjects/litestar/litestar/_asgi/asgi_router.py", line 164, in lifespan\n    message = await receive()\n  File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/uvicorn/middleware/message_logger.py", line 60, in inner_receive\n    message = await receive()\n  File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/uvicorn/lifespan/on.py", line 137, in receive\n    return await self.receive_queue.get()\n  File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/asyncio/queues.py", line 163, in get\n    await getter\nasyncio.exceptions.CancelledError\n\nDuring handling of the above exception, another exception occurred:\n\n  + Exception Group Traceback (most recent call last):\n  |   File "/home/peter/PycharmProjects/litestar/litestar/_asgi/asgi_router.py", line 164, in lifespan\n  |     message = await receive()\n  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 189, in __aexit__\n  |     await self.gen.athrow(typ, value, traceback)\n  |   File "/home/peter/PycharmProjects/litestar/litestar/app.py", line 574, in lifespan\n  |     yield\n  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 679, in __aexit__\n  |     raise exc_details[1]\n  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 662, in __aexit__\n  |     cb_suppress = await cb(*exc_details)\n  |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 113, in __aexit__\n  |     await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)\n  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 679, in __aexit__\n  |     raise exc_details[1]\n  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 662, in __aexit__\n  |     cb_suppress = await cb(*exc_details)\n  |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__\n  |     raise BaseExceptionGroup(\n  | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n  +-+---------------- 1 ----------------\n    | Traceback (most recent call last):\n    |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 88, in _worker\n    |     async for item in receive_stream:\n    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/abc/_streams.py", line 35, in __anext__\n    |     return await self.receive()\n    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/streams/memory.py", line 105, in receive\n    |     await receive_event.wait()\n    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 1621, in wait\n    |     await self._event.wait()\n    |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/asyncio/locks.py", line 309, in wait\n    |     await fut\n    | asyncio.exceptions.CancelledError\n    | \n    | During handling of the above exception, another exception occurred:\n    | \n    | Exception Group Traceback (most recent call last):\n    |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 92, in _worker\n    |     task_group.start_soon(fn, *args)\n    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__\n    |     raise BaseExceptionGroup(\n    | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n    +-+---------------- 1 ----------------\n      | Traceback (most recent call last):\n      |   File "/home/peter/PycharmProjects/litestar-hello-world/app.py", line 12, in raise_exception_if_odd\n      |     raise ValueError(f"{value} is odd")\n      | ValueError: 3 is odd\n      +------------------------------------\n'}
TRACE:    ASGI [1] Raised exception
INFO:     ASGI 'lifespan' protocol appears unsupported.
TRACE:    127.0.0.1:41920 - HTTP connection lost
```
Thanks for the excellent reproduction!
Nicer exception formatting:

```python
Traceback (most recent call last):
  File "/home/peter/PycharmProjects/litestar/litestar/app.py", line 574, in lifespan
    yield
  File "/home/peter/PycharmProjects/litestar/litestar/_asgi/asgi_router.py", line 164, in lifespan
    message = await receive()
  File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/uvicorn/middleware/message_logger.py", line 60, in inner_receive
    message = await receive()
  File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/uvicorn/lifespan/on.py", line 137, in receive
    return await self.receive_queue.get()
  File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/asyncio/queues.py", line 163, in get
    await getter
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

  + Exception Group Traceback (most recent call last):
  |   File "/home/peter/PycharmProjects/litestar/litestar/_asgi/asgi_router.py", line 164, in lifespan
  |     message = await receive()
  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 189, in __aexit__
  |     await self.gen.athrow(typ, value, traceback)
  |   File "/home/peter/PycharmProjects/litestar/litestar/app.py", line 574, in lifespan
  |     yield
  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 679, in __aexit__
  |     raise exc_details[1]
  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 662, in __aexit__
  |     cb_suppress = await cb(*exc_details)
  |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 113, in __aexit__
  |     await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 679, in __aexit__
  |     raise exc_details[1]
  |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/contextlib.py", line 662, in __aexit__
  |     cb_suppress = await cb(*exc_details)
  |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__
  |     raise BaseExceptionGroup(
  | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 88, in _worker
    |     async for item in receive_stream:
    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/abc/_streams.py", line 35, in __anext__
    |     return await self.receive()
    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/streams/memory.py", line 105, in receive
    |     await receive_event.wait()
    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 1621, in wait
    |     await self._event.wait()
    |   File "/home/peter/.pyenv/versions/3.8.18/lib/python3.8/asyncio/locks.py", line 309, in wait
    |     await fut
    | asyncio.exceptions.CancelledError
    | 
    | During handling of the above exception, another exception occurred:
    | 
    | Exception Group Traceback (most recent call last):
    |   File "/home/peter/PycharmProjects/litestar/litestar/events/emitter.py", line 92, in _worker
    |     task_group.start_soon(fn, *args)
    |   File "/home/peter/.local/share/pdm/venvs/litestar-hello-world-qe1MW6VZ-3.8/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__
    |     raise BaseExceptionGroup(
    | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Traceback (most recent call last):
      |   File "/home/peter/PycharmProjects/litestar-hello-world/app.py", line 12, in raise_exception_if_odd
      |     raise ValueError(f"{value} is odd")
      | ValueError: 3 is odd
      +------------------------------------
```

Resolving patch:
diff --git a/litestar/events/emitter.py b/litestar/events/emitter.py
--- a/litestar/events/emitter.py
+++ b/litestar/events/emitter.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 import math
 import sys
 from abc import ABC, abstractmethod
@@ -17,9 +18,6 @@
 
 from litestar.exceptions import ImproperlyConfiguredException
 
-__all__ = ("BaseEventEmitterBackend", "SimpleEventEmitter")
-
-
 if TYPE_CHECKING:
     from types import TracebackType
 
@@ -27,6 +25,10 @@
 
     from litestar.events.listener import EventListener
 
+__all__ = ("BaseEventEmitterBackend", "SimpleEventEmitter")
+
+logger = logging.getLogger(__name__)
+
 
 class BaseEventEmitterBackend(AsyncContextManager["BaseEventEmitterBackend"], ABC):
     """Abstract class used to define event emitter backends."""
@@ -77,19 +79,25 @@ def __init__(self, listeners: Sequence[EventListener]) -> None:
         self._send_stream: MemoryObjectSendStream | None = None
         self._exit_stack: AsyncExitStack | None = None
 
-    @staticmethod
-    async def _worker(receive_stream: MemoryObjectReceiveStream) -> None:
+    async def _worker(self, receive_stream: MemoryObjectReceiveStream) -> None:
         """Run items from ``receive_stream`` in a task group.
 
         Returns:
             None
         """
-        async with receive_stream, anyio.create_task_group() as task_group:
+        async with receive_stream:
             async for item in receive_stream:
-                fn, args, kwargs = item
+                await self._run_listener_in_task_group(*item)
+
+    @staticmethod
+    async def _run_listener_in_task_group(fn: Any, args: tuple[Any], kwargs: dict[str, Any]) -> None:
+        try:
+            async with anyio.create_task_group() as task_group:
                 if kwargs:
                     fn = partial(fn, **kwargs)
                 task_group.start_soon(fn, *args)
+        except Exception as exc:
+            logger.exception("Error in event listener: %s", exc)
 
     async def __aenter__(self) -> SimpleEventEmitter:
         self._exit_stack = AsyncExitStack()


Test patch:
diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py
--- a/tests/unit/test_events.py
+++ b/tests/unit/test_events.py
@@ -109,3 +109,36 @@ async def test_raises_when_not_listener_are_registered_for_an_event_id(async_lis
     with create_test_client(route_handlers=[], listeners=[async_listener]) as client:
         with pytest.raises(ImproperlyConfiguredException):
             client.app.emit("x")
+
+
+async def test_event_listener_raises_exception(async_listener: EventListener, mock: MagicMock) -> None:
+    """Test that an event listener that raises an exception does not prevent other listeners from being called.
+
+    https://github.com/litestar-org/litestar/issues/2809
+    """
+
+    error_mock = MagicMock()
+
+    @listener("error_event")
+    async def raising_listener(*args: Any, **kwargs: Any) -> None:
+        error_mock()
+        raise ValueError("test")
+
+    @get("/error")
+    def route_handler_1(request: Request[Any, Any, Any]) -> None:
+        request.app.emit("error_event")
+
+    @get("/no-error")
+    def route_handler_2(request: Request[Any, Any, Any]) -> None:
+        request.app.emit("test_event")
+
+    with create_test_client(
+        route_handlers=[route_handler_1, route_handler_2], listeners=[async_listener, raising_listener]
+    ) as client:
+        first_response = client.get("/error")
+        second_response = client.get("/no-error")
+        assert first_response.status_code == HTTP_200_OK
+        assert second_response.status_code == HTTP_200_OK
+
+    error_mock.assert_called()
+    mock.assert_called()