feat: add control API module
Lightweight asyncio HTTP handler for runtime inspection and management. Endpoints: /status, /metrics, /pool, /pool/alive, /config (GET) and /reload, /pool/test, /pool/refresh (POST). Raw HTTP/1.1 parsing, JSON responses, no new dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
274
src/s5p/api.py
Normal file
274
src/s5p/api.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""Built-in HTTP control API for runtime inspection and management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .metrics import Metrics
|
||||||
|
|
||||||
|
logger = logging.getLogger("s5p")
|
||||||
|
|
||||||
|
|
||||||
|
# -- HTTP helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_request(data: bytes) -> tuple[str, str]:
|
||||||
|
"""Extract method and path from an HTTP/1.1 request line.
|
||||||
|
|
||||||
|
Returns (method, path) or ("", "") on parse failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
line = data.split(b"\r\n", 1)[0].decode("ascii")
|
||||||
|
except (UnicodeDecodeError, IndexError):
|
||||||
|
return "", ""
|
||||||
|
parts = line.split(None, 2)
|
||||||
|
if len(parts) < 2:
|
||||||
|
return "", ""
|
||||||
|
return parts[0].upper(), parts[1].split("?", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
status: int,
|
||||||
|
body: dict | list,
|
||||||
|
) -> None:
|
||||||
|
"""Write an HTTP response with JSON body and close."""
|
||||||
|
phrases = {200: "OK", 400: "Bad Request", 404: "Not Found",
|
||||||
|
405: "Method Not Allowed", 500: "Internal Server Error"}
|
||||||
|
payload = json.dumps(body, separators=(",", ":")).encode()
|
||||||
|
header = (
|
||||||
|
f"HTTP/1.1 {status} {phrases.get(status, 'Error')}\r\n"
|
||||||
|
f"Content-Type: application/json\r\n"
|
||||||
|
f"Content-Length: {len(payload)}\r\n"
|
||||||
|
f"Connection: close\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
)
|
||||||
|
writer.write(header.encode() + payload)
|
||||||
|
|
||||||
|
|
||||||
|
# -- route handlers ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_status(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""GET /status -- combined runtime summary."""
|
||||||
|
metrics: Metrics = ctx["metrics"]
|
||||||
|
data = {
|
||||||
|
"uptime": round(time.monotonic() - metrics.started, 1),
|
||||||
|
"connections": metrics.connections,
|
||||||
|
"success": metrics.success,
|
||||||
|
"failed": metrics.failed,
|
||||||
|
"active": metrics.active,
|
||||||
|
"bytes_in": metrics.bytes_in,
|
||||||
|
"bytes_out": metrics.bytes_out,
|
||||||
|
}
|
||||||
|
pool = ctx.get("pool")
|
||||||
|
if pool:
|
||||||
|
data["pool"] = {"alive": pool.alive_count, "total": pool.count}
|
||||||
|
config = ctx.get("config")
|
||||||
|
if config:
|
||||||
|
data["chain"] = [str(h) for h in config.chain]
|
||||||
|
return 200, data
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_metrics(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""GET /metrics -- full metrics counters."""
|
||||||
|
return 200, ctx["metrics"].to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_pool(ctx: dict, alive_only: bool = False) -> tuple[int, dict]:
|
||||||
|
"""GET /pool or /pool/alive -- proxy pool state."""
|
||||||
|
pool = ctx.get("pool")
|
||||||
|
if not pool:
|
||||||
|
return 200, {"alive": 0, "total": 0, "proxies": {}}
|
||||||
|
|
||||||
|
proxies = {}
|
||||||
|
for key, entry in pool._proxies.items():
|
||||||
|
if alive_only and not entry.alive:
|
||||||
|
continue
|
||||||
|
proxies[key] = {
|
||||||
|
"alive": entry.alive,
|
||||||
|
"fails": entry.fails,
|
||||||
|
"tests": entry.tests,
|
||||||
|
"last_ok": entry.last_ok,
|
||||||
|
"last_test": entry.last_test,
|
||||||
|
"last_seen": entry.last_seen,
|
||||||
|
}
|
||||||
|
return 200, {
|
||||||
|
"alive": pool.alive_count,
|
||||||
|
"total": pool.count,
|
||||||
|
"proxies": proxies,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_config(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""GET /config -- sanitized runtime config."""
|
||||||
|
config = ctx.get("config")
|
||||||
|
if not config:
|
||||||
|
return 500, {"error": "config unavailable"}
|
||||||
|
|
||||||
|
data: dict = {
|
||||||
|
"listen": f"{config.listen_host}:{config.listen_port}",
|
||||||
|
"timeout": config.timeout,
|
||||||
|
"retries": config.retries,
|
||||||
|
"log_level": config.log_level,
|
||||||
|
"max_connections": config.max_connections,
|
||||||
|
"pool_size": config.pool_size,
|
||||||
|
"chain": [str(h) for h in config.chain],
|
||||||
|
}
|
||||||
|
if config.proxy_pool:
|
||||||
|
pp = config.proxy_pool
|
||||||
|
sources = []
|
||||||
|
for src in pp.sources:
|
||||||
|
s: dict = {}
|
||||||
|
if src.url:
|
||||||
|
s["url"] = src.url
|
||||||
|
if src.file:
|
||||||
|
s["file"] = src.file
|
||||||
|
sources.append(s)
|
||||||
|
data["proxy_pool"] = {
|
||||||
|
"sources": sources,
|
||||||
|
"refresh": pp.refresh,
|
||||||
|
"test_interval": pp.test_interval,
|
||||||
|
"max_fails": pp.max_fails,
|
||||||
|
}
|
||||||
|
return 200, data
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_reload(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""POST /reload -- re-read config (like SIGHUP)."""
|
||||||
|
reload_fn = ctx.get("reload_fn")
|
||||||
|
if not reload_fn:
|
||||||
|
return 500, {"error": "reload not available"}
|
||||||
|
try:
|
||||||
|
await reload_fn()
|
||||||
|
return 200, {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return 500, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_pool_test(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""POST /pool/test -- trigger immediate health test."""
|
||||||
|
pool = ctx.get("pool")
|
||||||
|
if not pool:
|
||||||
|
return 400, {"error": "no proxy pool configured"}
|
||||||
|
asyncio.create_task(pool._run_health_tests())
|
||||||
|
return 200, {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_pool_refresh(ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""POST /pool/refresh -- trigger immediate source re-fetch."""
|
||||||
|
pool = ctx.get("pool")
|
||||||
|
if not pool:
|
||||||
|
return 400, {"error": "no proxy pool configured"}
|
||||||
|
asyncio.create_task(pool._fetch_all_sources())
|
||||||
|
return 200, {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# -- routing -----------------------------------------------------------------
|
||||||
|
|
||||||
|
_GET_ROUTES: dict[str, str] = {
|
||||||
|
"/status": "status",
|
||||||
|
"/metrics": "metrics",
|
||||||
|
"/pool": "pool",
|
||||||
|
"/pool/alive": "pool_alive",
|
||||||
|
"/config": "config",
|
||||||
|
}
|
||||||
|
|
||||||
|
_POST_ROUTES: dict[str, str] = {
|
||||||
|
"/reload": "reload",
|
||||||
|
"/pool/test": "pool_test",
|
||||||
|
"/pool/refresh": "pool_refresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _route(method: str, path: str, ctx: dict) -> tuple[int, dict]:
|
||||||
|
"""Dispatch request to the appropriate handler."""
|
||||||
|
if method == "GET" and path in _GET_ROUTES:
|
||||||
|
name = _GET_ROUTES[path]
|
||||||
|
if name == "status":
|
||||||
|
return _handle_status(ctx)
|
||||||
|
if name == "metrics":
|
||||||
|
return _handle_metrics(ctx)
|
||||||
|
if name == "pool":
|
||||||
|
return _handle_pool(ctx)
|
||||||
|
if name == "pool_alive":
|
||||||
|
return _handle_pool(ctx, alive_only=True)
|
||||||
|
if name == "config":
|
||||||
|
return _handle_config(ctx)
|
||||||
|
|
||||||
|
if method == "POST" and path in _POST_ROUTES:
|
||||||
|
name = _POST_ROUTES[path]
|
||||||
|
if name == "reload":
|
||||||
|
return await _handle_reload(ctx)
|
||||||
|
if name == "pool_test":
|
||||||
|
return await _handle_pool_test(ctx)
|
||||||
|
if name == "pool_refresh":
|
||||||
|
return await _handle_pool_refresh(ctx)
|
||||||
|
|
||||||
|
# wrong method on a known path
|
||||||
|
if path in _GET_ROUTES or path in _POST_ROUTES:
|
||||||
|
expected = "GET" if path in _GET_ROUTES else "POST"
|
||||||
|
return 405, {"error": f"use {expected} for {path}"}
|
||||||
|
|
||||||
|
return 404, {"error": "not found"}
|
||||||
|
|
||||||
|
|
||||||
|
# -- server ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_connection(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
ctx: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Handle a single HTTP request on the control API."""
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(reader.read(8192), timeout=5.0)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
method, path = _parse_request(data)
|
||||||
|
if not method:
|
||||||
|
_json_response(writer, 400, {"error": "bad request"})
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
status, body = await _route(method, path, ctx)
|
||||||
|
_json_response(writer, status, body)
|
||||||
|
await writer.drain()
|
||||||
|
except (TimeoutError, ConnectionError, OSError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.debug("api: unexpected error handling request", exc_info=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def start_api(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
ctx: dict,
|
||||||
|
) -> asyncio.Server:
|
||||||
|
"""Start the control API HTTP server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Bind address.
|
||||||
|
port: Bind port.
|
||||||
|
ctx: Shared context dict with config, metrics, pool, reload_fn.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The running asyncio.Server (caller manages lifecycle).
|
||||||
|
"""
|
||||||
|
async def handler(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||||
|
await _handle_connection(r, w, ctx)
|
||||||
|
|
||||||
|
srv = await asyncio.start_server(handler, host, port)
|
||||||
|
addrs = ", ".join(str(s.getsockname()) for s in srv.sockets)
|
||||||
|
logger.info("api: listening on %s", addrs)
|
||||||
|
return srv
|
||||||
@@ -34,6 +34,19 @@ class Metrics:
|
|||||||
f"up={h}h{m:02d}m{s:02d}s"
|
f"up={h}h{m:02d}m{s:02d}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Return all counters as a dict (for JSON serialization)."""
|
||||||
|
return {
|
||||||
|
"connections": self.connections,
|
||||||
|
"success": self.success,
|
||||||
|
"failed": self.failed,
|
||||||
|
"retries": self.retries,
|
||||||
|
"active": self.active,
|
||||||
|
"bytes_in": self.bytes_in,
|
||||||
|
"bytes_out": self.bytes_out,
|
||||||
|
"uptime": round(time.monotonic() - self.started, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _human_bytes(n: int) -> str:
|
def _human_bytes(n: int) -> str:
|
||||||
"""Format byte count in human-readable form."""
|
"""Format byte count in human-readable form."""
|
||||||
|
|||||||
Reference in New Issue
Block a user