diff --git a/hathor/healthcheck/resources/healthcheck.py b/hathor/healthcheck/resources/healthcheck.py index 5e9afcb9f..eb1de7eed 100644 --- a/hathor/healthcheck/resources/healthcheck.py +++ b/hathor/healthcheck/resources/healthcheck.py @@ -1,6 +1,16 @@ import asyncio -from healthcheck import Healthcheck, HealthcheckCallbackResponse, HealthcheckInternalComponent, HealthcheckStatus +from healthcheck import ( + Healthcheck, + HealthcheckCallbackResponse, + HealthcheckInternalComponent, + HealthcheckResponse, + HealthcheckStatus, +) +from twisted.internet.defer import Deferred, succeed +from twisted.python.failure import Failure +from twisted.web.http import Request +from twisted.web.server import NOT_DONE_YET from hathor.api_util import Resource, get_arg_default, get_args from hathor.cli.openapi_files.register import register_resource @@ -24,6 +34,28 @@ class HealthcheckResource(Resource): def __init__(self, manager: HathorManager): self.manager = manager + def _render_error(self, failure: Failure, request: Request) -> None: + request.setResponseCode(500) + request.write(json_dumpb({ + 'status': 'fail', + 'reason': f'Internal Error: {failure.getErrorMessage()}', + 'traceback': failure.getTraceback() + })) + request.finish() + + def _render_success(self, result: HealthcheckResponse, request: Request) -> None: + raw_args = get_args(request) + strict_status_code = get_arg_default(raw_args, 'strict_status_code', '0') == '1' + + if strict_status_code: + request.setResponseCode(200) + else: + status_code = result.get_http_status_code() + request.setResponseCode(status_code) + + request.write(json_dumpb(result.to_json())) + request.finish() + def render_GET(self, request): """ GET request /health/ Returns the health status of the fullnode @@ -34,24 +66,26 @@ def render_GET(self, request): :rtype: string (json) """ - raw_args = get_args(request) - strict_status_code = get_arg_default(raw_args, 'strict_status_code', '0') == '1' - sync_component = HealthcheckInternalComponent( name='sync', ) sync_component.add_healthcheck(lambda: sync_healthcheck(self.manager)) healthcheck = Healthcheck(name='hathor-core', components=[sync_component]) - status = asyncio.get_event_loop().run_until_complete(healthcheck.run()) - if strict_status_code: - request.setResponseCode(200) + # The asyncio loop will be running in case the option --x-asyncio-reactor is used + # XXX: We should remove this if when the asyncio reactor becomes the default and the only option + if asyncio.get_event_loop().is_running(): + future = asyncio.ensure_future(healthcheck.run()) + deferred = Deferred.fromFuture(future) else: - status_code = status.get_http_status_code() - request.setResponseCode(status_code) + status = asyncio.get_event_loop().run_until_complete(healthcheck.run()) + deferred = succeed(status) + + deferred.addCallback(self._render_success, request) + deferred.addErrback(self._render_error, request) - return json_dumpb(status.to_json()) + return NOT_DONE_YET HealthcheckResource.openapi = { diff --git a/tests/resources/healthcheck/test_healthcheck.py b/tests/resources/healthcheck/test_healthcheck.py index e40fb2a76..c616d3a03 100644 --- a/tests/resources/healthcheck/test_healthcheck.py +++ b/tests/resources/healthcheck/test_healthcheck.py @@ -1,6 +1,7 @@ +import asyncio from unittest.mock import ANY -from twisted.internet.defer import inlineCallbacks +from twisted.internet.defer import Deferred, inlineCallbacks from hathor.healthcheck.resources.healthcheck import HealthcheckResource from hathor.manager import HathorManager @@ -39,6 +40,56 @@ def test_get_no_recent_activity(self): } }) + def test_with_running_asyncio_loop(self): + """Test with a running asyncio loop. + + This is a simulation of how this endpoint should behave in production when the + --x-asyncio-reactor is provided to hathor-core, because this causes the reactor to run + an asyncio loop. + """ + # This deferred will be used solely to make sure the test doesn't finish before the async code + done = Deferred() + + def set_done(_): + done.callback(None) + + def set_done_fail(failure): + done.errback(failure) + + # This will be called from inside the async method to perform the web request + # while a running asyncio loop is present + @inlineCallbacks + def get_health(): + response = yield self.web.get('/health') + return response.json_value() + + async def run(): + data = get_health() + # When the request is done, we make sure the response is as expected + data.addCallback(self.assertEqual, { + 'status': 'fail', + 'description': ANY, + 'checks': { + 'sync': [{ + 'componentType': 'internal', + 'componentName': 'sync', + 'status': 'fail', + 'output': HathorManager.UnhealthinessReason.NO_RECENT_ACTIVITY, + 'time': ANY + }] + } + }) + # We succeed the "done" deferred if everything is ok + data.addCallback(set_done) + # We fail the "done" deferred if something goes wrong. This includes the assertion above failing. + data.addErrback(set_done_fail) + + # This will make sure we have a running asyncio loop + asyncio.get_event_loop().run_until_complete(run()) + + # Return the deferred so the test doesn't finish before the async code + return done + @inlineCallbacks def test_strict_status_code(self): """Make sure the 'strict_status_code' parameter is working.