Skip to content

Commit e15b75a

Browse files
committed
✨ Enhance lifespan error handling and add lifespan call tracking
1 parent eeb6a1c commit e15b75a

File tree

2 files changed

+68
-6
lines changed

2 files changed

+68
-6
lines changed
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,44 @@
1+
from typing import Final
2+
13
from common_library.errors_classes import OsparcErrorMixin
4+
from fastapi import FastAPI
5+
from fastapi_lifespan_manager import State
26

37

48
class LifespanError(OsparcErrorMixin, RuntimeError): ...
59

610

711
class LifespanOnStartupError(LifespanError):
8-
msg_template = "Failed during startup of {module}"
12+
msg_template = "Failed during startup of {lifespan_name}"
913

1014

1115
class LifespanOnShutdownError(LifespanError):
12-
msg_template = "Failed during shutdown of {module}"
16+
msg_template = "Failed during shutdown of {lifespan_name}"
17+
18+
19+
class LifespanAlreadyCalledError(LifespanError):
20+
msg_template = "The lifespan '{lifespan_name}' has already been called."
21+
22+
23+
_CALLED_LIFESPANS_KEY: Final[str] = "_CALLED_LIFESPANS"
24+
25+
26+
def is_lifespan_called(state: State, lifespan_name: str) -> bool:
27+
called_lifespans = state.get(_CALLED_LIFESPANS_KEY, set())
28+
return lifespan_name in called_lifespans
29+
30+
31+
def record_lifespan_called_once(state: State, lifespan_name: str) -> State:
32+
"""Validates if a lifespan has already been called and records it in the state.
33+
Raises LifespanAlreadyCalledError if the lifespan has already been called.
34+
"""
35+
assert not isinstance( # nosec
36+
state, FastAPI
37+
), "TIP: lifespan func has (app, state) positional arguments"
38+
39+
if is_lifespan_called(state, lifespan_name):
40+
raise LifespanAlreadyCalledError(lifespan_name=lifespan_name)
41+
42+
called_lifespans = state.get(_CALLED_LIFESPANS_KEY, set())
43+
called_lifespans.add(lifespan_name)
44+
return {_CALLED_LIFESPANS_KEY: called_lifespans}

packages/service-library/tests/fastapi/test_lifespan_utils.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
from pytest_mock import MockerFixture
1717
from pytest_simcore.helpers.logging_tools import log_context
1818
from servicelib.fastapi.lifespan_utils import (
19+
LifespanAlreadyCalledError,
1920
LifespanOnShutdownError,
2021
LifespanOnStartupError,
22+
record_lifespan_called_once,
2123
)
2224

2325

@@ -186,7 +188,7 @@ async def lifespan_failing_on_startup(app: FastAPI) -> AsyncIterator[State]:
186188
startup_step(_name)
187189
except RuntimeError as exc:
188190
handle_error(_name, exc)
189-
raise LifespanOnStartupError(module=_name) from exc
191+
raise LifespanOnStartupError(lifespan_name=_name) from exc
190192
yield {}
191193
shutdown_step(_name)
192194

@@ -201,7 +203,7 @@ async def lifespan_failing_on_shutdown(app: FastAPI) -> AsyncIterator[State]:
201203
shutdown_step(_name)
202204
except RuntimeError as exc:
203205
handle_error(_name, exc)
204-
raise LifespanOnShutdownError(module=_name) from exc
206+
raise LifespanOnShutdownError(lifespan_name=_name) from exc
205207

206208
return {
207209
"startup_step": startup_step,
@@ -228,7 +230,7 @@ async def test_app_lifespan_with_error_on_startup(
228230
assert not failing_lifespan_manager["startup_step"].called
229231
assert not failing_lifespan_manager["shutdown_step"].called
230232
assert exception.error_context() == {
231-
"module": "lifespan_failing_on_startup",
233+
"lifespan_name": "lifespan_failing_on_startup",
232234
"message": "Failed during startup of lifespan_failing_on_startup",
233235
"code": "RuntimeError.LifespanError.LifespanOnStartupError",
234236
}
@@ -250,7 +252,35 @@ async def test_app_lifespan_with_error_on_shutdown(
250252
assert failing_lifespan_manager["startup_step"].called
251253
assert not failing_lifespan_manager["shutdown_step"].called
252254
assert exception.error_context() == {
253-
"module": "lifespan_failing_on_shutdown",
255+
"lifespan_name": "lifespan_failing_on_shutdown",
254256
"message": "Failed during shutdown of lifespan_failing_on_shutdown",
255257
"code": "RuntimeError.LifespanError.LifespanOnShutdownError",
256258
}
259+
260+
261+
async def test_lifespan_called_more_than_once(is_pdb_enabled: bool):
262+
state = {}
263+
264+
app_lifespan = LifespanManager()
265+
266+
@app_lifespan.add
267+
async def _one(_, state: State) -> AsyncIterator[State]:
268+
called_state = record_lifespan_called_once(state, "test_lifespan_one")
269+
yield {"other": 0, **called_state}
270+
271+
@app_lifespan.add
272+
async def _two(_, state: State) -> AsyncIterator[State]:
273+
called_state = record_lifespan_called_once(state, "test_lifespan_two")
274+
yield {"something": 0, **called_state}
275+
276+
app_lifespan.add(_one) # added "by mistake"
277+
278+
with pytest.raises(LifespanAlreadyCalledError) as err_info:
279+
async with ASGILifespanManager(
280+
FastAPI(lifespan=app_lifespan),
281+
startup_timeout=None if is_pdb_enabled else 10,
282+
shutdown_timeout=None if is_pdb_enabled else 10,
283+
):
284+
...
285+
286+
assert err_info.value.lifespan_name == "test_lifespan_one"

0 commit comments

Comments
 (0)