From 2052a0b2f88d0572b5be127116c21f2f0b3c8e90 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 17:50:04 +0200 Subject: [PATCH 1/2] Move onto pipelex-sdk 0.14.0 and present a refused run from its typed error pipelex-sdk 0.14.0 raises the typed ApiResponseError on every route, the protocol ones included, so the raw httpx.HTTPStatusError branch is gone: each mode's _run() catches PipelineRequestError alone, and httpx is no longer a runtime dependency. A refused request is read out by problem_lines the way a failed run's report is: the reason, the pipe or concept each validation item names, the next step and the retry advice, with a multi-line reason hanging under its label. The codegen explain helper drops its httpx arm too, prints the server's next step, and no longer reads a 404 carrying the runner's error class as a missing route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xcr1G5gkEa4Aeoo1a7ATyh --- CHANGELOG.md | 11 ++ CLAUDE.md | 2 +- docs/cli-architecture.md | 18 ++- pyproject.toml | 12 +- scripts/codegen.py | 30 ++--- tests/conftest.py | 66 ++++++++++ tests/unit/test_attended_cli.py | 12 +- tests/unit/test_blocking_cli.py | 11 ++ tests/unit/test_detached_cli.py | 14 +- tests/unit/test_errors.py | 180 ++++++++++++++++++++------ tests/unit/test_method_sources.py | 54 ++++++-- uv.lock | 20 +-- widget/attended/cli.py | 9 +- widget/blocking/cli.py | 9 +- widget/detached/cli.py | 9 +- widget/errors.py | 208 ++++++++++++++++++------------ 16 files changed, 477 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21d514..50f408d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Changed + +- **`pipelex-sdk` 0.14.0 and `mthds` 0.17.0 are the floors, and `httpx` is no longer a dependency (Breaking)**: from `pipelex-sdk` 0.14.0 every route, `execute` and `start` among them, raises the typed `ApiResponseError` on a non-2xx answer, so each mode's `_run()` catches `PipelineRequestError` alone and nothing in the project catches or parses a raw `httpx.HTTPStatusError` any more. Code copied from the starter that caught `httpx.HTTPStatusError` catches `ApiResponseError` and reads `exc.status` where it read `exc.response.status_code`. + +### Fixed + +- **A refused run says why, where and what to do**: a method the API refuses to run, met by `widget blocking …`, `widget attended …` or `widget detached …`, now prints the refusal's reason, the pipe or concept each of its validation errors names, the next step the server advises and whether running it again can succeed, the same lines a failed run prints, instead of the reason alone; a reason that spans lines hangs under its label. Any other refused request reads out its reason the same way, an authentication failure included, beside the API-key hint. +- **`make codegen` and `make add-method` tell a missing route from a 404 the route answered**: a 404 carrying the runner's error class, such as a `method_ref` whose package does not exist, is reported with the server's reason instead of sending you to check `PIPELEX_BASE_URL`, and any other refused request prints the server's next step under its reason. + ## [v0.1.1] - 2026-09-27 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index a13723a..8900647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi - Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests. - **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `widget blocking …` (`client.execute` — one call, dies at the hosted ~30s cap), `widget attended …` (`client.start` + `client.wait_for_result` — durable, you wait), `widget detached …` (`client.start` only — durable, you collect it later with `widget detached status|result|wait `). Attended and detached start the *same* durable run; the axis they name is who waits. There is no default mode and no `--mode` option: `widget/cli.py` is a thin assembler (`load_dotenv` callback + three `add_typer` calls in reading order) and nothing else. -- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report — `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print — and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. +- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results → stdout, progress → stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments — `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file → `{"concept": "Document", "content": …}` envelope — `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder — and the `SAMPLE_*` constants), `widget/errors.py` (SDK error → message + hint, hints naming the mode groups; every route of the SDK raises the typed `ApiResponseError` on a non-2xx answer, and `problem_lines` reads its **problem+json** document out — the reason, the pipe or concept each validation item names, the next step and the retry advice — while the hint branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` → "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report — `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print — and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** — the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes — a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method — are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner — the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. - **Full demo matrix, guarded.** All three demos exist in all three modes: `extract-entities` (text in), `summarize-pdf` (a *file* in), `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap — `widget blocking generate-image` is *expected to fail*, and that is the teaching moment for the durable modes. The near-duplication across mode files is the pedagogy (diff two mode files and only the lifecycle helper differs); `tests/unit/test_mode_symmetry.py` keeps it from drifting. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`. - The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) all return the SDK's `RunResults` — one object carrying the resolved output, what the run consumed and the references to the files it produced. The blocking mode reaches it through `pipelex_sdk.execute_result.results_from_execute`, the SDK's public lift (0.10.2), so no mode reads the runner's raw `pipe_output`. The blocking/attended demo commands narrow `results.main_stuff` inline — e.g. `ExtractedEntities.model_validate(results.main_stuff)` — into the generated model, then hand `results` to the cost report and the download. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare, no cost — the run isn't done), and the run-id commands (`wait`/`result`) print the output generically **and** its produced files and cost report — no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer. - The modes spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the path for you (the production shortcut). The starter writes them out because teaching the difference is the point. diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index 43033e7..057f8c5 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -43,7 +43,7 @@ All three have the same four-part shape, so they diff cleanly: 1. **Module docstring** — the mode's contract in a paragraph, plus its copy-paste contract. 2. **App + consoles** — its own `typer.Typer`, its own `Console()` (stdout, for results — pipeable) and `Console(stderr=True)` (stderr, for progress chatter). 3. **The lifecycle helper** — one public async function that *is* the mode (`detached` adds the run-id lifecycle helpers described below). It gets a public name because it is the featured code, and it is what the unit tests patch and the e2e tests call directly. -4. **The demo commands + a private `_run()`** — each command reads its input, reads its bundle, awaits the lifecycle helper through `_run()` (`asyncio.run` + the single `except (PipelineRequestError, httpx.HTTPStatusError)` that presents via `widget/errors.py`), narrows the result into its *generated* model, prints JSON to stdout, brings down any file the result references, and prints the run's cost report to stderr. +4. **The demo commands + a private `_run()`** — each command reads its input, reads its bundle, awaits the lifecycle helper through `_run()` (`asyncio.run` + the single `except PipelineRequestError` that presents via `widget/errors.py`), narrows the result into its *generated* model, prints JSON to stdout, brings down any file the result references, and prints the run's cost report to stderr. The lifecycle helpers take the bundle as `mthds_contents: list[str]` — one string per `.mthds` file — and pass it straight to the SDK. The three demos are single-file methods, so each reads its `main.mthds` and wraps it as `mthds_contents=[bundle]`. A method dir may instead hold several `.mthds` files (a multi-file bundle split across pipes with `signature_for` cross-file declarations); read them all with `[p.read_text() for p in sorted((METHODS_DIR / "").glob("*.mthds"))]` and hand that list to the helper unchanged. Concatenating the files into one string would be invalid TOML — the list is the interface for exactly this reason. @@ -69,9 +69,21 @@ Three SDK capabilities show up in every result-producing path: **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) — `RUN_ID=$(widget detached generate-image "…")` just works. -**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. +**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises, a non-2xx answer from any route included), maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. -The protocol routes (`execute`/`start`/`runs/*`) surface a non-2xx as a raw `httpx.HTTPStatusError`, whose default string is useless (`Client error '400 Bad Request' for url …` + an MDN link). `widget/errors.py` instead reads the API's RFC 7807 **problem+json** body and shows the server's own `detail`, branching on the structured `error_type` (never the transport status) for the cases worth a hint — a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`. +**A refused request says why, where and what to do.** Every route of the SDK — the protocol routes (`execute`, `start`, `runs/*`) as much as the product ones — raises the typed `ApiResponseError` on a non-2xx answer, with the API's RFC 9457 **problem+json** document parsed onto it: its `title` and `detail`, the runner's `error_type`, the `validation_errors` of a method it refused to run, the `user_action` it advises and whether the request is `retryable`. `problem_lines` reads that document out the way `report_lines` reads a failed run's report, so a method the API will not run reads like this whichever mode you met it with: + +```text +Error: The API answered 422 Unprocessable Entity. + Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found in the model deck + + Did you mean: gpt-5.5, gpt-5.4, gpt-5.6-sol, gpt-5.4-pro, gpt-5.6-luna + Pipe: draft_pitch + Next step: Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one + Retry: running it again will fail the same way until the cause is fixed. +``` + +The reason is the problem's `title` and `detail`. Each validation item follows it, located by the pipe or concept it names; the runner writes a refusal's `detail` from its items, so an item whose message the reason already says is reduced to where it is rather than printed twice. The lines of a multi-line reason hang under its label. As under a failed run's report, the next step is the advice, and no hint competes with it. The branch that earns a hint goes on the structured `error_type`, never the wording: a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`, which the server cannot know to say. The HTTP status decides only an authentication failure (`401`/`403`), which gets the API-key hint and still reads out its reason, because a `403` can be a key that was recognised but may not use the route. **A failed run says why.** A durable run that ended without a result carries the error report the runner stored when it failed, which the SDK hands back typed as `RunErrorReport` on `RunFailedError.error` (out of `wait_for_result`, `start_and_wait` and an artifact download), on the failed arm of `get_run_result`, and on the status read's `RunRead.error`. `present_failed_run` presents all three the same way, and `report_lines` reads the report out as the lines a person reads: diff --git a/pyproject.toml b/pyproject.toml index bd43d6e..e7e3735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,12 @@ classifiers = [ # cost report). A transitive dependency is not a contract, so a release that dropped either would # break a fresh install of a template every new project is cloned from. The `mthds` floor is the # version `pipelex-sdk` pins exactly today, which is a floor and not a second pin: the SDK's exact -# pin always satisfies it, so bumping the SDK never has to be matched here. +# pin always satisfies it, so bumping the SDK never has to be matched here. `httpx` is not among +# them: since `pipelex-sdk` 0.14.0 every route raises the SDK's typed `ApiResponseError`, so +# nothing here imports it, and it arrives only as the SDK's own transport. dependencies = [ - "httpx>=0.27.0", - "mthds>=0.16.0", - "pipelex-sdk>=0.13.0", + "mthds>=0.17.0", + "pipelex-sdk>=0.14.0", "python-dotenv>=1.0.0", "rich>=13.0.0", "typer>=0.15.0", @@ -54,7 +55,10 @@ widget = ["py.typed", "methods/*/*.mthds", "methods/*/method.json"] "*" = ["codegen.lock"] [project.optional-dependencies] +# `httpx` is here because the tests import it to fake the SDK's transport (`tests/conftest.py`), which +# `widget/` never does. dev = [ + "httpx>=0.27.0", "mypy==1.19.1", "pipelex-tools>=0.7.2", "pyright>=1.1.411", diff --git a/scripts/codegen.py b/scripts/codegen.py index e1fb681..52cd4b3 100644 --- a/scripts/codegen.py +++ b/scripts/codegen.py @@ -45,7 +45,6 @@ from pathlib import Path from urllib.parse import urlparse -import httpx from dotenv import load_dotenv from pipelex_sdk.client import PipelexAPIClient from pipelex_sdk.codegen_writer import write_codegen_tree @@ -198,19 +197,16 @@ def explain(exc: Exception, base_url: str, route: str = "POST /v1/codegen") -> s `route` is a parameter because `scripts/add_method.py` reuses this on `POST /v1/validate`, and a validate failure reported against the codegen route would send the reader to the wrong place. + Every route of the SDK, `validate` among the protocol ones, raises `ApiResponseError` on a + non-2xx answer, so that one class carries the status, the server's reason and its next step. """ - status: int | None = None - server_message: str | None = None - if isinstance(exc, ApiResponseError): - status = exc.status - server_message = exc.server_message - elif isinstance(exc, httpx.HTTPStatusError): - # The protocol routes (`validate` among them) surface a raw httpx error rather than the - # SDK's own class, so without this arm their failures print as an httpx one-liner with a - # link to MDN and nothing about what to do next. - status = exc.response.status_code - server_message = exc.response.text.strip() or None - if status == 404: + if not isinstance(exc, ApiResponseError): + return str(exc) + status = exc.status + # A route this server lacks answers a bare 404, with neither the platform's `code` nor the + # runner's `error_type`. A 404 carrying either is an answer from the route itself — a + # `method_ref` whose package does not exist, say — and the base URL is not what to change. + if status == 404 and exc.code is None and exc.error_type is None: return ( f"this base URL does not serve {route} (HTTP 404).\n" f" Base URL: {base_url}\n" @@ -220,15 +216,15 @@ def explain(exc: Exception, base_url: str, route: str = "POST /v1/codegen") -> s if status == 403: # Not a base-URL problem: a 403 on a product route is the platform's surface-access # gate, so sending the user to edit PIPELEX_BASE_URL would be the wrong advice. - discriminant = f" {exc.code}" if isinstance(exc, ApiResponseError) and exc.code else "" + discriminant = f" {exc.code}" if exc.code else "" return ( f"PIPELEX_API_KEY may not use {route} (HTTP 403{discriminant}).\n" f" Base URL: {base_url}\n" " The key was recognised; this surface is not enabled for it." ) - if status is not None: - return f"HTTP {status} from {route} — {server_message or exc}" - return str(exc) + reason = exc.server_message or exc.title or exc.error_type or exc.code or exc.response_body.strip() or exc.status_text + next_step = f"\n Next step: {exc.user_action.detail}" if exc.user_action else "" + return f"HTTP {status} from {route} — {reason}{next_step}" async def generate_method(client: PipelexAPIClient, method: MethodSource) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index ecc37b8..f8266f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,11 @@ +import asyncio + +import httpx import pytest from dotenv import load_dotenv from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult, DownloadedArtifact +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError from pipelex_sdk.upload import UploadRecord from pytest_mock import MockerFixture @@ -55,3 +60,64 @@ def stub_download(mocker: MockerFixture) -> str: async_cm.__aexit__ = mocker.AsyncMock(return_value=None) mocker.patch("widget.artifacts.PipelexAPIClient", return_value=async_cm) return STUB_ARTIFACT_PATH + + +#: The hosted plane's answer to a `POST /v1/start` whose bundle names a model the deck does not know, +#: as `api-dev.pipelex.com` gave it on 2026-09-27 (the execution-errors acceptance reading, case 1): +#: the runner's refusal, relayed with its reason, the item naming the failing pipe and the next step. +_REFUSED_START_MESSAGE = ( + "Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found in the model deck\n\n" + "Did you mean: gpt-5.5, gpt-5.4, gpt-5.6-sol, gpt-5.4-pro, gpt-5.6-luna" +) +_REFUSED_START_NEXT_STEP = "Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one" +_REFUSED_START_BODY: dict[str, object] = { + "type": "https://docs.pipelex.com/latest/errors/validate-bundle-error/", + "title": "Validate bundle", + "detail": _REFUSED_START_MESSAGE, + "error_category": "configuration", + "error_domain": "input", + "retryable": False, + "error_type": "ValidateBundleError", + "validation_errors": [ + { + "category": "pipe_validation", + "message": _REFUSED_START_MESSAGE, + "error_type": "unknown_model", + "pipe_code": "draft_pitch", + "domain_code": "sales_copy", + "field_path": "pipe.draft_pitch.model", + "field_name": "model", + "model_reference": "gpt-5.1", + "model_type": "llm", + "suggestions": ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol", "gpt-5.4-pro", "gpt-5.6-luna"], + } + ], + "user_action": {"kind": "change_input", "detail": _REFUSED_START_NEXT_STEP}, + "status": 422, + "instance": "urn:pipelex:request:req_97e7f218-e071-4682-b9fe-30c1193cff9d", + "request_id": "req_97e7f218-e071-4682-b9fe-30c1193cff9d", +} + + +@pytest.fixture +def refused_start() -> ApiResponseError: + """The refusal above as `pipelex-sdk` raises it: a real client's `start` answered by a faked transport. + + Built through the client rather than by hand, so the tests that take it hold the SDK in the lock to + its promise that every route — `start`, a protocol route, among them — raises the typed + `ApiResponseError` with the problem document parsed onto it. + """ + + def answer(request: httpx.Request) -> httpx.Response: + return httpx.Response(422, json=_REFUSED_START_BODY, headers={"content-type": "application/problem+json"}, request=request) + + async def start() -> None: + async with PipelexAPIClient(api_key="test-key", base_url="https://api.example.com") as client: + if client.client is not None: + await client.client.aclose() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(answer)) + await client.start(pipe_code="sales_copy.pitch_product", mthds_contents=["domain = 'sales_copy'"]) + + with pytest.raises(ApiResponseError) as caught: + asyncio.run(start()) + return caught.value diff --git a/tests/unit/test_attended_cli.py b/tests/unit/test_attended_cli.py index 36239b9..b2b7202 100644 --- a/tests/unit/test_attended_cli.py +++ b/tests/unit/test_attended_cli.py @@ -1,7 +1,7 @@ from pathlib import Path from pipelex_sdk.error_models import RunErrorReport, UserAction -from pipelex_sdk.errors import RunFailedError +from pipelex_sdk.errors import ApiResponseError, RunFailedError from pipelex_sdk.runs import RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -131,3 +131,13 @@ def test_a_failed_run_prints_the_reason_the_next_step_and_the_retry_advice(self, assert "Reason: LLM completion — The model refused the request." in output assert "Next step: Rephrase the prompt, or pick another model." in output assert "Retry: running it again will fail the same way until the cause is fixed." in output + + def test_a_refused_start_prints_the_reason_the_pipe_and_the_next_step(self, mocker: MockerFixture, refused_start: ApiResponseError): + mocker.patch("widget.attended.cli.start_and_wait", side_effect=refused_start) + result = runner.invoke(app, ["attended", "extract-entities", "some text"]) + assert result.exit_code == 1 + output = " ".join(result.output.split()) + assert "The API answered 422 Unprocessable Entity." in output + assert "Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found" in output + assert "Pipe: draft_pitch" in output + assert "Next step: Edit the bundle as each validation error says" in output diff --git a/tests/unit/test_blocking_cli.py b/tests/unit/test_blocking_cli.py index 8241f6f..46c50b8 100644 --- a/tests/unit/test_blocking_cli.py +++ b/tests/unit/test_blocking_cli.py @@ -1,5 +1,6 @@ from pathlib import Path +from pipelex_sdk.errors import ApiResponseError from pipelex_sdk.runs import RunResults from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -110,3 +111,13 @@ def test_generate_image_sends_the_prompt(self, mocker: MockerFixture, stub_downl assert stub_download in result.output assert execute_mock.await_args is not None assert execute_mock.await_args.kwargs["inputs"] == {"image_prompt": "a cat wearing a hat"} + + def test_a_refused_execute_prints_the_reason_the_pipe_and_the_next_step(self, mocker: MockerFixture, refused_start: ApiResponseError): + mocker.patch("widget.blocking.cli.execute_pipe", side_effect=refused_start) + result = runner.invoke(app, ["blocking", "extract-entities", "some text"]) + assert result.exit_code == 1 + output = " ".join(result.output.split()) + assert "The API answered 422 Unprocessable Entity." in output + assert "Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found" in output + assert "Pipe: draft_pitch" in output + assert "Next step: Edit the bundle as each validation error says" in output diff --git a/tests/unit/test_detached_cli.py b/tests/unit/test_detached_cli.py index c0b3951..3c13470 100644 --- a/tests/unit/test_detached_cli.py +++ b/tests/unit/test_detached_cli.py @@ -1,7 +1,7 @@ from pathlib import Path from pipelex_sdk.error_models import RunErrorReport, UserAction -from pipelex_sdk.errors import RunFailedError +from pipelex_sdk.errors import ApiResponseError, RunFailedError from pipelex_sdk.runs import RunRead, RunResultCompleted, RunResultFailed, RunResultRunning, RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -122,6 +122,18 @@ def test_generate_image_sends_the_prompt(self, mocker: MockerFixture): assert start_mock.await_args.kwargs["inputs"] == {"image_prompt": "a cat wearing a hat"} assert result.stdout.strip() == RUN_ID + def test_a_refused_start_prints_the_reason_the_pipe_and_the_next_step(self, mocker: MockerFixture, refused_start: ApiResponseError): + mocker.patch("widget.detached.cli.start_pipe", side_effect=refused_start) + result = runner.invoke(app, ["detached", "extract-entities", "some text"]) + assert result.exit_code == 1 + # No run was created, so nothing reaches stdout for `$(widget detached …)` to capture as an id. + assert result.stdout == "" + output = " ".join(result.output.split()) + assert "The API answered 422 Unprocessable Entity." in output + assert "Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found" in output + assert "Pipe: draft_pitch" in output + assert "Next step: Edit the bundle as each validation error says" in output + def test_wait_prints_the_raw_main_stuff(self, mocker: MockerFixture): attend_mock = mocker.patch("widget.detached.cli.attend_run", return_value=_results(ENTITIES_CONTENT)) result = runner.invoke(app, ["detached", "wait", RUN_ID]) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 1b419f0..34fde2b 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,11 +1,11 @@ import io -from collections.abc import Mapping -import httpx import pytest +from mthds.runners.api.problem import UserAction as ProblemUserAction from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult from pipelex_sdk.error_models import RunErrorReport, UserAction from pipelex_sdk.errors import ( + ApiResponseError, ApiUnreachableError, ArtifactAuthenticationError, ArtifactOperationError, @@ -19,9 +19,10 @@ UploadAuthenticationError, ) from pipelex_sdk.runs import RunStatus +from pipelex_sdk.validation_models import ValidationErrorCategory, ValidationErrorItem from rich.console import Console -from widget.errors import ErrorPresentation, present_error, print_error, report_lines +from widget.errors import ErrorPresentation, present_error, print_error, problem_lines, report_lines # A failed run's stored report as the runner writes it for an inference failure, and the platform's # sentence about the run with and without one (`Run finished with status : `). @@ -37,10 +38,37 @@ UNREPORTED_DETAIL = "Run finished with status FAILED; no result available" -def _http_status_error(status_code: int, *, problem: Mapping[str, object] | None = None) -> httpx.HTTPStatusError: - request = httpx.Request("POST", "https://api.pipelex.com/v1/start") - response = httpx.Response(status_code, request=request, json=problem) if problem is not None else httpx.Response(status_code, request=request) - return httpx.HTTPStatusError("boom", request=request, response=response) +def _api_response_error( + status: int, + status_text: str, + *, + title: str | None = None, + detail: str | None = None, + error_type: str | None = None, + code: str | None = None, + user_action: ProblemUserAction | None = None, + retryable: bool | None = None, + validation_errors: list[ValidationErrorItem] | None = None, +) -> ApiResponseError: + """A non-2xx answer as the SDK raises it, carrying only the problem members a test names.""" + return ApiResponseError( + f"API POST /v1/start failed ({status}): {detail or title or status_text}", + api_url="https://api.pipelex.com", + status=status, + status_text=status_text, + response_body="", + title=title, + server_message=detail, + error_type=error_type, + code=code, + user_action=user_action, + retryable=retryable, + validation_errors=validation_errors, + ) + + +def _item(message: str, *, pipe_code: str | None = None, concept_code: str | None = None) -> ValidationErrorItem: + return ValidationErrorItem(category=ValidationErrorCategory.PIPE_VALIDATION, message=message, pipe_code=pipe_code, concept_code=concept_code) class TestPresentError: @@ -56,46 +84,50 @@ def test_lifecycle_unavailable_hints_blocking(self): assert presentation.hint is not None assert "widget blocking" in presentation.hint - def test_http_auth_error_hints_api_key(self): - # The protocol routes (execute/start/runs) raise raw httpx.HTTPStatusError, - # not ApiResponseError — an auth failure must still get the key hint. - for status_code in (401, 403): - presentation = present_error(_http_status_error(status_code)) - assert str(status_code) in presentation.message + def test_a_refused_start_reads_out_the_reason_the_pipe_the_next_step_and_the_retry_advice(self, refused_start: ApiResponseError): + # The protocol routes raise the typed error since pipelex-sdk 0.14.0, so a method the plane + # will not run says why, where and what to do — not only the transport status. + presentation = present_error(refused_start) + assert presentation.message == "The API answered 422 Unprocessable Entity." + assert presentation.details == ( + "Reason: Validate bundle — Pipe 'draft_pitch' (PipeLLM), field 'model': Model handle 'gpt-5.1' was not found in the model deck" + "\n\nDid you mean: gpt-5.5, gpt-5.4, gpt-5.6-sol, gpt-5.4-pro, gpt-5.6-luna", + # The item's message is the reason's detail verbatim, so only where it is is added. + "Pipe: draft_pitch", + "Next step: Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one", + "Retry: running it again will fail the same way until the cause is fixed.", + ) + # The next step is the advice, so no hint competes with it. + assert presentation.hint is None + + def test_an_auth_refusal_hints_api_key_and_keeps_the_reason(self): + # A 403 can be a key that was recognised but may not use the route, so the reason is read out beside the hint. + for status, status_text in ((401, "Unauthorized"), (403, "Forbidden")): + presentation = present_error(_api_response_error(status, status_text, detail="This key may not use the route.")) + assert presentation.message == f"The API rejected the request ({status} {status_text})." + assert presentation.details == ("Reason: This key may not use the route.",) assert presentation.hint is not None assert "PIPELEX_API_KEY" in presentation.hint - def test_http_server_error_has_no_hint(self): - presentation = present_error(_http_status_error(500)) + def test_a_server_error_has_no_hint(self): + presentation = present_error(_api_response_error(500, "Internal Server Error", title="Internal error")) + assert presentation.details == ("Reason: Internal error",) assert presentation.hint is None def test_start_without_async_orchestration_hints_blocking(self): - # A synchronous-only runner rejects /start with this RFC 7807 error_type; - # the fix is to run the same demo under `widget blocking`. - problem = { - "error_type": "StartRequiresAsyncOrchestration", - "detail": "Orchestration mode 'direct' cannot honor fire-and-forget delivery. Use /execute instead.", - "status": 400, - } - presentation = present_error(_http_status_error(400, problem=problem)) - assert "Orchestration mode 'direct'" in presentation.message + # A synchronous-only runner refuses /start with this error_type; the fix is to run the same + # demo under `widget blocking`, which the server cannot know to say. + detail = "Orchestration mode 'direct' cannot honor fire-and-forget delivery. Use /execute instead." + presentation = present_error(_api_response_error(400, "Bad Request", detail=detail, error_type="StartRequiresAsyncOrchestration")) + assert presentation.message == detail assert presentation.hint is not None assert "widget blocking" in presentation.hint - def test_http_error_with_undecodable_body_falls_back_to_status(self): - # A non-UTF-8 body makes `response.json()` raise UnicodeDecodeError (not - # JSONDecodeError); the best-effort problem+json parse must still fall - # back to the status-only message instead of crashing mid-presentation. - request = httpx.Request("POST", "https://api.pipelex.com/v1/start") - response = httpx.Response(400, request=request, headers={"content-type": "application/problem+json"}, content=b"\xffnot-json") - presentation = present_error(httpx.HTTPStatusError("boom", request=request, response=response)) - assert presentation.message == "The API answered 400 Bad Request." - assert presentation.hint is None - - def test_http_error_surfaces_the_problem_detail(self): - problem = {"title": "Bad input", "detail": "Missing required input 'text'.", "status": 400} - presentation = present_error(_http_status_error(400, problem=problem)) - assert "Missing required input 'text'." in presentation.message + def test_an_answer_without_a_problem_document_names_the_status(self): + # A body that was not a problem document — a proxy's page, say — leaves every member `None`. + presentation = present_error(_api_response_error(502, "Bad Gateway")) + assert presentation.message == "The API answered 502 Bad Gateway." + assert presentation.details == () assert presentation.hint is None def test_unreachable_hints_base_url(self): @@ -209,6 +241,68 @@ def test_artifact_operation_hints_the_download_directory_without_saying_rerun(se def test_report_lines_read_out_what_the_report_carries(self, report: RunErrorReport | None, expected_lines: tuple[str, ...]): assert report_lines(report) == expected_lines + @pytest.mark.parametrize( + ("exc", "expected_lines"), + [ + pytest.param( + _api_response_error(400, "Bad Request", title="Bad input", detail="Missing required input 'text'."), + ("Reason: Bad input — Missing required input 'text'.",), + id="title and detail", + ), + pytest.param( + _api_response_error(404, "Not Found", error_type="PackageNotFoundError"), ("Reason: PackageNotFoundError",), id="runner class" + ), + pytest.param(_api_response_error(409, "Conflict", code="conflict"), ("Reason: conflict",), id="platform code"), + pytest.param( + _api_response_error( + 422, + "Unprocessable Entity", + title="Validate bundle", + detail="The method is invalid.", + validation_errors=[ + _item("Model handle 'gpt-5.1' was not found.", pipe_code="draft_pitch"), + _item("Field 'ideas' is not a list.", concept_code="TopicReview"), + _item("The bundle declares no domain."), + ], + ), + ( + "Reason: Validate bundle — The method is invalid.", + "In pipe draft_pitch: Model handle 'gpt-5.1' was not found.", + "In concept TopicReview: Field 'ideas' is not a list.", + "Problem: The bundle declares no domain.", + ), + id="items saying more than the reason", + ), + pytest.param( + _api_response_error( + 422, + "Unprocessable Entity", + validation_errors=[_item("Model handle 'gpt-5.1' was not found.", pipe_code="draft_pitch")], + user_action=ProblemUserAction(kind="change_input", detail="Fix the bundle."), + ), + ("In pipe draft_pitch: Model handle 'gpt-5.1' was not found.", "Next step: Fix the bundle."), + id="items without a reason", + ), + pytest.param( + _api_response_error( + 422, + "Unprocessable Entity", + detail="First. Second.", + validation_errors=[_item("First.", pipe_code="a"), _item("Second.", concept_code="B"), _item("Second.")], + ), + ("Reason: First. Second.", "Pipe: a", "Concept: B"), + id="items the reason already says", + ), + pytest.param( + _api_response_error(429, "Too Many Requests", detail="Slow down.", retryable=True), + ("Reason: Slow down.", "Retry: running it again may succeed."), + id="retryable", + ), + ], + ) + def test_problem_lines_read_out_what_the_problem_carries(self, exc: ApiResponseError, expected_lines: tuple[str, ...]): + assert problem_lines(exc) == expected_lines + def test_print_error_prints_the_message_the_details_and_the_hint(self): buffer = io.StringIO() print_error(Console(file=buffer, width=200), ErrorPresentation(message="Run run-9 failed.", hint="Do this.", details=("Reason: it broke.",))) @@ -217,6 +311,16 @@ def test_print_error_prints_the_message_the_details_and_the_hint(self): assert "Reason: it broke." in rendered assert "Hint: Do this." in rendered + def test_print_error_hangs_a_multi_line_detail_under_its_label(self): + # A refused method's reason ends with a blank line and the model names it suggests instead; at + # the margin, that suggestion would read as a line of its own. + buffer = io.StringIO() + print_error( + Console(file=buffer, width=200), + ErrorPresentation(message="Refused.", hint=None, details=("Reason: Unknown model.\n\nDid you mean: gpt-5?",)), + ) + assert buffer.getvalue() == "Error: Refused.\n Reason: Unknown model.\n\n Did you mean: gpt-5?\n" + def test_print_error_prints_server_text_verbatim_rather_than_as_markup(self): # A report's message is the runner's text, provider wording included: a bracketed span in it # must neither vanish as a style tag nor crash the print as an unmatched closing tag. diff --git a/tests/unit/test_method_sources.py b/tests/unit/test_method_sources.py index 1de29b7..8b1be44 100644 --- a/tests/unit/test_method_sources.py +++ b/tests/unit/test_method_sources.py @@ -7,8 +7,8 @@ truth and `widget/generated/` stays purely derived. Everything here is filesystem and request-shape work — no key, no network. The failure translation -the two scripts share (`explain`) is here too, because a failure reported as an httpx one-liner -with a link to MDN is the shape both of them exist to avoid. +the two scripts share (`explain`) is here too, because a failure reported without its route, its +reason or its fix is the shape both of them exist to avoid. """ from __future__ import annotations @@ -18,8 +18,8 @@ from pathlib import Path from typing import Any -import httpx import pytest +from mthds.runners.api.problem import UserAction from pipelex_sdk.errors import ApiResponseError from widget.manifest import ManifestError, MethodSelector, read_manifest, write_manifest @@ -128,23 +128,51 @@ def test_the_codegen_request_carries_exactly_one_selector(self, selector: Any, e def test_dashes_become_underscores(self): assert codegen.generated_package_dir("summarize-pdf").name == "summarize_pdf" - def test_a_raw_protocol_route_error_is_translated_like_an_sdk_one(self): - """`validate` surfaces `httpx.HTTPStatusError`, not the SDK's class — both must read alike.""" - request = httpx.Request("POST", "https://api.example.com/v1/validate") - exc = httpx.HTTPStatusError("nope", request=request, response=httpx.Response(403, request=request)) + def test_a_protocol_route_refusal_names_the_route_and_the_key(self): + """`validate` is a protocol route, and it raises the SDK's typed error like a product route does.""" + exc = ApiResponseError("nope", api_url="https://api.example.com", status=403, status_text="Forbidden", response_body="") message = codegen.explain(exc, "https://api.example.com", route="POST /v1/validate") assert "POST /v1/validate" in message assert "403" in message assert "may not use" in message - def test_an_unknown_status_still_names_the_route_and_the_server_message(self): - request = httpx.Request("POST", "https://api.example.com/v1/validate") - response = httpx.Response(422, request=request, text="method_ref is not supported here") - exc = httpx.HTTPStatusError("nope", request=request, response=response) + def test_an_unknown_status_names_the_route_the_reason_and_the_next_step(self): + exc = ApiResponseError( + "nope", + api_url="https://api.example.com", + status=422, + status_text="Unprocessable Entity", + response_body="", + server_message="method_ref is not supported here", + user_action=UserAction(kind="change_input", detail="Send the bundle's files instead."), + ) message = codegen.explain(exc, "https://api.example.com", route="POST /v1/validate") - assert "422" in message - assert "method_ref is not supported here" in message + assert message == "HTTP 422 from POST /v1/validate — method_ref is not supported here\n Next step: Send the bundle's files instead." + + def test_a_plain_text_answer_is_still_the_reason(self): + exc = ApiResponseError( + "nope", api_url="https://api.example.com", status=422, status_text="Unprocessable Entity", response_body="not supported\n" + ) + assert codegen.explain(exc, "https://api.example.com", route="POST /v1/validate") == "HTTP 422 from POST /v1/validate — not supported" def test_a_missing_route_sends_the_reader_to_the_base_url(self): exc = ApiResponseError("gone", api_url="https://api.example.com/v1/codegen", status=404, status_text="Not Found", response_body="") assert "PIPELEX_BASE_URL" in codegen.explain(exc, "https://api.example.com") + + def test_a_404_the_route_itself_answered_is_not_a_missing_route(self): + """A `method_ref` whose package does not exist is a 404 carrying the runner's class, not a wrong base URL.""" + exc = ApiResponseError( + "gone", + api_url="https://api.example.com", + status=404, + status_text="Not Found", + response_body="", + error_type="PackageNotFoundError", + server_message="No package at github.com/acme/nothing.", + ) + message = codegen.explain(exc, "https://api.example.com", route="POST /v1/validate") + assert "PIPELEX_BASE_URL" not in message + assert message == "HTTP 404 from POST /v1/validate — No package at github.com/acme/nothing." + + def test_a_failure_that_is_no_answer_reads_as_itself(self): + assert codegen.explain(OSError("disk full"), "https://api.example.com") == "disk full" diff --git a/uv.lock b/uv.lock index b853d31..7a11218 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -201,9 +201,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/03/4aca733536f741f741d0cba3ae3d23dfd56f691c53a0d7e3e7baea371585/mthds-0.16.0.tar.gz", hash = "sha256:cc8f54bc76c9ed13273e7cd2e1c66767e5478fc3640a26481bf099451d56aa29", size = 259394, upload-time = "2026-09-23T12:13:31.752Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/e68684d8bea26098f6a03339f97084038dba54b778ad7d55a07b73763e38/mthds-0.17.0.tar.gz", hash = "sha256:7515dea7a014ed55416e428c29172a7482d674a12bad73d1222bc5090d4c669c", size = 276301, upload-time = "2026-09-27T13:08:43.234Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/00/a80f90f0ed7ddff9886f925fbb8dd0061ec0b0e1af49901971664f1a3e12/mthds-0.16.0-py3-none-any.whl", hash = "sha256:67468669451278e8c17409c5fed60eafab8453fd5cd92f333d788b21f7500ebe", size = 103813, upload-time = "2026-09-23T12:13:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e2/dc57d0a6370546c2ef1563eacfdb5037c6f5d569c23f407d73acb0473c2e/mthds-0.17.0-py3-none-any.whl", hash = "sha256:cfd4530eb7760490489680edbbbaba448150086ac4b122ee272a64f271785ec4", size = 110748, upload-time = "2026-09-27T13:08:41.722Z" }, ] [[package]] @@ -283,7 +283,7 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -291,9 +291,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/47/0baf77c28bd66fd76d7dfe41c56a2040a99a2f1fbdf12c81fd7538cba423/pipelex_sdk-0.13.0.tar.gz", hash = "sha256:2ad0a9f619742c99b23df7dc5a39fb1feb33ef633d249674e7c2958b03150107", size = 391622, upload-time = "2026-09-27T10:38:31.576Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/2b/d23ca062ba60fb78c38cc61c3741875ab68e134028cf09915bde5abf51f4/pipelex_sdk-0.14.0.tar.gz", hash = "sha256:ecafc6e4411a8fcaf8a66499e4620f80e7ee8de2f565996d051b49b40dbfb027", size = 402382, upload-time = "2026-09-27T13:53:45.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/5c/1929a77768c58a3e9917a7b811ed8cc2999abfdac3345f2252724baab0bd/pipelex_sdk-0.13.0-py3-none-any.whl", hash = "sha256:75b1fe64c4b4d8db56d602300da4da70c97d98b116f09fd17753232c7bc4498d", size = 132322, upload-time = "2026-09-27T10:38:30.21Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2a/41d43d66fea39fdf9ba0e721e6076e0f4e109cb0b54909b658b5619c114f/pipelex_sdk-0.14.0-py3-none-any.whl", hash = "sha256:5da67d3585f2e794c53e961ccd4c2ab8de4ea9a20197357689afd2a50c314e73", size = 134680, upload-time = "2026-09-27T13:53:43.693Z" }, ] [[package]] @@ -632,7 +632,6 @@ name = "widget" version = "0.1.1" source = { editable = "." } dependencies = [ - { name = "httpx" }, { name = "mthds" }, { name = "pipelex-sdk" }, { name = "python-dotenv" }, @@ -642,6 +641,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "pipelex-tools" }, { name = "pyright" }, @@ -654,10 +654,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27.0" }, - { name = "mthds", specifier = ">=0.16.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "mthds", specifier = ">=0.17.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, - { name = "pipelex-sdk", specifier = ">=0.13.0" }, + { name = "pipelex-sdk", specifier = ">=0.14.0" }, { name = "pipelex-tools", marker = "extra == 'dev'", specifier = ">=0.7.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.411" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, diff --git a/widget/attended/cli.py b/widget/attended/cli.py index 84ea3db..810a3f3 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -21,7 +21,6 @@ from pathlib import Path from typing import Annotated, Any, Coroutine, TypeVar -import httpx import typer from mthds.protocol.exceptions import PipelineRequestError from pipelex_sdk.client import PipelexAPIClient @@ -167,13 +166,13 @@ def generate_image( def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, except the - raw `httpx.HTTPStatusError` its protocol routes surface. Nothing else is caught: - an unexpected exception crashes loudly with its traceback. + Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx + answer from any route included (`ApiResponseError`). Nothing else is caught: an + unexpected exception crashes loudly with its traceback. """ try: return asyncio.run(coro) - except (PipelineRequestError, httpx.HTTPStatusError) as exc: + except PipelineRequestError as exc: print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index 3ff99f0..38a823d 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -14,7 +14,6 @@ from pathlib import Path from typing import Annotated, Any, Coroutine, TypeVar -import httpx import typer from mthds.protocol.exceptions import PipelineRequestError from pipelex_sdk.client import PipelexAPIClient @@ -147,13 +146,13 @@ def generate_image( def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, except the - raw `httpx.HTTPStatusError` its protocol routes surface. Nothing else is caught: - an unexpected exception crashes loudly with its traceback. + Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx + answer from any route included (`ApiResponseError`). Nothing else is caught: an + unexpected exception crashes loudly with its traceback. """ try: return asyncio.run(coro) - except (PipelineRequestError, httpx.HTTPStatusError) as exc: + except PipelineRequestError as exc: print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: diff --git a/widget/detached/cli.py b/widget/detached/cli.py index 31c2945..07301f7 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -22,7 +22,6 @@ from pathlib import Path from typing import Annotated, Any, Coroutine, TypeVar -import httpx import typer from mthds.protocol.exceptions import PipelineRequestError from pipelex_sdk.client import PipelexAPIClient @@ -243,13 +242,13 @@ def _print_results(results: RunResults) -> None: def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, except the - raw `httpx.HTTPStatusError` its protocol routes surface. Nothing else is caught: - an unexpected exception crashes loudly with its traceback. + Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx + answer from any route included (`ApiResponseError`). Nothing else is caught: an + unexpected exception crashes loudly with its traceback. """ try: return asyncio.run(coro) - except (PipelineRequestError, httpx.HTTPStatusError) as exc: + except PipelineRequestError as exc: print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: diff --git a/widget/errors.py b/widget/errors.py index 8937ba2..8114d14 100644 --- a/widget/errors.py +++ b/widget/errors.py @@ -12,19 +12,20 @@ when it failed, which the SDK hands back typed as `RunErrorReport`: `report_lines` reads out its reason, its next step and its retry advice, and the detached `status` and `result` commands print the same lines, so a failed run reads the same wherever you -meet it. +meet it. A request the API refused — a method it will not run, a key it does not accept — +arrives as the SDK's typed `ApiResponseError` whatever the route, and `problem_lines` +reads its problem document out the same way: the reason, the pipes the refusal names, +the next step and the retry advice. Error presentation is orthogonal to execution mode, so it is shared — but the hints name the mode *groups*, since the fix for a timed-out blocking run is to rerun it under another group. """ -import json -from typing import Any, NamedTuple, cast +from typing import NamedTuple -import httpx from mthds.protocol.exceptions import PipelineRequestError -from pipelex_sdk.error_models import RunErrorReport, UserAction +from pipelex_sdk.error_models import RunErrorReport from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -41,6 +42,7 @@ UploadAuthenticationError, ) from pipelex_sdk.runs import RunStatus +from pipelex_sdk.validation_models import ValidationErrorItem from rich.console import Console from rich.markup import escape @@ -56,6 +58,11 @@ "contact_support": "Contact support with the run id.", } +#: The runner's `error_type` for a `/start` it cannot honor: its orchestration is synchronous only. +_START_REQUIRES_ASYNC_ORCHESTRATION = "StartRequiresAsyncOrchestration" + +_API_KEY_HINT = "Set PIPELEX_API_KEY in your environment or .env file — get a key at https://app.pipelex.com" + class ErrorPresentation(NamedTuple): """What the CLI shows for a failed command: the error, the lines that explain it, and what to do about it.""" @@ -66,16 +73,16 @@ class ErrorPresentation(NamedTuple): details: tuple[str, ...] = () -def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPresentation: +def present_error(exc: PipelineRequestError) -> ErrorPresentation: """Map an SDK error to a CLI-facing message and an actionable hint. - The SDK's protocol routes (`execute`, `start`, `runs/*`) surface non-2xx - responses as raw `httpx.HTTPStatusError` (the inherited regime); the typed - `ApiResponseError` only rides the product routes. Both are mapped here so - an auth failure gets the API-key hint whichever route raised it. + Every route of the SDK — the protocol routes (`execute`, `start`, `runs/*`) as much + as the product ones — raises the typed `ApiResponseError` on a non-2xx answer, with + the answer's problem document parsed onto it, so one branch presents every refusal + whichever route raised it. The SDK's own translations of a status come first: a + blocking run cut off at the gateway and a `/start` on a server with no run store + each have a class of their own. """ - if isinstance(exc, httpx.HTTPStatusError): - return _present_http_status_error(exc) if isinstance(exc, PipelineExecuteTimeoutError): return ErrorPresentation( message=f"The blocking run exceeded the hosted gateway's ~30s synchronous cap ({exc.elapsed_seconds:.0f}s elapsed).", @@ -87,15 +94,7 @@ def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPre hint="You are talking to a bare runner — use `widget blocking ...`.", ) if isinstance(exc, ApiResponseError): - if exc.status in (401, 403): - return ErrorPresentation( - message=f"The API rejected the request ({exc.status} {exc.status_text}).", - hint="Set PIPELEX_API_KEY in your environment or .env file — get a key at https://app.pipelex.com", - ) - return ErrorPresentation( - message=f"The API answered {exc.status} {exc.status_text}: {exc.server_message or exc}", - hint=None, - ) + return _present_api_response_error(exc) if isinstance(exc, ApiUnreachableError): return ErrorPresentation( message=f"Could not reach the Pipelex API at {exc.api_url}.", @@ -156,21 +155,37 @@ def report_lines(report: RunErrorReport | None) -> tuple[str, ...]: """ if report is None: return () - lines: list[str] = [] - reason = _reason(report) - if reason: - lines.append(f"Reason: {reason}") - next_step = _next_step(report.user_action) - if next_step: - lines.append(f"Next step: {next_step}") - match report.retryable: - case True: - lines.append("Retry: running it again may succeed.") - case False: - lines.append("Retry: running it again will fail the same way until the cause is fixed.") - case None: - pass - return tuple(lines) + user_action = report.user_action + return _advice_lines( + reason=_reason(title=report.title, message=report.message, last_resort=report.error_type), + next_step=_next_step(kind=user_action.kind, detail=user_action.detail) if user_action else None, + retryable=report.retryable, + ) + + +def problem_lines(exc: ApiResponseError) -> tuple[str, ...]: + """A refused request's problem document as the lines a person reads, worded as `report_lines` words a run's. + + The reason is the problem's `title` and `detail` (its `error_type`, else the platform's `code`, + when it carries neither); then one line per item a refused method's `validation_errors` lists, + naming the pipe or concept it is about, so the reader knows where to look; then the next step + the server advised and whether running it again can succeed. Empty when the answer carried + none of these — a body that was not a problem document, say. + + The runner writes a refusal's `detail` from its items — the one item's message, verbatim — so + an item whose message the reason already says is reduced to where it is, rather than printed + twice. + """ + reason = _reason(title=exc.title, message=exc.server_message, last_resort=exc.error_type or exc.code) + said = _text(exc.server_message) + items = tuple(line for item in exc.validation_errors or () if (line := _validation_line(item, said=said))) + next_step = _next_step(kind=exc.user_action.kind, detail=exc.user_action.detail) if exc.user_action else None + lines = _advice_lines(reason=reason, next_step=next_step, retryable=exc.retryable) + if not items: + return lines + # The items sit between the reason and the advice, which is about them. + split = 1 if reason else 0 + return lines[:split] + items + lines[split:] def print_error(console: Console, presentation: ErrorPresentation) -> None: @@ -182,11 +197,22 @@ def print_error(console: Console, presentation: ErrorPresentation) -> None: """ console.print(f"[red]Error:[/red] {escape(presentation.message)}") for line in presentation.details: - console.print(f" {escape(line)}") + console.print(escape(_indented(line))) if presentation.hint: console.print(f"\n[yellow]Hint:[/yellow] {escape(presentation.hint)}") +def _indented(line: str) -> str: + """A detail line under the message, the continuation lines of a multi-line one hanging under its label. + + The server's text can span lines — a refused method's reason ends with a blank line and the + model names it suggests instead — and a continuation line at the margin would read as a line + of its own. + """ + first, *rest = line.splitlines() or [""] + return "\n".join([f" {first}", *(f" {part}" if part.strip() else "" for part in rest)]) + + def _how_the_run_ended(status: RunStatus) -> str: match status: case RunStatus.FAILED: @@ -212,20 +238,54 @@ def _hint_without_a_reason(*, run_id: str, status: RunStatus) -> str: return f"Check it again with `widget detached status {run_id}`." -def _reason(report: RunErrorReport) -> str | None: - """The report's title and message, whichever it carries, or its exception class as a last resort.""" - title = _text(report.title) - message = _text(report.message) +def _advice_lines(*, reason: str | None, next_step: str | None, retryable: bool | None) -> tuple[str, ...]: + """The reason, the next step and the retry advice, each only when known. + + `retryable` is left unsaid when it is `None`, which means unknown rather than no. + """ + lines: list[str] = [] + if reason: + lines.append(f"Reason: {reason}") + if next_step: + lines.append(f"Next step: {next_step}") + match retryable: + case True: + lines.append("Retry: running it again may succeed.") + case False: + lines.append("Retry: running it again will fail the same way until the cause is fixed.") + case None: + pass + return tuple(lines) + + +def _reason(*, title: str | None, message: str | None, last_resort: str | None) -> str | None: + """The title and the message, whichever are there, or the error's class name as a last resort.""" + title = _text(title) + message = _text(message) if title and message: return f"{title} — {message}" - return title or message or _text(report.error_type) + return title or message or _text(last_resort) -def _next_step(user_action: UserAction | None) -> str | None: +def _next_step(*, kind: str | None, detail: str | None) -> str | None: """The advice's own words, or a sentence for its kind when it gives none.""" - if user_action is None: - return None - return _text(user_action.detail) or _NEXT_STEP_BY_KIND.get(user_action.kind or "") + return _text(detail) or _NEXT_STEP_BY_KIND.get(kind or "") + + +def _validation_line(item: ValidationErrorItem, *, said: str | None) -> str | None: + """One item of a refused method's `validation_errors`, located by its pipe or concept when it names one. + + `said` is the refusal's reason as already printed: an item whose message it contains is reduced + to where it is, and an item with neither a new message nor a location prints nothing. + """ + message = _text(item.message) + if message is not None and said is not None and message in said: + message = None + if item.pipe_code: + return f"In pipe {item.pipe_code}: {message}" if message else f"Pipe: {item.pipe_code}" + if item.concept_code: + return f"In concept {item.concept_code}: {message}" if message else f"Concept: {item.concept_code}" + return f"Problem: {message}" if message else None def _text(value: str | None) -> str | None: @@ -242,7 +302,7 @@ def _present_upload_error(exc: InputPreparationError) -> ErrorPresentation: if isinstance(exc, UnsupportedUploadCapabilityError): hint = "File upload is a hosted capability — point PIPELEX_BASE_URL at https://api.pipelex.com (a bare runner may not serve /v1/upload)." elif isinstance(exc, UploadAuthenticationError): - hint = "Set PIPELEX_API_KEY in your environment or .env file — get a key at https://app.pipelex.com" + hint = _API_KEY_HINT elif isinstance(exc, RejectedAssetError): hint = "The server rejected the file (usually past the service size cap) — try a smaller file." elif isinstance(exc, InvalidLocalSourceError): @@ -260,51 +320,29 @@ def _present_artifact_error(exc: ArtifactOperationError) -> ErrorPresentation: is to recover the files it produced. """ if isinstance(exc, ArtifactAuthenticationError): - hint = "Set PIPELEX_API_KEY in your environment or .env file — get a key at https://app.pipelex.com" + hint = _API_KEY_HINT else: hint = "The run itself succeeded — check the download directory is writable and is not an existing file." return ErrorPresentation(message=str(exc), hint=hint) -def _present_http_status_error(exc: httpx.HTTPStatusError) -> ErrorPresentation: - """Present a raw protocol-route HTTP error, reading its RFC 7807 problem+json body. +def _present_api_response_error(exc: ApiResponseError) -> ErrorPresentation: + """Present a non-2xx answer from the problem document the SDK parsed onto the error. - The protocol routes (`execute`, `start`, `runs/*`) return errors as - `application/problem+json`: a human `detail`/`title` and a machine `error_type`. - httpx's own stringification throws all of that away (`Client error '400 Bad - Request' for url …` plus an MDN link), so we read the body and surface what the - server actually said — and branch on the structured `error_type`, never the - transport status, for the cases worth a hint. + A request the API refused is read out by `problem_lines`: the reason, the pipes a refused + method names, the next step and the retry advice. The branch that earns a hint goes on the + runner's structured `error_type`, never the wording: a `/start` on a deployment whose + orchestration is synchronous only, whose fix is to run the same demo under `widget blocking`. + The HTTP status decides only an authentication failure, whose fix is the key whatever the + route; its reason is read out too, because a `403` can be a key that was recognised but may + not use the route. """ - status_code = exc.response.status_code - problem = _read_problem_json(exc.response) - if status_code in (401, 403): - return ErrorPresentation( - message=f"The API rejected the request ({status_code} {exc.response.reason_phrase}).", - hint="Set PIPELEX_API_KEY in your environment or .env file — get a key at https://app.pipelex.com", - ) - # A durable-run endpoint (`/start`) this deployment can't serve: it runs a - # synchronous-only orchestration, so only `widget blocking` (`/execute`) works here. - if problem.get("error_type") == "StartRequiresAsyncOrchestration": + status = f"{exc.status} {exc.status_text}".strip() + if exc.error_type == _START_REQUIRES_ASYNC_ORCHESTRATION: return ErrorPresentation( - message=problem.get("detail") or "This deployment cannot start durable runs — it has no async orchestration.", + message=_text(exc.server_message) or "This deployment cannot start durable runs — it has no async orchestration.", hint="This runner only does synchronous runs — use `widget blocking ...` instead.", ) - detail = problem.get("detail") or problem.get("title") - if detail: - return ErrorPresentation(message=f"The API answered {status_code} {exc.response.reason_phrase}: {detail}", hint=None) - return ErrorPresentation(message=f"The API answered {status_code} {exc.response.reason_phrase}.", hint=None) - - -def _read_problem_json(response: httpx.Response) -> dict[str, Any]: - """Best-effort parse of an RFC 7807 problem+json body; `{}` when it isn't JSON.""" - try: - body: Any = response.json() - except (json.JSONDecodeError, UnicodeDecodeError): - # UnicodeDecodeError: `response.json()` is `json.loads(response.content)`, - # which raises it (not JSONDecodeError) on a non-UTF-8 body. - return {} - if isinstance(body, dict): - # JSON object keys are always strings. - return cast("dict[str, Any]", body) - return {} + if exc.status in (401, 403): + return ErrorPresentation(message=f"The API rejected the request ({status}).", hint=_API_KEY_HINT, details=problem_lines(exc)) + return ErrorPresentation(message=f"The API answered {status}.", hint=None, details=problem_lines(exc)) From 89c05f631fa27bd86d95e5d014b6240ace38c36b Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 18:00:06 +0200 Subject: [PATCH 2/2] Say where the SDK's typed errors stop: a failed connection on execute or start The `_run()` docstrings, the architecture doc, the error module's docstring, the changelog and the pyproject comment said or implied that every error the SDK raises descends from `PipelineRequestError`. A connection that fails on `execute` or `start` does not: pipelex-sdk 0.14.0 sends those routes through the base client's unwrapped transport, so a mistyped PIPELEX_BASE_URL still crashes with httpx's own ConnectError, as it did on 0.13.0. The sentences now say so; the fix belongs in the SDK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xcr1G5gkEa4Aeoo1a7ATyh --- CHANGELOG.md | 2 +- docs/cli-architecture.md | 2 +- pyproject.toml | 4 ++-- widget/attended/cli.py | 8 +++++--- widget/blocking/cli.py | 8 +++++--- widget/detached/cli.py | 8 +++++--- widget/errors.py | 8 +++++--- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f408d..66758ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed -- **`pipelex-sdk` 0.14.0 and `mthds` 0.17.0 are the floors, and `httpx` is no longer a dependency (Breaking)**: from `pipelex-sdk` 0.14.0 every route, `execute` and `start` among them, raises the typed `ApiResponseError` on a non-2xx answer, so each mode's `_run()` catches `PipelineRequestError` alone and nothing in the project catches or parses a raw `httpx.HTTPStatusError` any more. Code copied from the starter that caught `httpx.HTTPStatusError` catches `ApiResponseError` and reads `exc.status` where it read `exc.response.status_code`. +- **`pipelex-sdk` 0.14.0 and `mthds` 0.17.0 are the floors, and `httpx` is no longer a dependency (Breaking)**: from `pipelex-sdk` 0.14.0 every route, `execute` and `start` among them, raises the typed `ApiResponseError` on a non-2xx answer, so each mode's `_run()` catches `PipelineRequestError` with no second arm for a raw `httpx.HTTPStatusError`, and nothing in the project parses one any more. Code copied from the starter that caught `httpx.HTTPStatusError` catches `ApiResponseError` and reads `exc.status` where it read `exc.response.status_code`. ### Fixed diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index 057f8c5..f059cf8 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -69,7 +69,7 @@ Three SDK capabilities show up in every result-producing path: **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) — `RUN_ID=$(widget detached generate-image "…")` just works. -**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises, a non-2xx answer from any route included), maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. +**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of the SDK's typed errors, a non-2xx answer from any route included), maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. One failure you might expect to be presented crashes that way too: a connection that fails on `execute` or `start`, from a mistyped `PIPELEX_BASE_URL` or a machine that is offline, reaches `_run()` as httpx's own `ConnectError`, because the SDK wraps a transport failure as `ApiUnreachableError` only on the routes that read a run. There `widget detached status` and its siblings present it with the `PIPELEX_BASE_URL` hint. **A refused request says why, where and what to do.** Every route of the SDK — the protocol routes (`execute`, `start`, `runs/*`) as much as the product ones — raises the typed `ApiResponseError` on a non-2xx answer, with the API's RFC 9457 **problem+json** document parsed onto it: its `title` and `detail`, the runner's `error_type`, the `validation_errors` of a method it refused to run, the `user_action` it advises and whether the request is `retryable`. `problem_lines` reads that document out the way `report_lines` reads a failed run's report, so a method the API will not run reads like this whichever mode you met it with: diff --git a/pyproject.toml b/pyproject.toml index e7e3735..9dd2759 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ classifiers = [ # break a fresh install of a template every new project is cloned from. The `mthds` floor is the # version `pipelex-sdk` pins exactly today, which is a floor and not a second pin: the SDK's exact # pin always satisfies it, so bumping the SDK never has to be matched here. `httpx` is not among -# them: since `pipelex-sdk` 0.14.0 every route raises the SDK's typed `ApiResponseError`, so -# nothing here imports it, and it arrives only as the SDK's own transport. +# them: since `pipelex-sdk` 0.14.0 every route raises the SDK's typed `ApiResponseError` on a +# non-2xx answer, so nothing here imports it, and it arrives only as the SDK's own transport. dependencies = [ "mthds>=0.17.0", "pipelex-sdk>=0.14.0", diff --git a/widget/attended/cli.py b/widget/attended/cli.py index 810a3f3..daf9666 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -166,9 +166,11 @@ def generate_image( def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx - answer from any route included (`ApiResponseError`). Nothing else is caught: an - unexpected exception crashes loudly with its traceback. + The SDK's typed errors descend from `PipelineRequestError`, a non-2xx answer from any + route included (`ApiResponseError`). A connection that fails on `execute` or `start` is + the exception: the SDK lets httpx's own transport error through from those two routes, + so it crashes with its traceback like any unexpected exception, since nothing else is + caught here. """ try: return asyncio.run(coro) diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index 38a823d..9ef7bfc 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -146,9 +146,11 @@ def generate_image( def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx - answer from any route included (`ApiResponseError`). Nothing else is caught: an - unexpected exception crashes loudly with its traceback. + The SDK's typed errors descend from `PipelineRequestError`, a non-2xx answer from any + route included (`ApiResponseError`). A connection that fails on `execute` or `start` is + the exception: the SDK lets httpx's own transport error through from those two routes, + so it crashes with its traceback like any unexpected exception, since nothing else is + caught here. """ try: return asyncio.run(coro) diff --git a/widget/detached/cli.py b/widget/detached/cli.py index 07301f7..34f758d 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -242,9 +242,11 @@ def _print_results(results: RunResults) -> None: def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: """Await the lifecycle, presenting SDK errors and Ctrl-C as clean exits. - Every error the SDK client raises descends from `PipelineRequestError`, a non-2xx - answer from any route included (`ApiResponseError`). Nothing else is caught: an - unexpected exception crashes loudly with its traceback. + The SDK's typed errors descend from `PipelineRequestError`, a non-2xx answer from any + route included (`ApiResponseError`). A connection that fails on `execute` or `start` is + the exception: the SDK lets httpx's own transport error through from those two routes, + so it crashes with its traceback like any unexpected exception, since nothing else is + caught here. """ try: return asyncio.run(coro) diff --git a/widget/errors.py b/widget/errors.py index 8114d14..ec83152 100644 --- a/widget/errors.py +++ b/widget/errors.py @@ -2,11 +2,13 @@ This module defines no exception classes — it is a presentation mapper. Each mode package's `_run()` wrapper (`widget/blocking/cli.py`, `widget/attended/cli.py`, -`widget/detached/cli.py`) catches `PipelineRequestError` (the base of every error the -`pipelex-sdk` client raises) exactly once, turns it into an `ErrorPresentation` here — +`widget/detached/cli.py`) catches `PipelineRequestError` (the base of the typed errors +the `pipelex-sdk` client raises) exactly once, turns it into an `ErrorPresentation` here — a message, the lines that explain it, and a hint — prints it with `print_error`, and exits non-zero. Unexpected exceptions are deliberately NOT caught anywhere: they crash -loudly with a full traceback. +loudly with a full traceback, and so, for now, does a connection that fails on `execute` +or `start`, which the SDK passes through as httpx's own transport error rather than as +`ApiUnreachableError`. A run that ended without a result is presented from the error report the runner stored when it failed, which the SDK hands back typed as `RunErrorReport`: `report_lines` reads