Skip to content

Commit f900ae2

Browse files
committed
feat: add /healthz and /readyz endpoints
- Adds endpoints to check if the S3 is reachable and Celery / broker connected; - Returns 503 with a per-check breakdown when a dependency is down. - When storage is disabled both checks report "disabled". Closes #181
1 parent ee62364 commit f900ae2

6 files changed

Lines changed: 162 additions & 4 deletions

File tree

app/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from app.crates.ids import InvalidCrateId
88
from app.crates.resolver import CrateNotFound, AmbiguousCrate
9+
from app.health import health_bp
910
from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp
1011
from app.services.logging_service import (
1112
new_request_id,
@@ -48,6 +49,7 @@ def create_app(settings: Settings | None = None) -> APIFlask:
4849
app.config["PROFILES_PATH"] = settings.profiles_path
4950

5051
# Always available:
52+
app.register_blueprint(health_bp)
5153
app.register_blueprint(v1_post_bp, url_prefix="/v1/ro_crates")
5254

5355
# Object storage is optional and disabled by default. Only register the

app/health.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Liveness and readiness endpoints for orchestration.
2+
3+
``/healthz`` reports that the process is up. ``/readyz`` reports whether the
4+
service can actually serve S3 requests, by checking the object store and the
5+
Celery broker. When storage is disabled, those dependencies are not required
6+
and report ``disabled``.
7+
"""
8+
9+
import logging
10+
11+
from apiflask import APIBlueprint
12+
from flask import current_app, jsonify
13+
14+
from app.storage.s3 import S3Backend
15+
16+
logger = logging.getLogger(__name__)
17+
18+
health_bp = APIBlueprint("health", __name__)
19+
20+
# Short connection timeout (seconds) so readiness checks fail quickly.
21+
_BROKER_TIMEOUT = 3
22+
23+
24+
def check_storage(settings) -> tuple[bool, str]:
25+
"""Return whether the object store is reachable, with a detail string."""
26+
if not settings.storage_enabled:
27+
return True, "disabled"
28+
try:
29+
S3Backend.from_settings(settings).health_check()
30+
return True, "ok"
31+
except Exception as error: # noqa: BLE001 - any failure means not ready
32+
logger.warning("Storage readiness check failed: %s", error)
33+
return False, str(error)
34+
35+
36+
def check_broker(settings) -> tuple[bool, str]:
37+
"""Return whether the Celery broker is reachable, with a detail string."""
38+
if not settings.storage_enabled:
39+
return True, "disabled"
40+
try:
41+
from kombu import Connection
42+
43+
with Connection(settings.celery_broker_url) as connection:
44+
connection.ensure_connection(max_retries=1, timeout=_BROKER_TIMEOUT)
45+
return True, "ok"
46+
except Exception as error: # noqa: BLE001 - any failure means not ready
47+
logger.warning("Broker readiness check failed: %s", error)
48+
return False, str(error)
49+
50+
51+
@health_bp.get("/healthz")
52+
def healthz():
53+
"""Liveness: the process is running."""
54+
return jsonify({"status": "ok"}), 200
55+
56+
57+
@health_bp.get("/readyz")
58+
def readyz():
59+
"""Readiness: dependencies needed to serve requests are reachable."""
60+
settings = current_app.config["SETTINGS"]
61+
62+
storage_ok, storage_detail = check_storage(settings)
63+
broker_ok, broker_detail = check_broker(settings)
64+
ready = storage_ok and broker_ok
65+
66+
body = {
67+
"status": "ready" if ready else "not ready",
68+
"checks": {"storage": storage_detail, "broker": broker_detail},
69+
}
70+
return jsonify(body), (200 if ready else 503)

app/ro_crates/routes/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
"""Defines main Blueprint and registers sub-Blueprints for organising related routes."""
22

3-
# Author: Alexander Hambley
4-
# License: MIT
5-
# Copyright (c) 2025 eScience Lab, The University of Manchester
6-
73
from app.ro_crates.routes.post_routes import post_routes_bp, minio_post_routes_bp
84
from app.ro_crates.routes.get_routes import get_routes_bp
95

app/storage/s3.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ def download_tree(self, prefix: str, dest_dir: str) -> None:
9292
with open(local_path, "wb") as handle:
9393
handle.write(self.get_bytes(key))
9494

95+
def health_check(self) -> None:
96+
"""Verify the bucket is reachable; raise ``StorageError`` if not."""
97+
try:
98+
self._client.head_bucket(Bucket=self.bucket)
99+
except (ClientError, BotoCoreError) as error:
100+
raise StorageError(
101+
f"Bucket {self.bucket} not reachable: {error}"
102+
) from error
103+
95104
@staticmethod
96105
def _translate(error: ClientError, key: str) -> StorageError:
97106
"""Map a botocore ClientError to the storage error vocabulary.

tests/test_health.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Tests for the health and readiness endpoints."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from app import create_app
8+
from app.utils.config import Settings
9+
10+
11+
def _storage_env() -> dict:
12+
return {
13+
"STORAGE_ENABLED": "true",
14+
"S3_ENDPOINT": "minio:9000",
15+
"S3_ACCESS_KEY": "a",
16+
"S3_SECRET_KEY": "b",
17+
"S3_BUCKET": "ro-crates",
18+
"CELERY_BROKER_URL": "redis://r/0",
19+
"CELERY_RESULT_BACKEND": "redis://r/1",
20+
}
21+
22+
23+
@pytest.fixture
24+
def disabled_client():
25+
return create_app(settings=Settings.from_env({})).test_client()
26+
27+
28+
@pytest.fixture
29+
def storage_client():
30+
return create_app(settings=Settings.from_env(_storage_env())).test_client()
31+
32+
33+
def test_healthz_is_always_ok(disabled_client):
34+
response = disabled_client.get("/healthz")
35+
assert response.status_code == 200
36+
assert response.json["status"] == "ok"
37+
38+
39+
def test_readyz_ready_when_storage_disabled(disabled_client):
40+
response = disabled_client.get("/readyz")
41+
assert response.status_code == 200
42+
assert response.json["status"] == "ready"
43+
assert response.json["checks"]["storage"] == "disabled"
44+
45+
46+
def test_readyz_ok_when_all_checks_pass(storage_client):
47+
with mock.patch("app.health.check_storage", return_value=(True, "ok")), \
48+
mock.patch("app.health.check_broker", return_value=(True, "ok")):
49+
response = storage_client.get("/readyz")
50+
51+
assert response.status_code == 200
52+
assert response.json["status"] == "ready"
53+
54+
55+
def test_readyz_503_when_storage_unreachable(storage_client):
56+
with mock.patch("app.health.check_storage", return_value=(False, "bucket down")), \
57+
mock.patch("app.health.check_broker", return_value=(True, "ok")):
58+
response = storage_client.get("/readyz")
59+
60+
assert response.status_code == 503
61+
assert response.json["status"] == "not ready"
62+
assert response.json["checks"]["storage"] == "bucket down"
63+
64+
65+
def test_readyz_503_when_broker_unreachable(storage_client):
66+
with mock.patch("app.health.check_storage", return_value=(True, "ok")), \
67+
mock.patch("app.health.check_broker", return_value=(False, "broker down")):
68+
response = storage_client.get("/readyz")
69+
70+
assert response.status_code == 503
71+
assert response.json["checks"]["broker"] == "broker down"

tests/test_storage_s3.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ def test_non_missing_client_error_becomes_storage_error(s3_backend):
7878
assert not isinstance(exc_info.value, ObjectNotFound)
7979

8080

81+
def test_health_check_passes_for_existing_bucket(s3_backend):
82+
s3_backend.health_check() # must not raise
83+
84+
85+
def test_health_check_fails_for_missing_bucket(s3_backend):
86+
broken = S3Backend(s3_backend._client, "nonexistent-bucket")
87+
with pytest.raises(StorageError):
88+
broken.health_check()
89+
90+
8191
def test_s3_backend_satisfies_protocol(s3_backend):
8292
assert isinstance(s3_backend, StorageBackend)
8393

0 commit comments

Comments
 (0)