Repository: encode/starlette

Issue:
Error in app startup event is not logged and does not prevent startup
Related #431 #177  and mentioned here https://github.com/encode/uvicorn/issues/333#issuecomment-484885300

Scenario:
I have a 'startup' event handler:
```python
@app.on_event('startup')
async def startup():
    initialise()
```
and I am running under uvicorn for local dev just via the:
```python
if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8000)
```

If there is an uncaught exception in the startup event handler, the exception will be swallowed (not logged) and uvicorn will continue to startup.

The only anomaly seen in the logs is that uvicorn will report:
```
ASGI 'lifespan' protocol appears unsupported
```

I would like to see:
- the exception and traceback logged
- process aborted, since we haven't fully started up yet



Discussion:

Patch:
diff --git a/starlette/middleware/errors.py b/starlette/middleware/errors.py
index 54f5fd2..b56d6ba 100644
--- a/starlette/middleware/errors.py
+++ b/starlette/middleware/errors.py
@@ -121,7 +121,7 @@ class ServerErrorMiddleware:
             # to optionally raise the error within the test case.
             raise exc from None
 
-    def generate_frame_html(self, frame: traceback.FrameSummary) -> str:
+    def genenrate_frame_html(self, frame: traceback.FrameSummary) -> str:
         values = {
             "frame_filename": frame.filename,
             "frame_lineno": frame.lineno,
@@ -135,7 +135,7 @@ class ServerErrorMiddleware:
             exc, capture_locals=True
         )
         exc_html = "".join(
-            self.generate_frame_html(frame) for frame in traceback_obj.stack
+            self.genenrate_frame_html(frame) for frame in traceback_obj.stack
         )
         error = f"{traceback_obj.exc_type.__name__}: {traceback_obj}"
 
diff --git a/starlette/routing.py b/starlette/routing.py
index d013591..a67bb49 100644
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -1,6 +1,7 @@
 import asyncio
 import inspect
 import re
+import traceback
 import typing
 from enum import Enum
 
@@ -469,9 +470,15 @@ class Lifespan(BaseRoute):
     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
         message = await receive()
         assert message["type"] == "lifespan.startup"
-        await self.startup()
-        await send({"type": "lifespan.startup.complete"})
 
+        try:
+            await self.startup()
+        except BaseException:
+            msg = traceback.format_exc()
+            await send({"type": "lifespan.startup.failed", "message": msg})
+            raise
+
+        await send({"type": "lifespan.startup.complete"})
         message = await receive()
         assert message["type"] == "lifespan.shutdown"
         await self.shutdown()


Test patch:
diff --git a/starlette/testclient.py b/starlette/testclient.py
index 7679207..de47f52 100644
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -463,9 +463,14 @@ class TestClient(requests.Session):
     async def wait_startup(self) -> None:
         await self.receive_queue.put({"type": "lifespan.startup"})
         message = await self.send_queue.get()
-        if message is None:
-            self.task.result()
-        assert message["type"] == "lifespan.startup.complete"
+        assert message["type"] in (
+            "lifespan.startup.complete",
+            "lifespan.startup.failed",
+        )
+        if message["type"] == "lifespan.startup.failed":
+            message = await self.send_queue.get()
+            if message is None:
+                self.task.result()
 
     async def wait_shutdown(self) -> None:
         await self.receive_queue.put({"type": "lifespan.shutdown"})
diff --git a/tests/middleware/test_lifespan.py b/tests/middleware/test_lifespan.py
index 8224689..4f381c1 100644
--- a/tests/middleware/test_lifespan.py
+++ b/tests/middleware/test_lifespan.py
@@ -42,11 +42,22 @@ def test_raise_on_startup():
     def run_startup():
         raise RuntimeError()
 
-    app = Router(routes=[Lifespan(on_startup=run_startup)])
+    router = Router(routes=[Lifespan(on_startup=run_startup)])
 
+    async def app(scope, receive, send):
+        async def _send(message):
+            nonlocal startup_failed
+            if message["type"] == "lifespan.startup.failed":
+                startup_failed = True
+            return await send(message)
+
+        await router(scope, receive, _send)
+
+    startup_failed = False
     with pytest.raises(RuntimeError):
         with TestClient(app):
             pass  # pragma: nocover
+    assert startup_failed
 
 
 def test_raise_on_shutdown():
diff --git a/tests/test_testclient.py b/tests/test_testclient.py
index 1f92c71..6569990 100644
--- a/tests/test_testclient.py
+++ b/tests/test_testclient.py
@@ -1,4 +1,5 @@
 import asyncio
+
 import pytest
 
 from starlette.applications import Starlette