agnes-the-ai-analyst/cli/v2_client.py
ZdenekSrotyr 57482be263 feat(cli): #160 shared structured error renderer for BQ-typed responses
The reporter (#160) saw `USER_PROJECT_DENIED` raw in the CLI because
all three CLI error-rendering paths flatten typed BqAccessError /
guardrail / RBAC dicts to a truncated single-line string, hiding the
structured `hint` field that explains how to fix the misconfig.

Fix: shared `cli/error_render.py:render_error(status_code, body)` that
recognizes the canonical typed shapes and pretty-prints them. Falls
back to truncated-and-flattened form for unrecognized bodies, so the
renderer never makes worse-than-status-quo output.

Recognized shapes:
- {detail: {kind: ..., hint?, billing_project?, data_project?}}
  — typed BqAccessError responses from /api/v2/scan, /sample, /schema,
  /api/query (when /api/query escalates a BQ failure)
- {detail: {reason: 'remote_scan_too_large', scan_bytes, limit_bytes,
  tables, suggestion}} — new /api/query cost-guardrail rejection
- {detail: {reason: 'bq_path_not_registered'/'bq_path_access_denied',
  path, hint?, registered_as?}} — new /api/query RBAC patch
- {detail: '...'} — string detail (legacy endpoints)

Wired through 3 CLI paths:
- cli/v2_client.py: V2ClientError.__str__ delegates to render_error;
  pre-truncation removed from V2ClientError.message (was hiding hints
  past 200 chars).
- cli/commands/query.py:_query_remote: parse JSON body, call renderer
  on error.
- cli/commands/query.py:_query_hybrid: catch RemoteQueryError, build
  synthetic `{detail: {kind: error_type, **details}}` payload, render.

tests/test_cli_query.py:test_remote_query_failure: assertion updated
from `"Query failed"` (no longer printed) to `HTTP 400` + `bad SQL`
(what the renderer surfaces for string detail).

Sample output for cross_project_forbidden:

  Error: cross_project_forbidden (HTTP 502)
    billing_project: (empty)
    data_project: prj-example-data-001
    message: USER_PROJECT_DENIED on bigquery.googleapis.com
    hint: Set data_source.bigquery.billing_project in
        /admin/server-config to a project where the SA has
        serviceusage.services.use, or grant the SA that role on the
        data project.

19 tests pass — 10 from T4a now GREEN + 3 prior cli_query tests still
green + 6 ancillary.
2026-05-04 10:31:35 +02:00

69 lines
2.3 KiB
Python

"""HTTP client helpers for /api/v2/* endpoints (CLI side)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import io
import httpx
import pyarrow as pa
from cli.config import get_server_url, get_token
from cli.error_render import render_error
@dataclass
class V2ClientError(Exception):
status_code: int
body: Any
# `message` retained for backwards compat with any existing caller
# that reads `.message`. Renderer is the canonical str path now.
message: str = ""
def __str__(self) -> str:
# Prefer the structured renderer — it pretty-prints typed BQ errors
# (cross_project_forbidden, remote_scan_too_large, etc.) instead
# of the historical truncate-and-flatten form. Falls back to
# truncated form for unrecognized bodies, so we never make output
# WORSE than the status-quo (#160 §4.7).
return render_error(self.status_code, self.body)
def _headers() -> dict:
token = get_token()
return {"Authorization": f"Bearer {token}"} if token else {}
def _parse_error_body(r: httpx.Response) -> Any:
if "json" in r.headers.get("content-type", ""):
try:
return r.json()
except Exception:
return r.text
return r.text
def api_get_json(path: str, **params) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.get(url, headers=_headers(), params=params or None, timeout=30)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_post_json(path: str, payload: dict) -> dict:
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.post(url, json=payload, headers=_headers(), timeout=120)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
return r.json()
def api_post_arrow(path: str, payload: dict) -> pa.Table:
"""Post JSON, expect Arrow IPC stream response."""
url = f"{get_server_url().rstrip('/')}{path}"
r = httpx.post(url, json=payload, headers=_headers(), timeout=600)
if r.status_code >= 400:
raise V2ClientError(status_code=r.status_code, body=_parse_error_body(r))
reader = pa.ipc.open_stream(io.BytesIO(r.content))
return reader.read_all()