Skip to content

Commit 88a76cc

Browse files
Automatically detect lifespan support
1 parent fc9154f commit 88a76cc

File tree

5 files changed

+206
-45
lines changed

5 files changed

+206
-45
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async def app(scope, receive, send):
7777
await send({"type": "http.response.body", "body": b"Hello, world!"})
7878

7979

80-
handler = Mangum(app, enable_lifespan=False) # disable lifespan for raw ASGI example
80+
handler = Mangum(app)
8181
```
8282

8383
## WebSockets (experimental)

mangum/adapter.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def __post_init__(self) -> None:
4848
loop = asyncio.get_event_loop()
4949
self.lifespan = Lifespan(self.app, logger=self.logger)
5050
loop.create_task(self.lifespan.run())
51-
loop.run_until_complete(self.lifespan.wait_startup())
51+
loop.run_until_complete(self.lifespan.startup())
5252

5353
def __call__(self, event: dict, context: dict) -> dict:
5454
try:
@@ -74,8 +74,9 @@ def handler(self, event: dict, context: dict) -> dict:
7474
response = self.handle_http(event, context, is_http_api=is_http_api)
7575

7676
if self.enable_lifespan:
77-
loop = asyncio.get_event_loop()
78-
loop.run_until_complete(self.lifespan.wait_shutdown())
77+
if self.lifespan.is_supported:
78+
loop = asyncio.get_event_loop()
79+
loop.run_until_complete(self.lifespan.shutdown())
7980

8081
return response
8182

mangum/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class ASGIWebSocketCycleException(Exception):
22
"""Raise when an exception occurs within an ASGI websocket cycle"""
3+
4+
5+
class LifespanFailure(Exception):
6+
"""Raise when an error occurs in a lifespan event"""

mangum/lifespan.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,66 @@
22
import asyncio
33
from dataclasses import dataclass
44

5-
from mangum.types import ASGIApp, Message, Send, Receive
5+
from mangum.types import ASGIApp, Message
6+
from mangum.exceptions import LifespanFailure
67

78

89
@dataclass
910
class Lifespan:
1011
app: ASGIApp
1112
logger: logging.Logger
12-
startup_event: asyncio.Event = asyncio.Event()
13-
shutdown_event: asyncio.Event = asyncio.Event()
14-
app_queue: asyncio.Queue = asyncio.Queue()
13+
is_supported: bool = False
14+
has_error: bool = False
15+
16+
def __post_init__(self) -> None:
17+
self.app_queue: asyncio.Queue = asyncio.Queue()
18+
self.startup_event: asyncio.Event = asyncio.Event()
19+
self.shutdown_event: asyncio.Event = asyncio.Event()
20+
21+
async def startup(self) -> None:
22+
self.logger.info("Waiting for application startup.")
23+
if self.is_supported:
24+
await self.app_queue.put({"type": "lifespan.startup"})
25+
await self.startup_event.wait()
26+
if self.has_error:
27+
self.logger.error("Application startup failed.")
28+
else:
29+
self.logger.info("Application startup complete.")
30+
31+
async def shutdown(self) -> None:
32+
if self.has_error:
33+
return
34+
self.logger.info("Waiting for application shutdown.")
35+
await self.app_queue.put({"type": "lifespan.shutdown"})
36+
await self.shutdown_event.wait()
1537

1638
async def run(self) -> None:
17-
receive, send = (self.receiver(), self.sender())
1839
try:
19-
await self.app({"type": "lifespan"}, receive, send)
20-
except BaseException as exc: # pragma: no cover
21-
self.logger.error(f"Exception in 'lifespan' protocol: {exc}")
22-
finally:
40+
await self.app({"type": "lifespan"}, self.receive, self.send)
41+
except BaseException as exc:
2342
self.startup_event.set()
2443
self.shutdown_event.set()
44+
self.has_error = True
45+
if not self.is_supported:
46+
self.logger.info("ASGI 'lifespan' protocol appears unsupported.")
47+
else:
48+
self.logger.error("Exception in 'lifespan' protocol.", exc_info=exc)
2549

26-
def sender(self) -> Send:
27-
# startup_event, shutdown_event = self.startup_event, self.shutdown_event
28-
29-
async def send(message: Message) -> None:
30-
message_type = message["type"]
31-
if message_type == "lifespan.startup.complete":
32-
self.startup_event.set()
33-
elif message_type == "lifespan.shutdown.complete":
34-
self.shutdown_event.set()
35-
else: # pragma: no cover
36-
raise RuntimeError(
37-
f"Expected lifespan message type, received: {message_type}"
38-
)
39-
return None
50+
async def receive(self) -> Message:
51+
self.is_supported = True
4052

41-
return send
53+
return await self.app_queue.get()
4254

43-
def receiver(self) -> Receive:
44-
async def receive() -> Message:
45-
return await self.app_queue.get()
55+
async def send(self, message: Message) -> None:
56+
if not self.is_supported:
57+
raise LifespanFailure("Lifespan unsupported.")
4658

47-
return receive
48-
49-
async def wait_startup(self) -> None:
50-
self.logger.info("Waiting for application startup.")
51-
await self.app_queue.put({"type": "lifespan.startup"})
52-
await self.startup_event.wait()
53-
54-
async def wait_shutdown(self) -> None:
55-
self.logger.info("Waiting for application shutdown.")
56-
await self.app_queue.put({"type": "lifespan.shutdown"})
57-
await self.shutdown_event.wait()
59+
message_type = message["type"]
60+
if message_type == "lifespan.startup.complete":
61+
self.startup_event.set()
62+
elif message_type == "lifespan.shutdown.complete":
63+
self.shutdown_event.set()
64+
else: # pragma: no cover
65+
raise RuntimeError(
66+
f"Expected lifespan message type, received: {message_type}"
67+
)

tests/test_lifespan.py

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from starlette.applications import Starlette
55
from starlette.responses import PlainTextResponse
66

7-
87
from mangum import Mangum
98

109
# One (or more) of Quart's dependencies does not support Python 3.8, ignore this case.
@@ -18,9 +17,156 @@
1817

1918

2019
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
21-
def test_starlette_response(mock_http_event) -> None:
20+
def test_lifespan_startup_error(mock_http_event) -> None:
21+
async def app(scope, receive, send):
22+
if scope["type"] == "lifespan":
23+
while True:
24+
message = await receive()
25+
if message["type"] == "lifespan.startup":
26+
raise Exception("error")
27+
else:
28+
await send(
29+
{
30+
"type": "http.response.start",
31+
"status": 200,
32+
"headers": [[b"content-type", b"text/plain; charset=utf-8"]],
33+
}
34+
)
35+
await send({"type": "http.response.body", "body": b"Hello, world!"})
36+
37+
handler = Mangum(app)
38+
assert handler.lifespan.is_supported
39+
assert handler.lifespan.has_error
40+
41+
response = handler(mock_http_event, {})
42+
assert response == {
43+
"statusCode": 200,
44+
"isBase64Encoded": False,
45+
"headers": {"content-type": "text/plain; charset=utf-8"},
46+
"body": "Hello, world!",
47+
}
48+
49+
50+
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
51+
def test_lifespan(mock_http_event) -> None:
2252
startup_complete = False
2353
shutdown_complete = False
54+
55+
async def app(scope, receive, send):
56+
nonlocal startup_complete, shutdown_complete
57+
58+
if scope["type"] == "lifespan":
59+
while True:
60+
message = await receive()
61+
if message["type"] == "lifespan.startup":
62+
await send({"type": "lifespan.startup.complete"})
63+
startup_complete = True
64+
elif message["type"] == "lifespan.shutdown":
65+
await send({"type": "lifespan.shutdown.complete"})
66+
shutdown_complete = True
67+
return
68+
else:
69+
await send(
70+
{
71+
"type": "http.response.start",
72+
"status": 200,
73+
"headers": [[b"content-type", b"text/plain; charset=utf-8"]],
74+
}
75+
)
76+
await send({"type": "http.response.body", "body": b"Hello, world!"})
77+
78+
handler = Mangum(app)
79+
assert startup_complete
80+
81+
response = handler(mock_http_event, {})
82+
assert shutdown_complete
83+
assert response == {
84+
"statusCode": 200,
85+
"isBase64Encoded": False,
86+
"headers": {"content-type": "text/plain; charset=utf-8"},
87+
"body": "Hello, world!",
88+
}
89+
90+
91+
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
92+
def test_lifespan_unsupported(mock_http_event) -> None:
93+
async def app(scope, receive, send):
94+
await send(
95+
{
96+
"type": "http.response.start",
97+
"status": 200,
98+
"headers": [[b"content-type", b"text/plain; charset=utf-8"]],
99+
}
100+
)
101+
await send({"type": "http.response.body", "body": b"Hello, world!"})
102+
103+
handler = Mangum(app)
104+
assert not handler.lifespan.is_supported
105+
106+
response = handler(mock_http_event, {})
107+
assert response == {
108+
"statusCode": 200,
109+
"isBase64Encoded": False,
110+
"headers": {"content-type": "text/plain; charset=utf-8"},
111+
"body": "Hello, world!",
112+
}
113+
114+
115+
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
116+
def test_lifespan_disabled(mock_http_event) -> None:
117+
async def app(scope, receive, send):
118+
assert scope["type"] == "http"
119+
await send(
120+
{
121+
"type": "http.response.start",
122+
"status": 200,
123+
"headers": [[b"content-type", b"text/plain; charset=utf-8"]],
124+
}
125+
)
126+
await send({"type": "http.response.body", "body": b"Hello, world!"})
127+
128+
handler = Mangum(app, enable_lifespan=False)
129+
130+
response = handler(mock_http_event, {})
131+
assert response == {
132+
"statusCode": 200,
133+
"isBase64Encoded": False,
134+
"headers": {"content-type": "text/plain; charset=utf-8"},
135+
"body": "Hello, world!",
136+
}
137+
138+
139+
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
140+
def test_lifespan_supported_with_error(mock_http_event) -> None:
141+
async def app(scope, receive, send):
142+
await receive()
143+
await send(
144+
{
145+
"type": "http.response.start",
146+
"status": 200,
147+
"headers": [[b"content-type", b"text/plain; charset=utf-8"]],
148+
}
149+
)
150+
await send({"type": "http.response.body", "body": b"Hello, world!"})
151+
152+
handler = Mangum(app)
153+
assert handler.lifespan.is_supported
154+
assert handler.lifespan.has_error
155+
156+
response = handler(mock_http_event, {})
157+
assert response == {
158+
"statusCode": 200,
159+
"isBase64Encoded": False,
160+
"headers": {"content-type": "text/plain; charset=utf-8"},
161+
"body": "Hello, world!",
162+
}
163+
164+
165+
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
166+
def test_starlette_lifespan(mock_http_event) -> None:
167+
startup_complete = False
168+
shutdown_complete = False
169+
24170
path = mock_http_event["path"]
25171
app = Starlette()
26172

@@ -67,7 +213,7 @@ def homepage(request):
67213
)
68214
@pytest.mark.skipif(IS_PY36, reason="Quart does not support Python 3.6.")
69215
@pytest.mark.parametrize("mock_http_event", [["GET", None, None]], indirect=True)
70-
def test_quart_app(mock_http_event) -> None:
216+
def test_quart_lifespan(mock_http_event) -> None:
71217
startup_complete = False
72218
shutdown_complete = False
73219
path = mock_http_event["path"]

0 commit comments

Comments
 (0)