Skip to content

Commit 69d3c2a

Browse files
authored
feat(service): Add /v1/health and /healthz endpoints for health checking (#2169)
1 parent eca26d4 commit 69d3c2a

7 files changed

Lines changed: 179 additions & 1 deletion

File tree

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,9 @@ RUN python -c "from fastembed.embedding import FlagEmbedding; FlagEmbedding('sen
5454

5555
RUN nemoguardrails --help
5656

57+
ENV NEMO_GUARDRAILS_HEALTHCHECK_URL=http://localhost:8000/v1/health
58+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
59+
CMD python -c "import os, urllib.request, sys; opener = urllib.request.build_opener(urllib.request.ProxyHandler({})); sys.exit(0 if opener.open(os.environ['NEMO_GUARDRAILS_HEALTHCHECK_URL']).status == 200 else 1)"
60+
5761
ENTRYPOINT ["nemoguardrails"]
5862
CMD ["server", "--verbose", "--config=/config"]

docs/deployment/using-docker.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,35 @@ To use the Chat CLI interface, run the Docker container in interactive mode:
106106

107107
```bash
108108
docker run -it \
109+
--no-healthcheck \
109110
-e OPENAI_API_KEY=$OPENAI_API_KEY \
110111
-v </path/to/local/config/>:/config \
111112
nemoguardrails chat --config=/config --verbose
112113
```
113114

115+
The image's built-in Docker `HEALTHCHECK` probes the server's liveness endpoint, so it is only meaningful when the container runs the default `server` command.
116+
Other commands such as `chat`, `actions-server`, `convert`, or `eval` do not serve a health endpoint.
117+
This means Docker would report the container as `unhealthy`.
118+
Add `--no-healthcheck` to `docker run` whenever the container does not run the server, as shown in the `chat` example above.
119+
120+
## Container Health Check
121+
122+
The image defines a Docker `HEALTHCHECK` that probes the server's liveness endpoint, so `docker ps` and orchestrators can report container health.
123+
By default it requests `http://localhost:8000/v1/health`, which matches the default `CMD` (the `server` command on port `8000` with no path prefix).
124+
125+
If you run the container on a different port or behind a path prefix, override the probe URL with the `NEMO_GUARDRAILS_HEALTHCHECK_URL` environment variable so the healthcheck targets the address the server actually serves:
126+
127+
```bash
128+
docker run \
129+
-p 9000:9000 \
130+
-e OPENAI_API_KEY=$OPENAI_API_KEY \
131+
-e NEMO_GUARDRAILS_HEALTHCHECK_URL=http://localhost:9000/v1/health \
132+
nemoguardrails server --config=/config --port 9000
133+
```
134+
135+
The health check applies only to the `server` application.
136+
For other commands, disable it with `--no-healthcheck`.
137+
114138
## AlignScore Fact-Checking
115139

116140
If one of your configurations uses the AlignScore fact-checking model, run the AlignScore server in a separate container:

docs/index.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,12 @@ navigation:
602602
title: List Challenges
603603
slug: list-challenges
604604
- health:
605+
- endpoint: GET /v1/health
606+
title: Health Check
607+
slug: get-health
608+
- endpoint: GET /healthz
609+
title: Health Check (Alias)
610+
slug: get-healthz
605611
- endpoint: GET /
606612
title: Get Server Health or Chat UI
607613
slug: get-root

docs/run-rails/using-fastapi-server/run-guardrails-server.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,23 @@ For production deployments, disable it using the `--disable-chat-ui` flag.
226226

227227
</Info>
228228

229+
## Health Check
230+
231+
The server exposes a liveness endpoint at `GET /v1/health` (also available as `GET /healthz`) for orchestrators, load balancers, and container runtimes.
232+
It returns HTTP `200` with the `application/health+json` media type and a body of `{"status": "pass"}` whenever the server process is running and able to serve requests.
233+
234+
```bash
235+
curl http://localhost:8000/v1/health
236+
```
237+
238+
```json
239+
{"status": "pass"}
240+
```
241+
242+
The check is intentionally shallow: it reflects only that the server process is up, and does not verify guardrails configurations, the model provider, or any datastore.
243+
Use this endpoint rather than `GET /` for health probes, because `GET /` redirects to the chat UI when the UI is enabled.
244+
When the server runs behind a path prefix (`--prefix`), the endpoints are served at `<prefix>/v1/health` and `<prefix>/healthz`.
245+
229246
## Related Topics
230247

231248
- [Chat with a Guardrailed Model](/run-guardrailed-inference/using-fastapi-server/chat-with-guardrailed-model)

fern/openapi.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,51 @@ paths:
189189
- id: jailbreak-1
190190
description: Attempt to bypass safety guardrails.
191191
category: jailbreak
192+
/v1/health:
193+
get:
194+
operationId: getHealth
195+
tags:
196+
- Health
197+
summary: Liveness health check
198+
description: |
199+
Shallow liveness check. Returns HTTP 200 while the server process is
200+
running and able to serve requests. It does not verify guardrails
201+
configurations, the model provider, or any datastore.
202+
responses:
203+
"200":
204+
description: The server process is up and able to serve requests.
205+
content:
206+
application/health+json:
207+
schema:
208+
type: object
209+
properties:
210+
status:
211+
type: string
212+
example: pass
213+
example:
214+
status: pass
215+
/healthz:
216+
get:
217+
operationId: getHealthz
218+
tags:
219+
- Health
220+
summary: Liveness health check (alias)
221+
description: |
222+
Kubernetes-style alias for `GET /v1/health`. Returns the same shallow
223+
liveness response.
224+
responses:
225+
"200":
226+
description: The server process is up and able to serve requests.
227+
content:
228+
application/health+json:
229+
schema:
230+
type: object
231+
properties:
232+
status:
233+
type: string
234+
example: pass
235+
example:
236+
status: pass
192237
/:
193238
get:
194239
operationId: getRoot

nemoguardrails/server/api.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from fastapi import FastAPI, HTTPException, Request
3030
from fastapi.middleware.cors import CORSMiddleware
3131
from pydantic import BaseModel, ValidationError
32-
from starlette.responses import RedirectResponse, StreamingResponse
32+
from starlette.responses import JSONResponse, RedirectResponse, StreamingResponse
3333

3434
from nemoguardrails import LLMRails, RailsConfig, utils
3535
from nemoguardrails.rails.llm.config import Model
@@ -253,6 +253,21 @@ async def get_rails_configs():
253253
return [{"id": config_id} for config_id in config_ids]
254254

255255

256+
@app.get(
257+
"/v1/health",
258+
summary="Liveness health check.",
259+
tags=["Health"],
260+
)
261+
@app.get(
262+
"/healthz",
263+
summary="Liveness health check.",
264+
tags=["Health"],
265+
)
266+
async def health():
267+
"""Return HTTP 200 while the server process is running and able to serve requests."""
268+
return JSONResponse(content={"status": "pass"}, media_type="application/health+json")
269+
270+
256271
@app.get(
257272
"/v1/models",
258273
response_model=OpenAIModelsList,

tests/server/test_health.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import pytest
17+
18+
pytest.importorskip("openai", reason="openai is required for server tests")
19+
from fastapi.testclient import TestClient
20+
21+
from nemoguardrails.server import api
22+
23+
client = TestClient(api.app)
24+
25+
ENDPOINT = "/v1/health"
26+
27+
28+
def test_health_returns_200_pass_status():
29+
"""GET /v1/health returns HTTP 200 with a JSON body of {"status": "pass"}."""
30+
response = client.get(ENDPOINT)
31+
32+
assert response.status_code == 200
33+
assert response.json() == {"status": "pass"}
34+
35+
36+
def test_health_uses_health_json_media_type():
37+
"""GET /v1/health responds with exactly the application/health+json media type."""
38+
response = client.get(ENDPOINT)
39+
40+
assert response.headers["content-type"] == "application/health+json"
41+
42+
43+
def test_health_rejects_post_with_405():
44+
"""POST /v1/health returns HTTP 405 because the endpoint is GET-only."""
45+
response = client.post(ENDPOINT)
46+
47+
assert response.status_code == 405
48+
49+
50+
def test_health_ok_without_config_or_cached_rails(monkeypatch):
51+
"""GET /v1/health returns 200 with no default config and no cached rails instances."""
52+
monkeypatch.setattr(api.app, "default_config_id", None)
53+
monkeypatch.setattr(api, "llm_rails_instances", {})
54+
55+
response = client.get(ENDPOINT)
56+
57+
assert response.status_code == 200
58+
assert response.json() == {"status": "pass"}
59+
60+
61+
def test_healthz_alias_matches_health_contract():
62+
"""GET /healthz returns the same 200 application/health+json {"status": "pass"} response as /v1/health."""
63+
response = client.get("/healthz")
64+
65+
assert response.status_code == 200
66+
assert response.headers["content-type"] == "application/health+json"
67+
assert response.json() == {"status": "pass"}

0 commit comments

Comments
 (0)