diff --git a/app/api/v2_catalog.py b/app/api/v2_catalog.py index a5b660e..43ade88 100644 --- a/app/api/v2_catalog.py +++ b/app/api/v2_catalog.py @@ -127,8 +127,17 @@ def build_catalog(conn: duckdb.DuckDBPyConnection, user: dict) -> dict: @router.get("/catalog") -async def catalog( +def catalog( user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): + # Plain ``def`` so FastAPI auto-offloads to the anyio thread pool — + # build_catalog now calls `_materialized_size_hint` for every visible + # row, which does sync `Path.stat()` / `Path.exists()` on the data + # volume. On local FS that's microseconds, but on a network-mounted + # DATA_DIR (NFS / CIFS / GCS-FUSE) those calls can block. Plain ``def`` + # means each request runs on its own thread; the event loop stays + # free for non-catalog traffic. Mirrors the Tier 1 conversion of + # /api/query, /api/v2/scan, /api/v2/sample, /api/v2/schema — + # Devin Review on PR #188. return build_catalog(conn, user) diff --git a/cli/client.py b/cli/client.py index e1861d8..1c665b9 100644 --- a/cli/client.py +++ b/cli/client.py @@ -212,10 +212,19 @@ def stream_download(path: str, target_path: str, progress_callback=None) -> int: break time.sleep(_RETRY_BACKOFFS_S[min(attempt, len(_RETRY_BACKOFFS_S) - 1)]) # Clean up any leftover tmp, then surface the last exception. Translate - # transport errors to AgnesTransportError so the CLI prints a clean - # message instead of a Python traceback (Pavel's #185 Phase 3B). + # transport errors (timeouts, connection drops, protocol errors) to + # AgnesTransportError so the CLI prints a clean message instead of a + # Python traceback (Pavel's #185 Phase 3B). HTTPStatusError (4xx/5xx + # response from the server) is NOT a transport failure and must + # re-raise verbatim so the caller's status-code handling + the rich + # server error body (e.g. 401 with "token expired", 403 with + # cross_project_forbidden detail) reach the analyst — Devin Review on + # PR #188 caught: HTTPStatusError is a subclass of HTTPError, so the + # generic isinstance(HTTPError) translation was eating status codes. tmp_path.unlink(missing_ok=True) assert last_exc is not None + if isinstance(last_exc, httpx.HTTPStatusError): + raise last_exc if isinstance(last_exc, httpx.HTTPError): raise _translate_transport_error( last_exc, context=f"GET {path} (stream → {target_path})"