twitch-byte-bot / cli /client.py
JuanCS-Dev's picture
Deploying CLI completion and HF Token auth support
d5dc912 verified
Raw History Blame Contribute Delete
7.67 kB
# SPDX-License-Identifier: MIT
"""HTTP client for the Byte Agent Dashboard API.
Provides a thin, production-grade wrapper over urllib.request with:
- Automatic authentication header injection
- Structured error handling with typed exceptions
- JSON request/response serialization
- Configurable timeouts
- Connection health probing
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Final
from cli.config import CLIConfig
_DEFAULT_TIMEOUT_SECONDS: Final[float] = 15.0
_HEALTH_TIMEOUT_SECONDS: Final[float] = 5.0
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class CLIClientError(Exception):
"""Base exception for all client errors."""
def __init__(self, message: str, *, status_code: int = 0) -> None:
super().__init__(message)
self.status_code = status_code
class AuthenticationError(CLIClientError):
"""Raised when the API rejects the admin token (401/403)."""
class NotFoundError(CLIClientError):
"""Raised when the requested route does not exist (404)."""
class ServerError(CLIClientError):
"""Raised when the server returns 5xx."""
class ConnectionError_(CLIClientError):
"""Raised when the agent is unreachable."""
class InvalidResponseError(CLIClientError):
"""Raised when the server returns non-JSON or malformed data."""
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class ByteClient:
"""HTTP client for the Byte Agent Dashboard API.
Usage:
client = ByteClient.from_config(config)
data = client.get("/api/control-plane")
client.post("/api/agent/suspend", {"reason": "maintenance"})
"""
base_url: str
token: str
hf_token: str = ""
timeout: float = _DEFAULT_TIMEOUT_SECONDS
@classmethod
def from_config(cls, config: CLIConfig) -> ByteClient:
return cls(
base_url=config.url.rstrip("/"),
token=config.token,
hf_token=config.hf_token,
)
# -- Public API ---------------------------------------------------------
def get(
self,
path: str,
params: dict[str, str] | None = None,
) -> dict[str, Any]:
"""HTTP GET. Query params are URL-encoded automatically."""
url = self._build_url(path, params)
return self._request(url, method="GET")
def put(
self,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""HTTP PUT with JSON body."""
url = self._build_url(path)
return self._request(url, method="PUT", payload=payload or {})
def post(
self,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""HTTP POST with JSON body."""
url = self._build_url(path)
return self._request(url, method="POST", payload=payload or {})
def health_check(self) -> bool:
"""Return True if the agent responds to /health."""
url = f"{self.base_url}/health"
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=_HEALTH_TIMEOUT_SECONDS) as resp:
return 200 <= resp.getcode() < 300
except Exception:
return False
# -- Internals ----------------------------------------------------------
def _build_url(
self,
path: str,
params: dict[str, str] | None = None,
) -> str:
url = f"{self.base_url}{path}"
if params:
# Filter out empty values
filtered = {k: v for k, v in params.items() if v}
if filtered:
url = f"{url}?{urllib.parse.urlencode(filtered)}"
return url
def _build_headers(self, *, with_body: bool = False) -> dict[str, str]:
headers: dict[str, str] = {
"User-Agent": "ByteCLI/1.0",
"Accept": "application/json",
}
if self.token:
headers["X-Byte-Admin-Token"] = self.token
if self.hf_token:
headers["Authorization"] = f"Bearer {self.hf_token}"
if with_body:
headers["Content-Type"] = "application/json; charset=utf-8"
return headers
def _request(
self,
url: str,
*,
method: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
has_body = payload is not None
headers = self._build_headers(with_body=has_body)
body_bytes: bytes | None = None
if has_body:
body_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=body_bytes,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as response:
raw = response.read()
if not raw:
return {"ok": True}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# Some endpoints return plain text (e.g. health)
text = raw.decode("utf-8", errors="replace").strip()
if text == "AGENT_ONLINE":
return {"ok": True, "status": "AGENT_ONLINE"}
raise InvalidResponseError(
f"Invalid JSON from {method} {url}: {text[:200]}",
status_code=response.getcode(),
) from exc
except urllib.error.HTTPError as exc:
status = exc.code
body_text = ""
try:
body_text = exc.read().decode("utf-8", errors="replace").strip()
except Exception:
pass
# Try to extract JSON error message
detail = body_text
try:
error_json = json.loads(body_text)
detail = error_json.get("error", error_json.get("message", body_text))
except (json.JSONDecodeError, AttributeError):
pass
if status in (401, 403):
raise AuthenticationError(
f"Authentication failed ({status}): {detail}",
status_code=status,
)
if status == 404:
raise NotFoundError(
f"Not found ({status}): {method} {url}",
status_code=status,
)
if status >= 500:
raise ServerError(
f"Server error ({status}): {detail}",
status_code=status,
)
raise CLIClientError(
f"HTTP {status}: {detail}",
status_code=status,
)
except urllib.error.URLError as exc:
raise ConnectionError_(
f"Cannot connect to agent at {self.base_url}: {exc.reason}",
) from exc
except TimeoutError as exc:
raise ConnectionError_(
f"Connection to {self.base_url} timed out after {self.timeout}s",
) from exc
except OSError as exc:
raise ConnectionError_(
f"Network error connecting to {self.base_url}: {exc}",
) from exc