fix: report backend errors without a traceback and keep --json parsable (#439)

`main()` called `cli()` without any error handling, so a failure to reach the
AdGuardHome API surfaced as a chained urllib3/requests/click traceback. In
`--json` mode nothing at all was written to stdout, leaving an agent with no
parsable result.

The Docker hint inside that error message also mapped the host port to itself
(`-p 3001:3001`), which cannot reach a fresh container: the image serves its web
interface on container port 3000, as the harness's own E2E fixture and README
already assume.

- catch RuntimeError and requests exceptions in `main()`: emit
  `{"error": ...}` on stdout with `--json`, the plain message on stderr
  otherwise, exit 1 in both cases
- map the Docker hint to container port 3000 and mention that the wizard must
  keep the web interface there
- cover both paths with tests: JSON shape and exit status on an unreachable
  host, plain-message path on stderr, and the port mapping in the hint

Fixes #437


Claude-Session: https://claude.ai/code/session_01ChPxA9ksnNqr83nLbfMhwu

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
clydeRbuegel
2026-08-21 15:23:44 +08:00
committed by GitHub
co-authored by Claude Opus 5
parent af4320fce5
commit 060712a287
4 changed files with 40 additions and 2 deletions
@@ -6,6 +6,7 @@ import sys
from pathlib import Path
import click
import requests
from cli_anything.adguardhome.core import blocking as blocking_core
from cli_anything.adguardhome.core import clients as clients_core
@@ -84,7 +85,17 @@ def cli(ctx: click.Context, host, port, username, password, config_path, use_htt
def main():
cli(obj={})
try:
cli(obj={})
except (RuntimeError, requests.exceptions.RequestException) as exc:
# Backend failures reach this point as exceptions; without this handler they
# surface as a chained traceback and --json produces nothing parsable.
# sys.argv is used because the Click context is already unwound here.
if "--json" in sys.argv:
click.echo(json.dumps({"error": str(exc)}, default=str))
else:
click.echo(str(exc), err=True)
sys.exit(1)
# ---------------------------------------------------------------------------
@@ -93,6 +93,11 @@ class TestAdGuardHomeClient:
assert result == {}
mock_post.assert_called_once()
def test_connection_error_docker_hint_maps_to_container_port(self):
client = AdGuardHomeClient(host="localhost", port=3001)
message = str(client._connection_error(Exception("refused")))
assert "-p 3001:3000" in message
def test_connection_error_raises_runtime(self):
c = make_client()
with patch.object(c.session, "get", side_effect=requests.exceptions.ConnectionError("refused")):
@@ -193,6 +193,27 @@ class TestCLISubprocess:
assert result.returncode == 0
assert "parental" in result.stdout
def test_connection_error_json_is_parsable(self):
"""--json must stay machine-readable when the server is unreachable."""
result = self._run(
["--json", "--host", "127.0.0.1", "--port", "1", "server", "status"],
check=False,
)
assert result.returncode != 0
data = json.loads(result.stdout)
assert "Cannot connect to AdGuardHome" in data["error"]
assert "Traceback" not in result.stderr
def test_connection_error_human_message(self):
"""Without --json the message goes to stderr, without a traceback."""
result = self._run(
["--host", "127.0.0.1", "--port", "1", "server", "status"],
check=False,
)
assert result.returncode != 0
assert "Cannot connect to AdGuardHome" in result.stderr
assert "Traceback" not in result.stderr
# ---------------------------------------------------------------------------
# Docker E2E tests
@@ -38,7 +38,8 @@ class AdGuardHomeClient:
f"Cannot connect to AdGuardHome at {self.base_url}.\n"
f"Ensure AdGuardHome is running and accessible.\n"
f"Install: curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v\n"
f"Or Docker: docker run --name adguardhome -p {self.port}:{self.port} adguard/adguardhome\n"
f"Or Docker: docker run --name adguardhome -p {self.port}:3000 adguard/adguardhome\n"
f"(the container serves its web interface on port 3000 - keep that port in the setup wizard)\n"
f"Error: {e}"
)