feat(webhook_service): start webhook service early and handle readiness

- Modified the core service to start the webhook service before establishing a radio connection, reducing the window for connection refusals.
- Implemented a readiness check in the webhook service to return a 503 status when the bot is not connected, ensuring clear communication to external callers.
- Added unit tests to verify the webhook service's behavior when the bot's connection status changes, including rate limiting and readiness responses.
This commit is contained in:
agessaman
2026-07-08 14:48:47 -07:00
parent 7e7279875c
commit b02f00cac6
3 changed files with 86 additions and 1 deletions
+17 -1
View File
@@ -1990,6 +1990,20 @@ long_jokes = false
self.web_viewer_integration.start_viewer()
self.logger.info("Web viewer started (early, before radio connect)")
# Start the inbound webhook service early too (before radio connect). Unlike
# the other service plugins, this one accepts inbound connections — starting
# it late leaves a window (proportional to how long radio connect() takes)
# where external callers get connection-refused instead of a clear response.
# The handler itself gates on self.connected and returns 503 until the mesh
# link is actually ready, so it's safe to bind before we're connected.
webhook_service = self.services.get('webhook')
if webhook_service is not None and getattr(webhook_service, 'enabled', False):
try:
await webhook_service.start()
self.logger.info("Service 'webhook' started (early, before radio connect)")
except Exception as e:
self.logger.error(f"Failed to start service 'webhook' early: {e}")
# Connect to MeshCore node
if not await self.connect():
self.logger.error("Failed to connect to MeshCore node")
@@ -2032,8 +2046,10 @@ long_jokes = false
# Send startup advert if enabled
await self.send_startup_advert()
# Start all loaded services
# Start all loaded services (webhook already started early, before radio connect)
for service_name, service_instance in self.services.items():
if service_name == 'webhook':
continue
try:
await service_instance.start()
self.logger.info(f"Service '{service_name}' started")
@@ -37,6 +37,11 @@ Response codes:
400 {"error": "..."} bad/missing fields
401 {"error": "Unauthorized"} wrong / missing token
405 method not allowed
429 {"error": "Rate limit exceeded"}
503 {"error": "Bot not yet connected to mesh, try again shortly"}
The listener starts before the radio connection is established, so
callers may see this briefly after service start; retry after a
few seconds.
"""
import secrets
@@ -177,6 +182,18 @@ class WebhookService(BaseServicePlugin):
text='{"error": "Rate limit exceeded"}',
)
# --- Readiness ---
# The listener starts before the mesh connection is established (see
# core.py), so reject requests with a clear, retryable error until the
# bot is actually connected, rather than accepting them only to fail
# message dispatch.
if not getattr(self.bot, "connected", False):
return aio_web.Response(
status=503,
content_type="application/json",
text='{"error": "Bot not yet connected to mesh, try again shortly"}',
)
# --- Auth ---
if self.secret_token and not self._verify_token(request):
self.logger.warning(
+52
View File
@@ -30,6 +30,7 @@ def _make_bot(mock_logger, extra_cfg=None):
bot.command_manager = Mock()
bot.command_manager.send_channel_message = AsyncMock(return_value=True)
bot.command_manager.send_dm = AsyncMock(return_value=True)
bot.connected = True
return bot
@@ -115,6 +116,57 @@ class TestVerifyToken:
assert svc._verify_token(req) is True
# ---------------------------------------------------------------------------
# TestHandleWebhook — readiness
# ---------------------------------------------------------------------------
class TestHandleWebhookReadiness:
@pytest.mark.asyncio
async def test_not_connected_returns_503(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 503
@pytest.mark.asyncio
async def test_not_connected_does_not_dispatch(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
await svc._handle_webhook(req)
bot.command_manager.send_channel_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_connected_true_proceeds_normally(self, mock_logger):
svc, bot = _make_service(mock_logger)
bot.connected = True
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_missing_connected_attr_defaults_to_not_ready(self, mock_logger):
"""If `bot.connected` isn't present at all, fail safe (503) rather than assume readiness."""
svc, bot = _make_service(mock_logger)
del bot.connected
# Mock() raises AttributeError for deleted attrs, so getattr(..., False) applies.
req = _make_request(body={"channel": "general", "message": "hi"})
resp = await svc._handle_webhook(req)
assert resp.status == 503
@pytest.mark.asyncio
async def test_rate_limit_checked_before_readiness(self, mock_logger):
"""Rate limiting should still apply even while the bot isn't connected yet."""
svc, bot = _make_service(mock_logger, {"rate_limit_per_minute": "1"})
bot.connected = False
req = _make_request(body={"channel": "general", "message": "hi"})
await svc._handle_webhook(req)
resp = await svc._handle_webhook(req)
assert resp.status == 429
# ---------------------------------------------------------------------------
# TestHandleWebhook — auth
# ---------------------------------------------------------------------------