Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
):

Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"):
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_health_endpoint_unit.py
Original file line number Diff line number Diff line change
@@ -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