Merge PR #2625: feat: add HTTP health endpoint on gateway port
feat: add HTTP health endpoint on gateway port
This commit is contained in:
@@ -1727,6 +1727,7 @@ Example config:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1739,6 +1740,14 @@ nanobot gateway --config ~/.nanobot-telegram/config.json
|
|||||||
nanobot gateway --config ~/.nanobot-discord/config.json
|
nanobot gateway --config ~/.nanobot-discord/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each gateway instance also exposes a lightweight HTTP health endpoint on
|
||||||
|
`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`,
|
||||||
|
so the endpoint stays local unless you explicitly set `gateway.host` to a
|
||||||
|
public or LAN-facing address.
|
||||||
|
|
||||||
|
- `GET /health` returns `{"status":"ok"}`
|
||||||
|
- Other paths return `404`
|
||||||
|
|
||||||
Override workspace for one-off runs when needed:
|
Override workspace for one-off runs when needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -821,6 +821,48 @@ def gateway(
|
|||||||
|
|
||||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||||
|
|
||||||
|
async def _health_server(host: str, health_port: int):
|
||||||
|
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
async def handle(reader, writer):
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(reader.read(4096), timeout=5)
|
||||||
|
except (asyncio.TimeoutError, ConnectionError):
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace")
|
||||||
|
method, path = "", ""
|
||||||
|
parts = request_line.split(" ")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
method, path = parts[0], parts[1]
|
||||||
|
|
||||||
|
if method == "GET" and path == "/health":
|
||||||
|
body = _json.dumps({"status": "ok"})
|
||||||
|
resp = (
|
||||||
|
f"HTTP/1.0 200 OK\r\n"
|
||||||
|
f"Content-Type: application/json\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
f"\r\n{body}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body = "Not Found"
|
||||||
|
resp = (
|
||||||
|
f"HTTP/1.0 404 Not Found\r\n"
|
||||||
|
f"Content-Type: text/plain\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
f"\r\n{body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.write(resp.encode())
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
server = await asyncio.start_server(handle, host, health_port)
|
||||||
|
console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
# Register Dream system job (always-on, idempotent on restart)
|
# Register Dream system job (always-on, idempotent on restart)
|
||||||
dream_cfg = config.agents.defaults.dream
|
dream_cfg = config.agents.defaults.dream
|
||||||
if dream_cfg.model_override:
|
if dream_cfg.model_override:
|
||||||
@@ -843,6 +885,7 @@ def gateway(
|
|||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
agent.run(),
|
agent.run(),
|
||||||
channels.start_all(),
|
channels.start_all(),
|
||||||
|
_health_server(config.gateway.host, port),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nShutting down...")
|
console.print("\nShutting down...")
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class ApiConfig(Base):
|
|||||||
class GatewayConfig(Base):
|
class GatewayConfig(Base):
|
||||||
"""Gateway/server configuration."""
|
"""Gateway/server configuration."""
|
||||||
|
|
||||||
host: str = "0.0.0.0"
|
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||||
port: int = 18790
|
port: int = 18790
|
||||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -1126,6 +1126,153 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
|
|||||||
assert "port 18792" in result.stdout
|
assert "port 18792" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
config_file = _write_instance_config(tmp_path)
|
||||||
|
config = Config()
|
||||||
|
config.gateway.port = 18791
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _FakeDream:
|
||||||
|
model = None
|
||||||
|
max_batch_size = 0
|
||||||
|
max_iterations = 0
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeAgentLoop:
|
||||||
|
def __init__(self, **_kwargs) -> None:
|
||||||
|
self.model = "test-model"
|
||||||
|
self.dream = _FakeDream()
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeChannelManager:
|
||||||
|
def __init__(self, _config, _bus) -> None:
|
||||||
|
self.enabled_channels = ["telegram", "discord"]
|
||||||
|
|
||||||
|
async def start_all(self) -> None:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def stop_all(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeCronService:
|
||||||
|
def __init__(self, _store_path: Path) -> None:
|
||||||
|
self.on_job = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def status(self) -> dict[str, int]:
|
||||||
|
return {"jobs": 0}
|
||||||
|
|
||||||
|
def register_system_job(self, _job) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeHeartbeatService:
|
||||||
|
def __init__(self, **_kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def serve_forever(self) -> None:
|
||||||
|
raise _StopGatewayError("stop")
|
||||||
|
|
||||||
|
async def _fake_start_server(handler, host: str, port: int):
|
||||||
|
captured["handler"] = handler
|
||||||
|
captured["host"] = host
|
||||||
|
captured["port"] = port
|
||||||
|
return _FakeServer()
|
||||||
|
|
||||||
|
class _FakeReader:
|
||||||
|
def __init__(self, payload: bytes) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
async def read(self, _size: int) -> bytes:
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
class _FakeWriter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.output = b""
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
self.output += data
|
||||||
|
|
||||||
|
async def drain(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
_patch_cli_command_runtime(
|
||||||
|
monkeypatch,
|
||||||
|
config,
|
||||||
|
message_bus=lambda: object(),
|
||||||
|
session_manager=lambda _workspace: object(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
|
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
||||||
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured["host"] == "127.0.0.1"
|
||||||
|
assert captured["port"] == 18791
|
||||||
|
assert "Health endpoint: http://127.0.0.1:18791/health" in result.stdout
|
||||||
|
|
||||||
|
def _call_handler(path: str) -> tuple[str, _FakeWriter]:
|
||||||
|
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
|
||||||
|
writer = _FakeWriter()
|
||||||
|
handler = captured["handler"]
|
||||||
|
assert callable(handler)
|
||||||
|
asyncio.run(handler(_FakeReader(request), writer))
|
||||||
|
return writer.output.decode(), writer
|
||||||
|
|
||||||
|
root_response, root_writer = _call_handler("/")
|
||||||
|
assert root_writer.closed is True
|
||||||
|
assert "HTTP/1.0 404 Not Found" in root_response
|
||||||
|
assert root_response.endswith("\r\n\r\nNot Found")
|
||||||
|
|
||||||
|
health_response, health_writer = _call_handler("/health")
|
||||||
|
assert health_writer.closed is True
|
||||||
|
assert "HTTP/1.0 200 OK" in health_response
|
||||||
|
health_body = json.loads(health_response.split("\r\n\r\n", 1)[1])
|
||||||
|
assert health_body == {"status": "ok"}
|
||||||
|
|
||||||
|
missing_response, missing_writer = _call_handler("/missing")
|
||||||
|
assert missing_writer.closed is True
|
||||||
|
assert "HTTP/1.0 404 Not Found" in missing_response
|
||||||
|
assert missing_response.endswith("\r\n\r\nNot Found")
|
||||||
|
|
||||||
|
|
||||||
def test_serve_uses_api_config_defaults_and_workspace_override(
|
def test_serve_uses_api_config_defaults_and_workspace_override(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user