Skip to content

Commit ced9008

Browse files
Merge pull request #243 from labthings/fallback-bugfixes
Fallback bugfixes
2 parents 25b99eb + db6d85a commit ced9008

3 files changed

Lines changed: 155 additions & 21 deletions

File tree

src/labthings_fastapi/server/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def __init__(
8181
:param settings_folder: the location on disk where `.Thing`
8282
settings will be saved.
8383
"""
84+
self.startup_failure: dict | None = None
8485
configure_thing_logger() # Note: this is safe to call multiple times.
8586
self._config = ThingServerConfig(things=things, settings_folder=settings_folder)
8687
self.app = FastAPI(lifespan=self.lifespan)
@@ -259,6 +260,10 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]:
259260
:param app: The FastAPI application wrapped by the server.
260261
:yield: no value. The FastAPI application will serve requests while this
261262
function yields.
263+
:raises BaseException: Reraises any errors that are caught when calling
264+
``__enter__`` on each Thing. The error is also saved to
265+
``self.startup_failure`` for post mortem, as otherwise uvicorn will swallow
266+
it and replace it with SystemExit(3) and no traceback.
262267
"""
263268
async with BlockingPortal() as portal:
264269
# We create a blocking portal to allow threaded code to call async code
@@ -271,7 +276,14 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]:
271276
# is present when this happens, in case we are dealing with threads.
272277
async with AsyncExitStack() as stack:
273278
for thing in self.things.values():
274-
await stack.enter_async_context(thing)
279+
try:
280+
await stack.enter_async_context(thing)
281+
except BaseException as e:
282+
self.startup_failure = {
283+
"thing": thing.name,
284+
"exception": e,
285+
}
286+
raise
275287
yield
276288

277289
self.blocking_portal = None

src/labthings_fastapi/server/fallback.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
from fastapi import FastAPI
1414
from fastapi.responses import HTMLResponse
1515
from starlette.responses import RedirectResponse
16+
from .config_model import ThingServerConfig
1617

1718
if TYPE_CHECKING:
1819
from . import ThingServer
19-
from .config_model import ThingServerConfig
2020

2121

2222
class FallbackApp(FastAPI):
@@ -32,7 +32,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
3232
:param \**kwargs: is passed to `fastapi.FastAPI.__init__`\ .
3333
"""
3434
super().__init__(*args, **kwargs)
35-
self.labthings_config: ThingServerConfig | None = None
35+
# Handle dictionary config here for legacy reasons.
36+
self.labthings_config: ThingServerConfig | dict | None = None
3637
self.labthings_server: ThingServer | None = None
3738
self.labthings_error: BaseException | None = None
3839
self.log_history = None
@@ -79,19 +80,22 @@ async def root() -> HTMLResponse:
7980
8081
:return: a response that serves the error as an HTML page.
8182
"""
82-
error_message = f"{app.labthings_error}"
83-
# use traceback.format_exception to get full traceback as list
84-
# this ends in newlines, but needs joining to be a single string
85-
error_w_trace = "".join(format_exception(app.labthings_error))
83+
error_message, error_w_trace = _format_error_and_traceback()
8684
things = ""
8785
if app.labthings_server:
8886
for path, thing in app.labthings_server.things.items():
8987
things += f"<li>{path}: {thing!r}</li>"
9088

89+
config = app.labthings_config
90+
if isinstance(config, ThingServerConfig):
91+
conf_str = config.model_dump_json(indent=2)
92+
else:
93+
conf_str = json.dumps(config, indent=2)
94+
9195
content = ERROR_PAGE
9296
content = content.replace("{{error}}", error_message)
9397
content = content.replace("{{things}}", things)
94-
content = content.replace("{{config}}", json.dumps(app.labthings_config, indent=2))
98+
content = content.replace("{{config}}", conf_str)
9599
content = content.replace("{{traceback}}", error_w_trace)
96100

97101
if app.log_history is None:
@@ -103,6 +107,34 @@ async def root() -> HTMLResponse:
103107
return HTMLResponse(content=content, status_code=app.html_code)
104108

105109

110+
def _format_error_and_traceback() -> tuple[str, str]:
111+
"""Format the error and traceback.
112+
113+
If the error was in lifespan causing Uvicorn to raise SystemExit(3) without a
114+
traceback. Try to extract the saved exception from the server.
115+
116+
:return: A tuple of error message and error traceback.
117+
"""
118+
err = app.labthings_error
119+
server = app.labthings_server
120+
error_message = f"{err}"
121+
122+
if (
123+
isinstance(err, SystemExit)
124+
and server is not None
125+
and isinstance(server.startup_failure, dict)
126+
):
127+
# It is a uvicorn SystemExit, so replace err with the saved error in the server.
128+
err = server.startup_failure.get("exception", err)
129+
thing = server.startup_failure.get("thing", "Unknown")
130+
error_message = f"Failed to enter '{thing}' Thing: {err}"
131+
132+
# use traceback.format_exception to get full traceback as list
133+
# this ends in newlines, but needs joining to be a single string
134+
error_w_trace = "".join(format_exception(err))
135+
return error_message, error_w_trace
136+
137+
106138
@app.get("/{path:path}")
107139
async def redirect_to_root(path: str) -> RedirectResponse:
108140
"""Redirect all paths on the server to the error page.

tests/test_fallback.py

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,56 @@
55
verifies that it works as expected.
66
"""
77

