diff --git a/django-backend/soroscan/health.py b/django-backend/soroscan/health.py index 735b9507..2a363686 100644 --- a/django-backend/soroscan/health.py +++ b/django-backend/soroscan/health.py @@ -7,6 +7,8 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response +WORKER_PING_TIMEOUT = 5 # seconds + @api_view(["GET"]) @permission_classes([AllowAny]) @@ -37,4 +39,28 @@ def readiness_view(request): if errors: return Response({"status": "not_ready", "errors": errors}, status=503) - return Response({"status": "ready"}) \ No newline at end of file + return Response({"status": "ready"}) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def workers_health_view(request): + """Check that at least one Celery worker is responding.""" + from soroscan.celery import app as celery_app + + try: + inspector = celery_app.control.inspect(timeout=WORKER_PING_TIMEOUT) + ping_result = inspector.ping() or {} + except Exception as e: + return Response( + {"status": "error", "detail": f"Celery inspect failed: {e}"}, + status=503, + ) + + if not ping_result: + return Response( + {"status": "no_workers", "detail": "No Celery workers responded"}, + status=503, + ) + + return Response({"status": "ok", "workers": list(ping_result.keys())}) \ No newline at end of file diff --git a/django-backend/soroscan/ingest/tests/test_health.py b/django-backend/soroscan/ingest/tests/test_health.py index 719e1a8e..0bcb9c74 100644 --- a/django-backend/soroscan/ingest/tests/test_health.py +++ b/django-backend/soroscan/ingest/tests/test_health.py @@ -61,4 +61,60 @@ def mocked_get(*args, **kwargs): assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert response.data["status"] == "not_ready" assert any("redis" in e for e in response.data["errors"]) - assert response["X-SoroScan-Version"] == settings.SOFTWARE_VERSION \ No newline at end of file + assert response["X-SoroScan-Version"] == settings.SOFTWARE_VERSION + + +@pytest.mark.django_db +class TestWorkersHealthView: + def test_workers_ok_when_ping_returns_results(self, api_client, monkeypatch): + fake_ping = {"celery@worker1": {"ok": "pong"}} + + class FakeInspector: + def ping(self): + return fake_ping + + class FakeControl: + def inspect(self, timeout=None): + return FakeInspector() + + import soroscan.celery as celery_module + monkeypatch.setattr(celery_module.app, "control", FakeControl()) + + url = reverse("workers-health") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "ok" + assert "celery@worker1" in response.data["workers"] + + def test_workers_503_when_no_workers_respond(self, api_client, monkeypatch): + class FakeInspector: + def ping(self): + return {} + + class FakeControl: + def inspect(self, timeout=None): + return FakeInspector() + + import soroscan.celery as celery_module + monkeypatch.setattr(celery_module.app, "control", FakeControl()) + + url = reverse("workers-health") + response = api_client.get(url) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.data["status"] == "no_workers" + + def test_workers_503_when_inspect_raises(self, api_client, monkeypatch): + class FakeControl: + def inspect(self, timeout=None): + raise Exception("Broker unreachable") + + import soroscan.celery as celery_module + monkeypatch.setattr(celery_module.app, "control", FakeControl()) + + url = reverse("workers-health") + response = api_client.get(url) + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.data["status"] == "error" \ No newline at end of file diff --git a/django-backend/soroscan/urls.py b/django-backend/soroscan/urls.py index 0192540f..f1208a91 100644 --- a/django-backend/soroscan/urls.py +++ b/django-backend/soroscan/urls.py @@ -15,7 +15,7 @@ ) from soroscan.graphql_views import ThrottledGraphQLView -from soroscan.health import health_view, readiness_view +from soroscan.health import health_view, readiness_view, workers_health_view from soroscan.meta_views import db_pool_stats_view from soroscan.ingest.views import audit_trail_view, contract_status, rate_limit_analytics_view from soroscan.ingest.schema import schema @@ -34,6 +34,7 @@ path("health/", health_view, name="health"), path("ready/", readiness_view, name="readiness"), + path("api/health/workers/", workers_health_view, name="workers-health"), path("admin/", admin.site.urls), path("api/audit-trail/", audit_trail_view, name="audit-trail"),