Skip to content

Commit 608e079

Browse files
committed
Fix website DB teardown and showing proper DB error page.
1 parent bbbdaeb commit 608e079

3 files changed

Lines changed: 228 additions & 14 deletions

File tree

python/rcdb/web/__init__.py

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from urllib.parse import urlparse
55

66
from flask import Flask, render_template, g, request, url_for
7+
from sqlalchemy.exc import SQLAlchemyError
78
from sqlalchemy.orm import subqueryload
89

910
import rcdb
@@ -60,8 +61,13 @@ def _connection_hint(conn_str):
6061

6162
app.config.from_object(__name__)
6263

63-
@app.before_request
64-
def before_request():
64+
def _resolve_active_database():
65+
"""Pick the connection string for this request and expose selector state.
66+
67+
Sets ``g.active_db_name`` / ``g.available_databases`` (used by the navbar DB
68+
selector) and returns the connection string to connect to. Does not touch
69+
the database -- only reads config and the ``rcdb_database`` cookie.
70+
"""
6571
available_dbs = app.config.get("AVAILABLE_DATABASES", {})
6672

6773
if available_dbs:
@@ -90,25 +96,94 @@ def before_request():
9096
# No default set, use first available
9197
active_name = next(iter(available_dbs))
9298

93-
connection_string = available_dbs[active_name]
9499
g.active_db_name = active_name
95100
g.available_databases = available_dbs
96-
else:
97-
# Original single-database behavior
98-
connection_string = app.config["SQL_CONNECTION_STRING"]
99-
g.active_db_name = None
100-
g.available_databases = {}
101-
102-
g.tdb = rcdb.ConfigurationProvider()
103-
g.tdb.connect(connection_string)
101+
return available_dbs[active_name]
102+
103+
# Original single-database behavior
104+
g.active_db_name = None
105+
g.available_databases = {}
106+
return app.config["SQL_CONNECTION_STRING"]
107+
108+
109+
@app.before_request
110+
def before_request():
111+
# Never gate static assets on the database -- the graceful DB-error page
112+
# below still needs its CSS/JS to load when the DB is unreachable.
113+
if request.endpoint == "static":
114+
return None
115+
104116
app.jinja_env.globals['datetime_now'] = datetime.now
105117

118+
# Resolve the selector state first so the navbar (and the DB-error page)
119+
# can render even if the connection below fails.
120+
connection_string = _resolve_active_database()
121+
122+
# Connect fresh, per request. A DB access/connection failure (access
123+
# denied, too many connections, timeout, host down, ...) must degrade
124+
# gracefully instead of surfacing a raw 500 -- and must not poison any
125+
# cookie or cached engine, so a later reload recovers once the DB is back.
126+
try:
127+
g.tdb = rcdb.ConfigurationProvider()
128+
g.tdb.connect(connection_string)
129+
except SQLAlchemyError:
130+
return _render_db_error(connection_string)
131+
132+
return None
133+
134+
135+
def _render_db_error(connection_string):
136+
"""Log the full failure detail and render the sanitized DB-error page.
137+
138+
The *log* gets everything an admin needs (which database, the underlying
139+
pymysql/SQLAlchemy error code + message, and the stack trace). The *page*
140+
gets none of it -- no connection string, host, user, or exception text --
141+
only a red "DB connection failure" strip with the top menu (and the DB
142+
selector) intact so the visitor can switch to a working database.
143+
"""
144+
logger.exception(
145+
"DB connection/access failure for database '%s' (%s); serving graceful "
146+
"error page instead of a 500.",
147+
getattr(g, "active_db_name", None) or "<default>",
148+
_connection_hint(connection_string),
149+
)
150+
151+
# Drop the half-built provider so its engine/pool is released now rather
152+
# than lingering as a broken cached connection -- the teardown below only
153+
# disposes fully-connected providers.
154+
broken = getattr(g, "tdb", None)
155+
if broken is not None:
156+
try:
157+
broken.disconnect()
158+
except Exception:
159+
logger.debug("Ignoring error while disposing failed DB engine.",
160+
exc_info=True)
161+
g.tdb = None
162+
163+
# 503 Service Unavailable: the failure is transient-friendly (e.g. "too
164+
# many connections" recovers), so signal a retryable condition rather than
165+
# a 200 or a permanent error. A plain reload after recovery renders fine.
166+
return render_template("db_error.html"), 503
167+
168+
169+
@app.errorhandler(SQLAlchemyError)
170+
def handle_db_error(error):
171+
"""Catch DB errors raised while a view runs (not just at connect time).
172+
173+
``before_request`` already handles the common case (``connect()`` exercises
174+
the connection via the schema-version check), but a lazily-issued query in
175+
a view can still raise. Route those through the same graceful page instead
176+
of a 500. ``g.active_db_name`` / ``g.available_databases`` were set by
177+
``before_request`` before the failure, so the selector still renders.
178+
"""
179+
return _render_db_error(getattr(g.get("tdb", None), "connection_string", ""))
180+
106181

107182
@app.teardown_request
108183
def teardown_request(exception):
109-
tdb = getattr(g, 'db', None)
110-
if tdb:
111-
tdb.close()
184+
tdb = getattr(g, 'tdb', None)
185+
if tdb is not None:
186+
tdb.disconnect()
112187

113188