8+
import re
9+
10+
import pytest
11+
import uvicorn
12+
813
from fastapi.testclient import TestClient
914
import labthings_fastapi as lt
1015
from labthings_fastapi.server.fallback import app
16+
from labthings_fastapi.example_things import ThingThatCantStart
17+
18+
CONFIG_DICT = {
19+
"things": {
20+
"thing1": "labthings_fastapi.example_things:MyThing",
21+
"thing2": {
22+
"class": "labthings_fastapi.example_things:MyThing",
23+
"kwargs": {},
24+
},
25+
}
26+
}
27+
28+
29+
@pytest.fixture(autouse=True)
30+
def reset_app_state():
31+
"""Reset the fallback app state before each fallback test."""
32+
app.labthings_config = None
33+
app.labthings_server = None
34+
app.labthings_error = None
35+
app.log_history = None
36+
37+
38+
def test_fallback_redirect():
39+
"""Test that the redirect works."""
40+
with TestClient(app) as client:
41+
response = client.get("/")
42+
# No history as no redirect
43+
assert len(response.history) == 0
44+
html = response.text
45+
# test that something when wrong is shown
46+
assert "Something went wrong" in html
47+
48+
# Now try another url
49+
response = client.get("/foo/bar")
50+
# redirected so there is a history item showing a 307 Temporary Redirect code.
51+
assert len(response.history) == 1
52+
assert response.history[0].status_code == 307
53+
54+
# Redirects to error page.
55+
html = response.text
56+
# test that something when wrong is shown
57+
assert "Something went wrong" in html
1158

1259

1360
def test_fallback_empty():
@@ -19,14 +66,31 @@ def test_fallback_empty():
1966
assert "No logging info available" in html
2067

2168

22-
def test_fallback_with_config():
23-
app.labthings_config = {"hello": "goodbye"}
69+
def test_fallback_with_config_dict():
70+
"""Check that fallback server prints a config dictionary as JSON."""
71+
app.labthings_config = CONFIG_DICT
72+
with TestClient(app) as client:
73+
response = client.get("/")
74+
html = response.text
75+
assert "Something went wrong" in html
76+
assert "No logging info available" in html
77+
assert '"thing1": "labthings_fastapi.example_things:MyThing"' in html
78+
assert '"class": "labthings_fastapi.example_things:MyThing"' in html
79+
80+
81+
def test_fallback_with_config_obj():
82+
"""Check that fallback server prints the config object as JSON."""
83+
config = lt.ThingServerConfig.model_validate(CONFIG_DICT)
84+
app.labthings_config = config
2485
with TestClient(app) as client:
2586
response = client.get("/")
2687
html = response.text
2788
assert "Something went wrong" in html
2889
assert "No logging info available" in html
29-
assert '"hello": "goodbye"' in html
90+
assert "thing1" in html
91+
assert "thing2" in html
92+
cls_regex = re.compile(r'"cls": "labthings_fastapi\.example_things\.MyThing"')
93+
assert len(cls_regex.findall(html)) == 2
3094

3195

3296
def test_fallback_with_error():
@@ -41,16 +105,7 @@ def test_fallback_with_error():
41105

42106

43107
def test_fallback_with_server():
44-
config_dict = {
45-
"things": {
46-
"thing1": "labthings_fastapi.example_things:MyThing",
47-
"thing2": {
48-
"class": "labthings_fastapi.example_things:MyThing",
49-
"kwargs": {},
50-
},
51-
}
52-
}
53-
config = lt.ThingServerConfig.model_validate(config_dict)
108+
config = lt.ThingServerConfig.model_validate(CONFIG_DICT)
54109
app.labthings_server = lt.ThingServer.from_config(config)
55110
with TestClient(app) as client:
56111
response = client.get("/")
@@ -70,3 +125,38 @@ def test_fallback_with_log():
70125
assert "No logging info available" not in html
71126
assert "<p>Logging info</p>" in html
72127
assert "Fake log content" in html
128+
129+
130+
def test_actual_server_fallback():
131+
"""Test that the the server configures its startup failure correctly.
132+
133+
This may want to become an integration test in the fullness of time. Though
134+
the integration test may want to actually let the cli really serve up the
135+
fallback.
136+
"""
137+
# ThingThatCantStart has an error in __enter__
138+
server = lt.ThingServer({"bad_thing": ThingThatCantStart})
139+
140+
# Starting the server is a SystemExit
141+
with pytest.raises(SystemExit, match="3") as excinfo:
142+
uvicorn.run(server.app, port=5000)
143+
server_error = excinfo.value
144+
assert server.startup_failure is not None
145+
assert server.startup_failure["thing"] == "bad_thing"
146+
thing_error = server.startup_failure["exception"]
147+
assert isinstance(thing_error, RuntimeError)
148+
149+
app.labthings_server = server
150+
app.labthings_error = server_error
151+
with TestClient(app) as client:
152+
response = client.get("/")
153+
html = response.text
154+
assert "Something went wrong" in html
155+
# Shouldn't be displaying the meaningless SystemExit
156+
assert "SystemExit" not in html
157+
158+
# The message from when the Thing errored should be displayed
159+
assert str(thing_error) in html
160+
# With the traceback
161+
assert 'labthings_fastapi/example_things/__init__.py", line' in html
162+
assert f'RuntimeError("{thing_error}")' in html

0 commit comments

Comments
 (0)