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.
69 lines
2.3 KiB
Python
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()
|