114189
@app.errorhandler(404)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{% extends "layouts/base.html" %}
2+
3+
{% block title %}Database unavailable{% endblock %}
4+
5+
{#
6+
Graceful page shown when the selected database cannot be reached or used
7+
(access denied, too many connections, timeout, host down, ...). The top
8+
menu -- including the database selector -- is inherited from base.html so
9+
the visitor can switch to a working database and reload. No connection
10+
string, host, user, or exception text is exposed here; the full detail is
11+
written to the application log instead.
12+
#}
13+
14+
{% block body %}
15+
<div class="alert alert-danger"
16+
role="alert"
17+
style="text-align: center; font-weight: bold; margin-top: 20px;">
18+
DB connection failure
19+
</div>
20+
{% endblock %}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Web DB-failure handling.
2+
3+
A database selected in the navbar (stored in the ``rcdb_database`` cookie) may
4+
be unreachable or access-denied server-side (e.g. the MariaDB ``rcdb`` user has
5+
no GRANT on ``xem2``/``pionct`` -> pymysql ``(1044, "Access denied ...")``).
6+
7+
These tests pin the required behaviour:
8+
9+
* such a failure renders a graceful page (top menu + DB selector + a red
10+
"DB connection failure" strip), never a raw 500;
11+
* no sensitive detail (connection string, host, user, error code/text) leaks
12+
into the page, while the full exception is written to the application log;
13+
* the failure is not sticky -- reloading after the DB recovers, or switching to
14+
a working DB, renders normally with no poisoned cookie or cached engine.
15+
"""
16+
17+
import unittest
18+
19+
from sqlalchemy.exc import OperationalError
20+
21+
import rcdb
22+
import rcdb.provider
23+
import rcdb.web
24+
25+
26+
class TestWebDbFailure(unittest.TestCase):
27+
def setUp(self):
28+
self.app = rcdb.web.app
29+
self.app.testing = True
30+
31+
# Save config we mutate so other tests see the app untouched.
32+
self._saved = {k: self.app.config.get(k) for k in
33+
("AVAILABLE_DATABASES", "DEFAULT_DATABASE")}
34+
self.app.config["AVAILABLE_DATABASES"] = {
35+
"good": "sqlite:///good",
36+
"bad": "sqlite:///bad",
37+
}
38+
self.app.config["DEFAULT_DATABASE"] = "sqlite:///good"
39+
40+
self.client = self.app.test_client()
41+
42+
# Replace connect(): raise an access-denied-style OperationalError for the
43+
# "bad" DB (while it is "down"); connect a fresh in-memory schema for the
44+
# "good" one. ``bad_down`` flips to False to simulate recovery.
45+
self.bad_down = True
46+
self._orig_connect = rcdb.ConfigurationProvider.connect
47+
test = self
48+
49+
def fake_connect(provider, connection_string="", check_version=True):
50+
if "bad" in connection_string and test.bad_down:
51+
raise OperationalError(
52+
"SELECT 1", {},
53+
Exception("(1044, \"Access denied for user 'rcdb'@'%' "
54+
"to database 'bad'\")"))
55+
test._orig_connect(provider, "sqlite://", check_version=False)
56+
rcdb.provider.destroy_all_create_schema(provider)
57+
58+
rcdb.ConfigurationProvider.connect = fake_connect
59+
60+
def tearDown(self):
61+
rcdb.ConfigurationProvider.connect = self._orig_connect
62+
for k, v in self._saved.items():
63+
if v is None:
64+
self.app.config.pop(k, None)
65+
else:
66+
self.app.config[k] = v
67+
68+
def _get(self, path="/", db=None):
69+
# Fresh client per call so the cookie jar carries only what we set here
70+
# (the Werkzeug test client ignores a raw Cookie header, hence set_cookie).
71+
client = self.app.test_client()
72+
if db:
73+
client.set_cookie("rcdb_database", db)
74+
return client.get(path)
75+
76+
def test_failure_renders_graceful_page_not_500(self):
77+
with self.assertLogs("rcdb.web", level="ERROR") as cm:
78+
resp = self._get("/", db="bad")
79+
80+
# Graceful, retryable status -- never a raw 500.
81+
self.assertEqual(resp.status_code, 503)
82+
self.assertNotEqual(resp.status_code, 500)
83+
84+
body = resp.get_data(as_text=True)
85+
# Red strip + intact top menu incl. the DB selector to switch away.
86+
self.assertIn("DB connection failure", body)
87+
self.assertIn('id="dbSelector"', body)
88+
self.assertIn(">bad<", body)
89+
self.assertIn(">good<", body)
90+
91+
# No sensitive detail leaks into the page ...
92+
for secret in ("1044", "Access denied", "sqlite:///bad", "rcdb@",
93+
"OperationalError", "Traceback"):
94+
self.assertNotIn(secret, body)
95+
96+
# ... but the log has the full picture (db name + underlying error).
97+
logtext = "\n".join(cm.output)
98+
self.assertIn("bad", logtext)
99+
self.assertIn("1044", logtext)
100+
101+
def test_reload_recovers_after_db_comes_back(self):
102+
# Same cookie, DB down -> graceful 503.
103+
self.assertEqual(self._get("/", db="bad").status_code, 503)
104+
105+
# DB recovers; a plain reload (identical cookie) must render normally.
106+
self.bad_down = False
107+
resp = self._get("/", db="bad")
108+
self.assertEqual(resp.status_code, 200)
109+
self.assertNotIn("DB connection failure", resp.get_data(as_text=True))
110+
111+
def test_switching_to_working_db_recovers(self):
112+
self.assertEqual(self._get("/", db="bad").status_code, 503)
113+
resp = self._get("/", db="good")
114+
self.assertEqual(resp.status_code, 200)
115+
self.assertNotIn("DB connection failure", resp.get_data(as_text=True))
116+
117+
118+
if __name__ == "__main__":
119+
unittest.main()

0 commit comments

Comments
 (0)