diff --git a/dash/dash.py b/dash/dash.py index 676fa0f8f4..b52dec08f0 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -416,6 +416,10 @@ class Dash(ObsoleteChecker): :param use_async: When True, the app will create async endpoints, as a dev, they will be responsible for installing the `flask[async]` dependency. :type use_async: boolean + + :param health_endpoint: Path for the health check endpoint. Set to None to + disable the health endpoint. Default is None. + :type health_endpoint: string or None """ _plotlyjs_url: str @@ -466,6 +470,7 @@ def __init__( # pylint: disable=too-many-statements description: Optional[str] = None, on_error: Optional[Callable[[Exception], Any]] = None, use_async: Optional[bool] = None, + health_endpoint: Optional[str] = None, **obsolete, ): @@ -537,6 +542,7 @@ def __init__( # pylint: disable=too-many-statements update_title=update_title, include_pages_meta=include_pages_meta, description=description, + health_endpoint=health_endpoint, ) self.config.set_read_only( [ @@ -769,6 +775,8 @@ def _setup_routes(self): self._add_url("_dash-update-component", self.dispatch, ["POST"]) self._add_url("_reload-hash", self.serve_reload_hash) self._add_url("_favicon.ico", self._serve_default_favicon) + if self.config.health_endpoint is not None: + self._add_url(self.config.health_endpoint, self.serve_health) self._add_url("", self.index) if jupyter_dash.active: @@ -987,6 +995,13 @@ def serve_reload_hash(self): } ) + def serve_health(self): + """ + Health check endpoint for monitoring Dash server status. + Returns a simple "OK" response with HTTP 200 status. + """ + return flask.Response("OK", status=200, mimetype="text/plain") + def get_dist(self, libraries: Sequence[str]) -> list: dists = [] for dist_type in ("_js_dist", "_css_dist"): diff --git a/tests/unit/test_health_endpoint_unit.py b/tests/unit/test_health_endpoint_unit.py new file mode 100644 index 0000000000..723591fba4 --- /dev/null +++ b/tests/unit/test_health_endpoint_unit.py @@ -0,0 +1,72 @@ +""" +Tests for the health endpoint. + +Covers: +- disabled by default +- enabled returns plain OK 200 +- respects routes_pathname_prefix +- custom nested path works +- HEAD allowed, POST not allowed +""" + +from dash import Dash, html + + +def test_health_disabled_by_default_returns_404(): + app = Dash(__name__) # health_endpoint=None by default + app.layout = html.Div("Test") + client = app.server.test_client() + r = client.get("/health") + # When health endpoint is disabled, it returns the main page (200) instead of 404 + # This is expected behavior - the health endpoint is not available + assert r.status_code == 200 + # Should return HTML content, not "OK" + assert b"OK" not in r.data + + +def test_health_enabled_returns_ok_200_plain_text(): + app = Dash(__name__, health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + r = client.get("/health") + assert r.status_code == 200 + assert r.data == b"OK" + # Flask automatically sets mimetype to text/plain for Response with mimetype + assert r.mimetype == "text/plain" + + +def test_health_respects_routes_pathname_prefix(): + app = Dash(__name__, routes_pathname_prefix="/x/", health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + ok = client.get("/x/health") + miss = client.get("/health") + + assert ok.status_code == 200 and ok.data == b"OK" + assert miss.status_code == 404 + + +def test_health_custom_nested_path(): + app = Dash(__name__, health_endpoint="api/v1/health") + app.layout = html.Div("Test") + client = app.server.test_client() + + r = client.get("/api/v1/health") + assert r.status_code == 200 + assert r.data == b"OK" + + +def test_health_head_allowed_and_post_405(): + app = Dash(__name__, health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + head = client.head("/health") + assert head.status_code == 200 + # for HEAD the body can be empty, so we do not validate body + assert head.mimetype == "text/plain" + + post = client.post("/health") + assert post.status_code == 405