From 8c29790846758a1f94d78f8c2a89d2195ffd96f6 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 15:34:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feature/Refused-start-raises-bare=20=C2=B7?= =?UTF-8?q?=20L-260927-424b11=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every route of `PipelexAPIClient` now raises a typed `ApiResponseError` that is `mthds`'s own error narrowed to `ValidationErrorItem`, so `start_and_wait()` or `execute()` on a method the plane refuses raises an error whose message and fields carry the reason, the failing pipe and the next step, instead of httpx's bare status line. The client overrides the new `_raise_api_response_error` seam of `mthds`, reads the shared members through `ProblemDocument`, and moves the gateway-timeout translation of `execute` and the bare-runner 404 translation of `start` onto the typed error, both of which the override would otherwise have silently disabled. The branch builds on `mthds` pinned at its dev commit and collapses onto the published 0.17.0 before it merges. Closes L-260927-424b11 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Summary by cubic Every route of `PipelexAPIClient` now raises a typed `ApiResponseError` — `mthds`'s own error narrowed to `ValidationErrorItem` — so a refused run reports the reason, the failing pipe, and the advised next step instead of `httpx`'s bare status line. **Migration** - Callers that caught `httpx.HTTPStatusError` must catch `ApiResponseError` and read `exc.status`, `exc.headers`, and `exc.request_url` where they previously read `exc.response.status_code`, `exc.response.headers`, and `exc.request.url`. - `execute`'s gateway-timeout translation and `start`'s bare-runner 404 translation now read the typed error; on `start`, a runner 404 that carries `error_type` but no `code` is read as a refusal, and only a 404 with neither is treated as a missing run store. **Dependencies** - `mthds` is upgraded to the published 0.17.0. - Tests replay the dev plane's three real refusal bodies byte for byte from `mthds-python`, and the deferred version-handshake finding is recorded in `wip/pr-50-review-notes.md`. Written for commit af35911359592c1724e2a38795addf15b00ca731. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 12 + README.md | 33 ++- docs/architecture.md | 25 +- pipelex_sdk/client.py | 359 +++++++++++-------------- pipelex_sdk/error_models.py | 7 +- pipelex_sdk/errors.py | 130 ++++++--- pipelex_sdk/prepare_inputs.py | 7 +- pyproject.toml | 2 +- tests/unit/test_api_response_error.py | 64 ++++- tests/unit/test_client_execute.py | 23 +- tests/unit/test_client_lifecycle.py | 69 ++++- tests/unit/test_client_refused_run.py | 217 +++++++++++++++ tests/unit/test_client_run_fallback.py | 27 +- tests/unit/test_client_validate.py | 32 +++ tests/unit/test_data.py | 66 +++++ tests/unit/test_error_parsing.py | 47 ---- uv.lock | 8 +- wip/pr-50-review-notes.md | 18 ++ 18 files changed, 822 insertions(+), 324 deletions(-) create mode 100644 tests/unit/test_client_refused_run.py create mode 100644 tests/unit/test_data.py delete mode 100644 tests/unit/test_error_parsing.py create mode 100644 wip/pr-50-review-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 740fbaf..380eb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Changed + +- **Every route raises `ApiResponseError`, and a refused run says why (Breaking)**: the protocol routes (`execute`, `start`, `validate`, `models`, `version`), the run status and results reads and `start_and_wait` raise `ApiResponseError` on a non-2xx answer instead of `httpx.HTTPStatusError`, so a method the plane refuses to run raises an error whose message gives the reason and, on its own line, the next step (`Next step: …`), and whose `validation_errors[0].pipe_code` names the failing pipe. A caller that caught `httpx.HTTPStatusError` catches `ApiResponseError` and reads `exc.status`, `exc.headers` and `exc.request_url` where it read `exc.response.status_code`, `exc.response.headers` and `exc.request.url`; the gateway-timeout translation of `execute` and the bare-runner `404` translation of `start` are unchanged. +- **`ApiResponseError` is `mthds`'s own error narrowed (Breaking)**: `pipelex_sdk.errors.ApiResponseError` now subclasses `mthds.runners.api.exceptions.ApiResponseError[ValidationErrorItem]`, adding `code`, `error_category` and `errors`, so `except mthds.runners.api.exceptions.ApiResponseError` catches it too. It gains `instance`, `headers` and `request_url`; its message is the base's (`API /v1/ failed (): `, the reason falling back from `detail` to `title` to the raw body to the status text); its `user_action` is the `mthds` `UserAction`, kept only when the answer carries a string `kind` and a non-empty `detail`, so one missing either now reads as `None`; and an empty `code` or `error_category` reads as `None`. +- **Requires `mthds` 0.17.0 (Breaking)**: the exact pin moves from 0.16.0 to the release that adds the typed `ApiResponseError` this client builds on. + +### Fixed + +- **A runner's 404 on `start` is no longer read as a missing run store**: a 404 carrying the runner's `error_type` — a `method_ref` whose package does not exist, relayed by the hosted API without a platform `code` — raised `RunLifecycleUnavailableError`, and `start_and_wait` then moved the client onto blocking `execute` for the rest of its life. Only a 404 whose body carries neither `code` nor `error_type` now counts as a missing route; any other raises `ApiResponseError`, and the client keeps using the durable path. + ## [v0.13.0] - 2026-09-27 ### Added diff --git a/README.md b/README.md index 1fc42a7..3afbcc7 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,35 @@ ack = await client.start(pipe_code="long_pipe", inputs={...}) result = await client.wait_for_result(ack.pipeline_run_id) ``` +### When a run is refused: `ApiResponseError` says why and what to do next + +A method the plane will not run — a bundle naming a model the deck does not serve, a pipe whose output cannot be assembled — is refused before any result exists: `start`, `execute` and `start_and_wait` raise `ApiResponseError` on the `422`. Its message names the request, gives the reason and, on its own line, the next step the plane advises, so printing the error is already actionable: + +```text +API POST /v1/start failed (422): 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 +Next step: Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one +``` + +A program reads the same thing as fields: + +```python +from pipelex_sdk.errors import ApiResponseError + +try: + result = await client.start_and_wait(pipe_code="pitch_product", mthds_contents=[bundle]) +except ApiResponseError as exc: + if exc.error_domain == "input": + for item in exc.validation_errors or []: + print(f"{item.pipe_code}: {item.message}") # the failing pipe, typed as ValidationErrorItem + if exc.user_action is not None: + print(f"Next step: {exc.user_action.detail}") + raise +``` + +`validation_errors` is set when the runner itemized its refusal at load; a run that failed during execution carries the pipe in `server_message` instead (`Pipe 'analyze_topics' failed (review_topics → analyze_topics): …`). + ### When a run fails: `RunFailedError` carries the run's report A run that ends without a result — `FAILED`, `CANCELLED`, `TERMINATED` or `TIMED_OUT` — makes `wait_for_result`, `start_and_wait` and `download_artifacts` raise `RunFailedError`. Its message already names the status and the reason (`Run finished with status FAILED: `), `status` is the typed `RunStatus`, and `error` is the run's stored error report, typed whole as `RunErrorReport` (`pipelex_sdk.error_models`): the runner's `error_type`, `message`, `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `validation_errors`, and anything newer on `model_extra`. `error` is `None` for a run that ended with no report, such as a cancelled one. @@ -140,7 +169,7 @@ Branch on `error_domain` (`input`, `config`, `runtime`), `type_uri` and `retryab ### API errors: branch on `type_uri` and `error_domain`, not the HTTP status -A non-2xx answer from a product route — the account, methods, organization, billing, API-key, onboarding, storage and upload methods, `codegen` and `resolve`, and the run records (`list_runs`, `iterate_runs`, `get_run_detail`, `update_run`) — raises a typed `ApiResponseError` carrying the members of the RFC 9457 problem document. The protocol routes (`execute`, `start`, `validate`, `models`, `version`) and the run status and results reads keep raising `httpx.HTTPStatusError` for a failure they do not translate, and `health` raises `PipelineRequestError`; `docs/architecture.md` lists the error regimes. The branch fields are `type_uri`, the problem's `type`, a stable URI naming the error class that every problem carries, and `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed). `error_domain` is carried only by the problems the runner renders — those of `codegen` and `resolve`, which the hosted API relays from the runner — and is `None` on the platform's own problems, such as those of the account, billing and API-key routes, which name their class by `type_uri` alone: +Every `/v1` route raises a typed `ApiResponseError` on a non-2xx answer, carrying the members of the RFC 9457 problem document: the protocol routes (`execute`, `start`, `validate`, `models`, `version`), the run status and results reads, and the product routes — the account, methods, organization, billing, API-key, onboarding, storage and upload methods, `codegen` and `resolve`, and the run records (`list_runs`, `iterate_runs`, `get_run_detail`, `update_run`). It is `mthds`'s own `ApiResponseError` narrowed, so `except mthds.runners.api.exceptions.ApiResponseError` catches it too; `health` raises `PipelineRequestError`, and `docs/architecture.md` lists the error regimes. The branch fields are `type_uri`, the problem's `type`, a stable URI naming the error class that every problem carries, and `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed). `error_domain` is carried only by the problems the runner renders — a run route's refusal, and those of `codegen` and `resolve`, which the hosted API relays from the runner — and is `None` on the platform's own problems, such as those of the account, billing and API-key routes, which name their class by `type_uri` alone: ```python from pipelex_sdk.errors import ApiResponseError @@ -158,7 +187,7 @@ except ApiResponseError as exc: raise ``` -On a problem the runner rendered, branch on `error_domain` for the class — `if exc.error_domain == "input":` shows the caller what to fix, whatever the exact error. The rest of the document rides beside them: `server_message` (the `detail`), `title`, `retryable`, `user_action`, `error_category`, the platform's field-level `errors`, `validation_errors` for a bundle fault, and `request_id` for a support request, read from the body or from the `X-Request-ID` header. `code` (the platform's closed code, such as `conflict`) and `error_type` (the runner's exception class name) are each surface's own finer code — useful for display and support, not the field to branch on. `problem` is the decoded document whole, for any member the SDK does not name. +On a problem the runner rendered, branch on `error_domain` for the class — `if exc.error_domain == "input":` shows the caller what to fix, whatever the exact error. The rest of the document rides beside them: `server_message` (the `detail`), `title`, `instance`, `retryable`, `user_action` (the `mthds` `UserAction`, kept only when it has a `kind` and a non-empty `detail`), `error_category`, the platform's field-level `errors`, `validation_errors` for a bundle fault, and `request_id` for a support request, read from the body or from the `X-Request-ID` header. The answer itself stays reachable as plain data: `status`, `headers` (lower-case names, so `exc.headers.get("retry-after")` reads a `429`'s delay) and `request_url`. `code` (the platform's closed code, such as `conflict`) and `error_type` (the runner's exception class name) are each surface's own finer code — useful for display and support, not the field to branch on. `problem` is the decoded document whole, for any member the SDK does not name. ## Public import paths (no barrel) diff --git a/docs/architecture.md b/docs/architecture.md index 0cd8ccb..390dd01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,22 +62,23 @@ The client inherits `mthds`'s `_send` (one raw HTTP request, no status interpret `start_client` is overridden so the `Authorization` header is sent only when a token is configured — anonymous access (empty token) omits it — and so the spec-conforming `User-Agent` (built once at construction by `pipelex_sdk.user_agent`, with the optional `app_info` in front) is a default header on every request, authenticated or not. See [`client-identification.md`](client-identification.md) and the workspace spec `docs/specs/client-identification.md`. -The `problem+json` / `HTTPException` error body is parsed by `_parse_error_body` into every member `ApiResponseError` carries: the message (`detail`, or `detail.message` / top-level `message` on the older `{"detail": {...}}` shape), `error_type`, `code`, `request_id`, `type` (as `type_uri`), `title`, `error_domain`, `error_category`, `retryable`, `user_action`, the platform's field-level `errors[]`, `validation_errors`, and the decoded object whole as `problem`, so a member the SDK does not name (`instance`, a failed run's `run_status` and `error`) stays reachable. A member of the wrong type reads as `None`, and the structured ones (`user_action`, `errors[]`, `validation_errors`) are validated leniently, because an odd shape on an error path must never mask the failure itself. A non-JSON or non-object body falls through to empty. `_raise_api_response_error` then takes the request id from the `X-Request-ID` response header when the body carries none. +Every non-2xx answer becomes an error in one place: `_raise_api_response_error(*, method, endpoint, response)`, the protected seam of the `mthds` base that this client overrides. The base's protocol routes (`execute`, `start`, `validate`, `models`, `version`) call it, and so do this client's own run status and results reads, its selector `validate` path and `_request_product`, so every `/v1` route raises the same `ApiResponseError`. The override reads the members both clients share through the base's own parse, `mthds.runners.api.problem.ProblemDocument.make_from_response`: the RFC 9457 members (`type` as `type_uri`, `title`, `instance`), the reason (`detail`, or `detail.message` / top-level `message` on the older `{"detail": {...}}` shape) as `server_message`, `error_type`, `request_id` (with the `X-Request-ID` header as its fallback), `error_domain`, `retryable`, `user_action`, `validation_errors`, and the decoded object whole as `problem`. On top of those it reads the Pipelex members the standard's client leaves out — the platform's `code`, the runner's `error_category` and the platform's field-level `errors[]` — and narrows `validation_errors` from the protocol's neutral `ValidationDiagnostic` to this SDK's `ValidationErrorItem`. Every member is read leniently, because an odd shape on an error path must never mask the failure itself: a member of the wrong type, or an empty string, reads as `None`; a `user_action` is kept only whole (a string `kind` and a non-empty `detail`); a `validation_errors` list is kept only when every item narrows, the raw list staying on `problem` otherwise; and a non-JSON or non-object body carries no member at all. The message is the base's, word for word: `API /v1/ failed (): `, the reason being the `detail`, else the `title`, else the raw body cut at 500 characters, else the status text, followed by `Next step: ` on its own line unless the reason already ends with it. ## Error regimes -Three regimes, ported faithfully from the TS SDK (the inherited-protocol vs product split is decision #5 — deliberately not unified): +Three regimes, ported from the TS SDK: -- **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the members of the RFC 9457 problem document. Consumers branch on `err.type_uri` (the problem's `type`, the stable URI naming the error class, on every problem) and `err.error_domain` (`input` / `config` / `runtime`), as the workspace's hosted-envelope spec (`docs/specs/pipelex-hosted-envelope.md`) makes them the cross-surface branch fields, and never on the HTTP status. `error_domain` is carried only by runner-rendered problems — among the product routes, those of `codegen` and `resolve`, which the platform relays from the runner — and is `None` on the platform's own problems, which that spec records as not emitting it; on those, `type_uri` is the branch field, 1:1 with `code`. `err.code` is the platform's native closed code (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`) and `err.error_type` the runner's open class name: finer, surface-specific, and not the branch field. It also carries `status`, `status_text`, `response_body`, `server_message`, `title`, `error_category`, `retryable`, `user_action`, `request_id`, `errors`, `validation_errors` and the decoded `problem`. -- **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError` (subclass of `PipelineRequestError`) with `api_url` and `code`. +- **Every `/v1` route** — the protocol routes inherited from `mthds`, the run status and results reads, and the product routes — raises a typed `ApiResponseError` on a non-2xx answer. It is `mthds`'s own `ApiResponseError[ValidationErrorItem]` narrowed (`class ApiResponseError(mthds.runners.api.exceptions.ApiResponseError[ValidationErrorItem])`), so a handler written against the standard's client catches it too, and it adds only `code`, `error_category` and `errors`. Consumers branch on `err.type_uri` (the problem's `type`, the stable URI naming the error class, on every problem) and `err.error_domain` (`input` / `config` / `runtime`), as the workspace's hosted-envelope spec (`docs/specs/pipelex-hosted-envelope.md`) makes them the cross-surface branch fields, and never on the HTTP status. `error_domain` is carried by the problems the runner renders — a run route's refusal, and among the product routes those of `codegen` and `resolve`, which the platform relays from the runner — and is `None` on the platform's own problems, which that spec records as not emitting it; on those, `type_uri` is the branch field, 1:1 with `code`. `err.code` is the platform's native closed code (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`) and `err.error_type` the runner's open class name: finer, surface-specific, and not the branch field. It also carries `status`, `status_text`, `response_body`, `headers` (lower-case names), `request_url`, `api_url`, `server_message`, `title`, `instance`, `error_category`, `retryable`, `user_action` (the `mthds` `UserAction`), `request_id`, `errors`, `validation_errors` and the decoded `problem`. A refused run is the case this serves first: `start`, `execute` and `start_and_wait` on a method the runner will not run raise an error whose message reads the reason and the next step, and whose `validation_errors[0].pipe_code` names the failing pipe. +- **Transport failures** (DNS/connect/TLS/timeout) raise `ApiUnreachableError` (subclass of `PipelineRequestError`) with `api_url` and `code` on the product routes and the run status and results reads, which send through `_send_or_unreachable`. The inherited protocol routes send on the base's raw `_send`, so a transport failure there is still the `httpx.TransportError` the base lets through. - **`health` / `_request_json`** raise the plainer `PipelineRequestError` on a non-2xx response. **(Checkpoint-5 decision: kept, not unified.)** Liveness is a binary up/down probe that needs no `code` taxonomy, and this already matches the JS `health` regime — bringing it under `ApiResponseError` would be over-engineering and a JS divergence. (Python's `PipelineRequestError` is already a typed improvement over the JS plain `Error`.) -- **Inherited protocol routes** (`execute` / `start` / `validate` / `models` / `version`) keep the base `mthds` `raise_for_status()` → `httpx.HTTPStatusError` behavior. The one typed addition is `PipelineExecuteTimeoutError` (below), raised by `execute` for the hosted gateway's synchronous cut-off. + +The two translations this client makes on top of the typed error read it rather than an httpx exception, because the inherited route raises through the override before the client's own `execute` and `start` see anything: the gateway-timeout check reads `exc.status`, and the missing-route check reads `exc.status`, `exc.response_body` and `exc.request_url`. The error keeps the answer as plain data, never the `httpx` request, which carries the API key. ## `execute` override (hosted gateway-timeout translation) The protocol `execute` is **overridden** to add one Pipelex-API behavior the bare protocol route doesn't carry, while keeping the inherited error regime for everything else. Behind the hosted gateway, a synchronous request is cut off at ~30s; a blocking `execute` that exceeds that comes back as a gateway `503`/`504` (or, less commonly, a client-side request timeout). The override times the call and, when such a failure is observed **at or after ~28s elapsed**, translates it into a clear `PipelineExecuteTimeoutError` whose message points the caller at the durable start+poll path. The ~28s threshold guards against mislabeling a *fast* `503` (the runner genuinely down) as a timeout. This closes a JS-parity gap — the inherited base `execute` does no such translation (the Phase-1 flag, resolved at Checkpoint 5 by **implementing** it rather than deferring). -Everything else stays the inherited regime: the protocol's optional 202 async-degrade still raises `RunStillRunningError` (from the base `execute`), and every other non-2xx keeps `httpx.HTTPStatusError` — consistent with the other inherited protocol routes (decision #5). The `_execute_blocking` bare-runner fallback (below) calls this same overridden `execute`, so it inherits the translation; off-platform there is no gateway cap, so the `503/504`-after-28s condition effectively never fires there. +The check reads the typed error: the inherited `execute` raises `ApiResponseError` through the override, the client catches it with a client-side `httpx.TimeoutException`, and a `503`/`504` is read on `exc.status`; the translated error keeps the gateway's answer as its `__cause__`. Everything else passes through unchanged: the protocol's optional 202 async-degrade still raises `RunStillRunningError` (from the base `execute`), and every other non-2xx raises the `ApiResponseError` itself, a fast `503` included. The `_execute_blocking` bare-runner fallback (below) calls this same overridden `execute`, so it inherits the translation; off-platform there is no gateway cap, so the `503/504`-after-28s condition effectively never fires there. The override also **enriches the return type**: it re-validates the base result into a `PipelexExecuteResult` (`pipelex_sdk/execute_result.py`), a `DictRunResultExecute` subtype that adds a resolved `.main_stuff` accessor (dug out of `pipe_output`'s working memory via the response's `main_stuff_name`, raising `MissingMainStuffError` if unlocatable). This gives blocking and durable results the **same output accessor** — `result.main_stuff` — so callers never branch on which path ran. `results_from_execute` (the public lift from that result onto `RunResults`, in the same module) reads that accessor too, keeping the resolution single-sourced; the `_execute_blocking` fallback is its only caller inside the SDK, and a consumer driving `execute()` directly calls it by name. @@ -123,7 +124,7 @@ The durable run lifecycle (`pipelex_sdk/runs.py` + the client's lifecycle method - **`get_run_result(run_id)`** — `GET /v1/runs/{id}/results`, mapping the platform's poll semantics to the `RunResultState` union: `202`/`503` → `running` (in-flight / degraded — never fail a poller), `200` → `completed`, `409` → `failed`. The `409`'s problem document carries `detail` (`Run finished with status : `), which becomes `message`, and two extension members: `run_status`, which becomes `status`, and `error`, the run's stored report, which becomes `error` typed as `RunErrorReport`. The status is read from that member and never parsed out of the sentence; a `409` without a `run_status` this SDK knows reads as `FAILED`. The report is read leniently, because a report written by another runner version must not mask the failure it explains: a known field whose value does not fit its type reads as `None` and the rest of the report stands, and only an `error` that is not an object at all reads as `None` — `message` still carries the reason either way. The status read and the run lists read it the same way (`LenientRunErrorReport`). - **`wait_for_result(run_id, options)`** — polls `get_run_result` to a terminal state, honoring `Retry-After` and the deadline. Resolves on `COMPLETED`; raises `RunFailedError` on any other terminal status, carrying the failed arm's `status`, `message` and `error`, and `RunTimeoutError` if the budget elapses (the run keeps executing server-side — resume later by id). -These poll GETs go through `_send_or_unreachable`, so a transport failure surfaces as `ApiUnreachableError` (consistent with the product layer), while a missing-route `404` surfaces as `RunLifecycleUnavailableError` and any other non-2xx as `httpx.HTTPStatusError`. +These poll GETs go through `_send_or_unreachable`, so a transport failure surfaces as `ApiUnreachableError` (consistent with the product layer), while a missing-route `404` surfaces as `RunLifecycleUnavailableError` and any other non-2xx — the platform's run-not-found `404` included — as `ApiResponseError`. ### `start_and_wait` — hosted ↔ bare self-healing @@ -132,7 +133,7 @@ These poll GETs go through `_send_or_unreachable`, so a transport failure surfac - A cached `GET /v1/version` handshake (`_supports_run_lifecycle`) classifies the runner. `VersionInfo.implementation == "pipelex-api"` ⇒ a bare runner (no run store); anything else ⇒ assumed hosted. The outcome is cached for the client's lifetime; a failed handshake assumes hosted and lets `start` surface the real error. - **Hosted:** durable `start` (202 ack) → `wait_for_result` (poll to terminal). - **Bare runner:** the blocking `POST /v1/execute` (`_execute_blocking`), which has no gateway cap off-platform and returns the native `pipe_output`, mapped onto `RunResults` — the SDK resolves `main_stuff` out of the working memory via the response's `main_stuff_name` and keeps the full working memory on `pipe_output`. -- **Self-heal:** a runner can look hosted yet lack the durable routes (`implementation` is an optional extension a compliant bare runner may omit). The client's `start` override translates a bare-runner missing-route `404` into `RunLifecycleUnavailableError` — raised **before any run is created** — so `start_and_wait` falls back to the blocking path without risking a double-run, and caches the negative so later calls skip the durable attempt. +- **Self-heal:** a runner can look hosted yet lack the durable routes (`implementation` is an optional extension a compliant bare runner may omit). The client's `start` override catches the `ApiResponseError` the inherited route raises and translates a bare-runner missing-route `404` into `RunLifecycleUnavailableError` — raised **before any run is created** — so `start_and_wait` falls back to the blocking path without risking a double-run, and caches the negative so later calls skip the durable attempt. A missing route is a 404 whose body is no problem document at all — Starlette's default `{"detail": "Not Found"}`, or a gateway page. A 404 carrying the platform's `code` or the runner's `error_type` was answered on purpose (a run not found; a `method_ref` whose package does not exist, which the platform relays from the runner unchanged) and stays an `ApiResponseError`, so a refused start never demotes the client to the blocking path. ### Run/lifecycle errors @@ -140,7 +141,7 @@ These poll GETs go through `_send_or_unreachable`, so a transport failure surfac ## `validate` override (Pipelex-API presentation + sources + selectors) -The protocol `validate` is **overridden** (not inherited) to add the Pipelex-API extensions the bare protocol route doesn't carry, while keeping the inherited protocol error regime (a no-verdict non-2xx surfaces as `httpx.HTTPStatusError`, not `ApiResponseError` — the verdict itself is always a 200 discriminated on `is_valid`): +The protocol `validate` is **overridden** (not inherited) to add the Pipelex-API extensions the bare protocol route doesn't carry. A no-verdict non-2xx surfaces as `ApiResponseError` on both wire paths, like every other route; the verdict itself is always a 200 discriminated on `is_valid`: - **WHAT is validated arrives in exactly one of three forms — the tooling routes' strict three-way XOR.** Inline `mthds_contents` (the protocol's own envelope), `method_ref=` (runner-resolved by address through the same fetch path as a `method_ref` run, the package's real file names feeding the diagnostics' source labels), or `method_id=` (hosted-only, platform-resolved before the runner sees the request). The routes are stateless, so there is no linkage exception: zero selectors or any pairing raises `PipelineRequestError` client-side, mirroring the server's request-shape `422`. `mthds_sources` is an inline-contents companion only and is rejected beside a selector — a selector validation gets its labels from the real file names. A selector-resolution failure (a fetch failure, no package at the address, an unknown or foreign-org id) is a non-2xx — never an `is_valid: false` verdict, which is reserved for actual MTHDS content. - **The two wire paths differ, deliberately.** The inline path reuses the inherited `_post_validate` body-building seam; a selector validation must NOT carry the `mthds_contents` key at all (the server XORs on presence, and an empty list is a request-shape `422`), so its body is built in the override and sent on the same inherited `_send` transport seam. Both paths get the markdown render injection and both parse into `PipelexValidationResultAdapter`. @@ -152,7 +153,7 @@ The protocol `validate` is **overridden** (not inherited) to add the Pipelex-API The override reuses the inherited base transport seam `_post_validate` (which builds the body — passing `render` / `mthds_sources` through the protocol's `extra` extension passthrough — sends the request, and raises on a no-verdict non-2xx), then parses the raw 200 body into this SDK's own `PipelexValidationResult` via `PipelexValidationResultAdapter`. Body-building and transport stay shared with the base; only the Pipelex presentation/sources concerns and the branded narrowing live here. The validation models (`PipelexValidationResult` = `PipelexValidationReport | PipelexInvalidReport`, with `rendered_markdown`) are **owned by this SDK** (`pipelex_sdk.validation_models`); they narrow `mthds`'s neutral verdict bases, completing the brand boundary (the resolved follow-up #9). The base `MthdsAPIClient.validate()` returns the neutral `ValidationResult` instead. -**Checkpoint-5 decision (validate error regime):** the delegation keeps the inherited `httpx.HTTPStatusError` regime on a *no-verdict* non-2xx, where the JS `validate` raises `ApiResponseError`. Kept as-is (deferred parity), because in both SDKs `validate`'s error regime matches the *other* protocol routes of that SDK — JS routes all raise `ApiResponseError`, Python protocol routes all inherit `httpx.HTTPStatusError` (decision #5). Making Python's `validate` alone raise `ApiResponseError` would make it inconsistent with `execute`/`start`/`models`/`version`, which is worse than the JS divergence. The verdict itself (valid/invalid) is always a 200 either way — only the no-verdict failure *presentation* differs. +**Validate error regime:** a *no-verdict* non-2xx raises `ApiResponseError`, as the JS `validate` does. This SDK once kept `httpx.HTTPStatusError` there so that `validate` would match the other inherited protocol routes; since `mthds` raises `ApiResponseError` from every route and this client overrides the seam it raises through, the same consistency argument now puts every route, `validate` included, on the typed error. The verdict itself (valid/invalid) is always a 200 either way. **What the report carries.** A valid `PipelexValidationReport` adds typed fields beyond the protocol base. `warnings: list[ValidationErrorItem]` are advisory lints on a bundle that is nonetheless valid — the same item type as `validation_errors[]`, so one parser serves both channels, but they never flip `is_valid` (this is where the `hint_*` error types ride). `liftable_pipes: list[LiftablePipeEntry]` inventories the pipes the runtime may skip when an optional slot resolves absent. `input_form: InputForm | None` and `output_form: OutputForm | None` carry the per-pipe input- and output-form descriptors, keyed exactly like `pipe_io_contracts`, each present only when the request named that view. `default_pipe_ref: str | None` is the qualified `pipe_ref` a caller gets by omitting the pipe selector, `None` when the closure declares none or several — manifest-aware for a fetched package, which is what makes it outrank a `bundle_blueprint` read; `docs/input-preparation.md` walks the fallback ladder `prepare_inputs` runs when a runner predates it. The lists default empty and the optionals default `None`, so a body from an older runner still parses; an empty list is also what a clean bundle yields, so no caller can tell the two apart. On the view fields the same default says something stronger: an opt-in view's absence means the request did not ask for it, never that the method has nothing to describe. `PipelexInvalidReport` gains none of them — they all derive from a crate that was never assembled. @@ -294,13 +295,13 @@ Each stays deferred rather than silently missing. Everything else — the protoc **Models** — field-for-field across the run-lifecycle types and the product wire models. Deliberate idiomatic ports (not gaps): milliseconds → seconds (`interval_seconds` / `timeout_seconds` / `elapsed_seconds`); the JS `AbortSignal` → Python `asyncio` cancellation (no `signal` field, and no `aborted` flag on the download verdict — a cancelled `download_artifacts` raises `CancelledError` with its partial files already unlinked); the JS artifact request object → `DownloadArtifactsOptions` beside an explicit `dir_path` (`dir` being a Python builtin) and the raw bulk call named `resolve_storage_urls_bulk` after its route rather than `resolveStorageUrls`, so it cannot be misread as the single-reference `resolve_storage_url`; the returned `Response` of `fetchArtifact` → an async context manager, because an httpx stream is only live inside its own block; JS inline string-unions promoted to `StrEnum`s (`OrgRole`, `PipeStatus`, the onboarding fields) with identical wire values; response models are `extra="allow"` for forward-compat. The Pipelex validation narrowing is **owned here** (`pipelex_sdk.validation_models`), narrowing `mthds`'s neutral verdict bases (the resolved follow-up #9); the brand-neutral `Dict*` wire concretes (`DictRunResultExecute`, and the `DictPipeOutputAbstract` / `DictWorkingMemoryAbstract` pair that types `RunResults.pipe_output` and `RunResults.working_memory`) are reused from `mthds` by inheritance or by import — they are a shared wire contract the `pipelex` runtime also builds on — rather than duplicated as `pipelex-sdk-js` does. One addition runs ahead of the JS SDK: `write_codegen_tree` has no `@pipelex/sdk` counterpart, because the JS writer lives inside `pipelex-starter-js`'s own harness, mixed in with project policy; the byte-fidelity contract is small and load-bearing enough to belong to the SDK, where every consumer shares one correct implementation. The drift check goes the other way and takes a different shape on purpose: `@pipelex/sdk`'s `runCodegenCheck` is **pure** — the caller walks its own tree and hands in the text — because that module must stay free of Node builtins for a browser bundle, and its doc consequently loads the caller with obligations (walk the whole tree, do not reformat, decode strictly) whose every breach yields a wrong verdict rather than an error. Python has no such constraint, this package already does filesystem work, and `pipelex`'s own surface takes a root — so `run_codegen_check(root=…)` takes one too. Every caller obligation becomes the library's, the verdict is provable against `pipelex` by calling both with the same directory, and it composes with `write_codegen_tree(report, output_dir=…)` as the same path in and out. Two divergences worth naming: the page envelopes keep the wire's snake_case `next_cursor`, where the JS mirror renamed it `nextCursor` for its own consumers; and the method-files catalog converter (`parse_method_files` / `serialize_method_files`) lives in this package, where the JS pair lives in `mthds-js` because `pipelex-mcp` consumes the same format and wanted one owner. There is no second Python consumer, and the catalog serialization is a Pipelex product concern rather than an MTHDS protocol one, so this SDK is a proper home for it. `mthds` has since grown the canonical pair as `mthds.protocol.method_files`, shipped since `mthds` 0.15.0 and present in the `mthds==0.16.0` this package pins exactly, and the local pair is kept beside it on purpose rather than left over. Adoption is a step of its own rather than a rename: that parser raises `PipelineRequestError`, which does not subclass `ValueError`, and pydantic converts only `ValueError` out of a validator — so re-pointing `MethodData`'s validator at it would stop every caller's `except ValidationError` from catching a malformed stored source. The exception's base class is `mthds`'s to settle, and it is settled first; until then the two implementations are kept in step, and they disagree on blankness (this one is Python's `str.strip`, the canonical one is ECMAScript's), on the serialized bytes (default separators and ASCII escapes here, `JSON.stringify`'s there), and on the JSON constants and integer-literal cap the canonical one closes with `parse_constant=` and `parse_int=float`. -**Errors** — `ApiResponseError`, `ApiUnreachableError`, `PipelineExecuteTimeoutError`, `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`, `PagingNotTerminatingError` are owned here; `RunStillRunningError` is re-exported from `mthds`. `ClientAuthenticationError` is **not** ported: it is a dormant export in the JS barrel (defined and exported but never raised by the client), and in Python it already lives in `mthds.runners.api.exceptions` — importable directly if ever needed, with no barrel here to re-export it through. +**Errors** — `ApiUnreachableError`, `PipelineExecuteTimeoutError`, `RunFailedError`, `RunTimeoutError`, `RunLifecycleUnavailableError`, `PagingNotTerminatingError` are owned here; `ApiResponseError` is owned here as a narrowing of `mthds`'s own, with the same members as `@pipelex/sdk`'s apart from the inference members (`model`, `provider`, `provider_metadata`, `migration`) the JS error also lifts, which a Python caller reads off `problem`; `RunStillRunningError` is re-exported from `mthds`. `ClientAuthenticationError` is **not** ported: it is a dormant export in the JS barrel (defined and exported but never raised by the client), and in Python it already lives in `mthds.runners.api.exceptions` — importable directly if ever needed, with no barrel here to re-export it through. **Resolved parity flags (Checkpoint 5):** - **`PipelineExecuteTimeoutError` / `execute` gateway-timeout translation** (Phase-1 flag) — **implemented** (see "`execute` override" above), closing the one genuine feature gap rather than deferring it. - **`health` error regime** (decision #5) — **kept** the plainer `PipelineRequestError` regime; already matches JS, needs no `code` taxonomy. -- **`validate` error regime** (Phase-3 flag) — **deferred**; keeps the inherited `httpx.HTTPStatusError` regime for consistency with the other Python protocol routes (see "`validate` override" above). +- **Protocol-route error regime** (the Phase-3 `validate` flag, and decision #5's inherited-protocol vs product split) — **unified**: every `/v1` route raises `ApiResponseError`, `mthds`'s own error narrowed, as every `@pipelex/sdk` route does (see "Error regimes" above). **Conscious exclusions:** the surfaces listed at the top of this section, and the organization *switch* (a WorkOS session op, not a `/v1` route). diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 10f2628..b72262a 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -8,8 +8,9 @@ surface, and `health`. This module holds construction, the transport extension helpers (`_request_product`, -`_request_json`, `_send_or_unreachable`), the `problem+json` error-body parser, the -`execute` override (hosted gateway-timeout translation), the durable run lifecycle, the +`_request_json`, `_send_or_unreachable`), the `_raise_api_response_error` override that +every route raises through, the `execute` override (hosted gateway-timeout translation), +the `start` override (bare-runner 404 translation), the durable run lifecycle, the `validate` override (markdown-render injection + `validate_files`), the Pipelex product surface (methods, organizations, billing, API keys, onboarding, storage, run records), and the origin-level `health` probe. @@ -18,15 +19,15 @@ from __future__ import annotations import asyncio -import json import os from time import monotonic -from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, cast +from typing import TYPE_CHECKING, Any, NoReturn, cast from urllib.parse import quote, urlencode, urlparse import httpx from mthds.protocol.exceptions import PipelineRequestError from mthds.runners.api.client import MthdsAPIClient +from mthds.runners.api.problem import ProblemDocument from pydantic import BaseModel, TypeAdapter, ValidationError from pydantic_core import to_json from typing_extensions import override @@ -51,7 +52,7 @@ ResolveResponse, ResolveResponseAdapter, ) -from pipelex_sdk.error_models import FieldError, UserAction +from pipelex_sdk.error_models import FieldError from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -108,6 +109,7 @@ from contextlib import AbstractAsyncContextManager from pathlib import Path + from mthds.protocol.models import ValidationDiagnostic from mthds.protocol.pipe_output import VariableMultiplicity from mthds.protocol.pipeline_inputs import PipelineInputs from mthds.protocol.stuff import StuffType @@ -140,7 +142,10 @@ # non-empty pages: neither iterator's adjacent-cursor check sees a non-adjacent repeat. _MAX_LIST_PAGES: int = 10_000 _DEFAULT_DEGRADED_RETRY_SECONDS = 5 # matches the platform's `_DEGRADE_RETRY_AFTER_SECONDS`. -_REQUEST_ID_HEADER = "x-request-id" # httpx headers are case-insensitive; the platform sends `X-Request-ID`. + +# How much of a body that is no problem document an error's message quotes; `response_body` keeps it +# whole. The same limit as the `mthds` base, so a refusal reads the same from either client. +_REASON_BODY_LIMIT = 500 # The hosted gateway caps synchronous requests at ~30s. A blocking-`execute` failure at/after # this elapsed threshold is the gateway cut-off, not a transient outage — the threshold guards @@ -222,6 +227,10 @@ class PipelexAPIClient(MthdsAPIClient): are paged: `list_methods` / `list_runs` answer one `{items, next_cursor}` page, and `iterate_methods` / `iterate_runs` follow the cursors for the whole catalog. + Every `/v1` route — protocol, lifecycle and product — raises this SDK's `ApiResponseError` + on a non-2xx answer, its message and members carrying the problem document's reason, the + next step the server advises and, for a refused run, the diagnostics naming the failing pipe. + Construction is Pipelex-only — it never reads the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`), whose values are a credential pair for whatever runner the vendor-neutral `mthds` tooling targets, not for this client. The API key @@ -371,44 +380,63 @@ async def _request_json(self, method: str, url: str, *, body: object | None = No raise PipelineRequestError(msg) return response.json() + @override def _raise_api_response_error(self, *, method: str, endpoint: str, response: httpx.Response) -> NoReturn: - """Parse an error response and raise the typed `ApiResponseError`.""" - body_text = response.text - parsed = _parse_error_body(body_text) - detail = parsed.server_message or body_text or response.reason_phrase - msg = f"API {method} /{_API_PREFIX}/{endpoint} failed ({response.status_code}): {detail}" - # The platform stamps the same correlation id on the `X-Request-ID` header as in the body; - # a problem rendered without the member (or a body that is no problem at all) still has it. - request_id = parsed.request_id or response.headers.get(_REQUEST_ID_HEADER) or None + """Raise this SDK's `ApiResponseError` for a non-2xx answer — the one place an answer becomes an error. + + Overrides the protected seam of the `mthds` base, so every inherited protocol route + (`execute`, `start`, `validate`, `models`, `version`) raises this SDK's subclass, as do the + run status and results reads and the product routes, which call it directly. The members + both clients share are read through the base's own parse (`ProblemDocument`), so they read + the same from either client; this adds the Pipelex members the standard's client leaves + out — the platform's `code`, the runner's `error_category` and the platform's field-level + `errors[]` — and narrows `validation_errors` to `ValidationErrorItem`. + + Args: + method: The HTTP method of the request, for the message (`POST`). + endpoint: The endpoint below `/v1`, query included, exactly as the route sent it: it + names the request in the message and gives `request_url`. + response: The API's non-2xx answer. + + Raises: + ApiResponseError: Always. + """ + document = ProblemDocument.make_from_response(response) + members = document.members or {} + msg = f"API {method} /{_API_PREFIX}/{endpoint} failed ({response.status_code}): {_failure_reason(document, response)}" raise ApiResponseError( msg, api_url=self.base_url, status=response.status_code, status_text=response.reason_phrase, - response_body=body_text, - error_type=parsed.error_type, - server_message=parsed.server_message, - validation_errors=parsed.validation_errors, - code=parsed.code, - request_id=request_id, - type_uri=parsed.type_uri, - title=parsed.title, - error_domain=parsed.error_domain, - error_category=parsed.error_category, - retryable=parsed.retryable, - user_action=parsed.user_action, - errors=parsed.errors, - problem=parsed.problem, + response_body=response.text, + headers=dict(response.headers), + request_url=self._url(endpoint), + error_type=document.error_type, + server_message=document.server_message, + validation_errors=_narrowed_validation_errors(document.validation_errors), + type_uri=document.type_uri, + title=document.title, + instance=document.instance, + request_id=document.request_id, + error_domain=document.error_domain, + retryable=document.retryable, + user_action=document.user_action, + problem=document.members, + code=_non_empty_string_member(members, "code"), + error_category=_non_empty_string_member(members, "error_category"), + errors=_field_errors_of(members.get("errors")), ) - def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> None: + def _raise_if_lifecycle_unavailable(self, *, status: int, body: str, url: str) -> None: """Translate a "route absent" 404 (a bare pipelex-api with no platform block) into a clear - `RunLifecycleUnavailableError`. The platform's own 404s (run not found / cross-org) carry a - structured problem+json envelope (a `code` field) and are left for normal handling. + `RunLifecycleUnavailableError`. A 404 the platform or the runner answered on purpose — a run not + found, a `method_ref` with no package — carries a problem document (`code` or `error_type`) and is + left for normal handling as `ApiResponseError`. """ - if response.status_code != 404: + if status != 404: return - if _is_missing_route_404(response): + if _is_missing_route_404(body): msg = ( f"The durable run lifecycle is not available: {url} returned 404. Run polling is a " f"hosted-API extension (/{_API_PREFIX}/{_RUNS}/*), not part of the MTHDS Protocol; " @@ -447,9 +475,9 @@ async def execute( or a client-side request timeout, after at least ~28s have elapsed — is translated into a clear `PipelineExecuteTimeoutError` pointing at the durable start+poll path, matching the JS SDK. The protocol's optional 202 async-degrade still raises - `RunStillRunningError` (from the inherited `execute`), and every other non-2xx keeps - the inherited `httpx.HTTPStatusError` regime (consistent with the other inherited - protocol routes). + `RunStillRunningError` (from the inherited `execute`), and every other non-2xx raises + `ApiResponseError`, whose message and members carry the server's reason, the next step + it advises and, for a method it refuses to run, the diagnostics naming the failing pipe. Args: pipe_code: The code identifying the pipe to execute. Beside a `method_ref` it @@ -489,7 +517,8 @@ async def execute( inline `mthds_contents` or with `method_id`. RunStillRunningError: The server answered 202 (the protocol's optional async degrade) — the run continues server-side; resume by `pipeline_run_id`. - httpx.HTTPStatusError: Any other non-2xx response (the inherited regime). + ApiResponseError: Any other non-2xx answer — a refusal to run an invalid method (a + `422`, its diagnostics on `validation_errors`), a failed run, auth, a server fault. """ merged_extra = _merge_run_extensions(extra, method_ref=method_ref, method_id=method_id) _assert_method_ref_pairs_with_nothing(mthds_contents=mthds_contents, merged_extra=merged_extra) @@ -504,7 +533,7 @@ async def execute( dynamic_output_concept_ref=dynamic_output_concept_ref, extra=merged_extra, ) - except (httpx.HTTPStatusError, httpx.TimeoutException) as exc: + except (ApiResponseError, httpx.TimeoutException) as exc: elapsed_seconds = monotonic() - started_at if _is_gateway_timeout(exc, elapsed_seconds): raise PipelineExecuteTimeoutError(_execute_timeout_message(elapsed_seconds), elapsed_seconds=elapsed_seconds) from exc @@ -536,10 +565,9 @@ async def start( `method_id` (the hosted platform's own run arg), both documented on `execute` and carrying the same semantics and exclusivity here — and that a bare-runner missing-route 404 (no run store) is translated into a clear - `RunLifecycleUnavailableError` instead of a raw `httpx.HTTPStatusError`, matching the - JS SDK and letting `start_and_wait` self-heal to the blocking-execute fallback. The - platform's structured 404s (run not found) keep their normal `httpx.HTTPStatusError` - behavior. + `RunLifecycleUnavailableError`, matching the JS SDK and letting `start_and_wait` + self-heal to the blocking-execute fallback. Every other non-2xx — the platform's + structured 404s included — raises `ApiResponseError`. Returns: The 202 ack as `PipelexRunResultStart` — the authoritative `pipeline_run_id`, @@ -552,6 +580,8 @@ async def start( selector is present and is not a string, or `method_ref` is combined with inline `mthds_contents` or with `method_id`. RunLifecycleUnavailableError: The configured server has no run store. + ApiResponseError: Any other non-2xx answer — a refusal to run an invalid method (a + `422`, its diagnostics on `validation_errors`), auth, a server fault. """ merged_extra = _merge_run_extensions(extra, method_ref=method_ref, method_id=method_id) _assert_method_ref_pairs_with_nothing(mthds_contents=mthds_contents, merged_extra=merged_extra) @@ -565,8 +595,10 @@ async def start( dynamic_output_concept_ref=dynamic_output_concept_ref, extra=merged_extra, ) - except httpx.HTTPStatusError as exc: - self._raise_if_lifecycle_unavailable(exc.response, str(exc.request.url)) + except ApiResponseError as exc: + # The inherited route raised through `_raise_api_response_error`; the error keeps the + # status, the body and the URL, which is all the missing-route test reads. + self._raise_if_lifecycle_unavailable(status=exc.status, body=exc.response_body, url=exc.request_url or self._url("start")) raise # Re-validate the base ack into the Pipelex-branded subtype (types `method_provenance`; # any other implementation extra keeps riding `model_extra`). @@ -590,7 +622,7 @@ async def validate( # type: ignore[override] body discriminated on `is_valid`, returned verbatim as the `PipelexValidationResult` union (an invalid bundle is NOT raised; the caller match/cases `is_valid`). A non-2xx means no verdict could be produced (request shape, auth, server fault) and surfaces as - `httpx.HTTPStatusError` (the inherited protocol error regime). A selector-resolution + `ApiResponseError`, like every other route. A selector-resolution failure — a fetch failure, no package at the address, an unknown or foreign-org id — is a non-2xx too, never an `is_valid: false` verdict, which is reserved for actual MTHDS content. @@ -650,6 +682,8 @@ async def validate( # type: ignore[override] Raises: PipelineRequestError: Zero or several selectors were supplied, a selector is not a string, or `mthds_sources` was supplied beside a selector. + ApiResponseError: No verdict could be produced — a request-shape `422`, a selector + that did not resolve, auth, a server fault. """ selected_method_ref = _normalized_selector(name="method_ref", value=method_ref) selected_method_id = _normalized_selector(name="method_id", value=method_id) @@ -687,7 +721,8 @@ async def validate( # type: ignore[override] if selected_method_id is not None: body["method_id"] = selected_method_id response = await self._send("POST", self._url("validate"), content=to_json(body), request_timeout=self.request_timeout_seconds) - response.raise_for_status() + if not response.is_success: + self._raise_api_response_error(method="POST", endpoint="validate", response=response) return PipelexValidationResultAdapter.validate_python(response.json()) async def validate_files( @@ -746,12 +781,14 @@ async def get_run_status(self, run_id: str) -> RunRead: Raises: RunLifecycleUnavailableError: If the lifecycle routes are absent (a bare runner). ApiUnreachableError: If the host cannot be reached (DNS / connect / TLS / timeout). - httpx.HTTPStatusError: For a genuine run-not-found 404 or any other non-2xx response. + ApiResponseError: For a genuine run-not-found 404 or any other non-2xx response. """ - url = self._url(f"{_RUNS}/{quote(run_id, safe='')}/status") + endpoint = f"{_RUNS}/{quote(run_id, safe='')}/status" + url = self._url(endpoint) response = await self._send_or_unreachable("GET", url, content=None, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) - self._raise_if_lifecycle_unavailable(response, url) - response.raise_for_status() + self._raise_if_lifecycle_unavailable(status=response.status_code, body=response.text, url=url) + if not response.is_success: + self._raise_api_response_error(method="GET", endpoint=endpoint, response=response) run = RunRead.model_validate(response.json()) retry_after = _parse_retry_after(response.headers) if retry_after is not None: @@ -771,9 +808,10 @@ async def get_run_result(self, run_id: str) -> RunResultState: Raises: RunLifecycleUnavailableError: If the lifecycle routes are absent (a bare runner). ApiUnreachableError: If the host cannot be reached (DNS / connect / TLS / timeout). - httpx.HTTPStatusError: For a genuine run-not-found 404 or any other non-2xx response. + ApiResponseError: For a genuine run-not-found 404 or any other non-2xx response. """ - url = self._url(f"{_RUNS}/{quote(run_id, safe='')}/results") + endpoint = f"{_RUNS}/{quote(run_id, safe='')}/results" + url = self._url(endpoint) response = await self._send_or_unreachable("GET", url, content=None, request_timeout=_POLL_REQUEST_TIMEOUT_SECONDS) status_code = response.status_code @@ -786,8 +824,9 @@ async def get_run_result(self, run_id: str) -> RunResultState: if status_code == 409: return _run_result_failed(run_id, response) - self._raise_if_lifecycle_unavailable(response, url) - response.raise_for_status() + self._raise_if_lifecycle_unavailable(status=response.status_code, body=response.text, url=url) + if not response.is_success: + self._raise_api_response_error(method="GET", endpoint=endpoint, response=response) # Inspect the decoded payload before validating: `main_stuff` is a required field on `RunResults`, # so a `200` that omits the key would raise a raw Pydantic error instead of the typed # `MissingMainStuffError`. `.get(...) is None` covers both the missing-key and explicit-null cases @@ -850,7 +889,9 @@ async def _supports_run_lifecycle(self) -> bool: if self._lifecycle_available is None: try: info = await self.version() - except (httpx.HTTPError, ValidationError): + # A non-2xx answer (`ApiResponseError`), a transport failure (the inherited route sends on + # the raw transport, so an `httpx.HTTPError`) or a body that is no version: assume hosted. + except (ApiResponseError, httpx.HTTPError, ValidationError): self._lifecycle_available = True else: implementation = (info.model_extra or {}).get("implementation") @@ -1530,7 +1571,7 @@ def _product_query(params: dict[str, str | int | None]) -> str: return "?" + urlencode(kept) -def _is_gateway_timeout(exc: httpx.HTTPStatusError | httpx.TimeoutException, elapsed_seconds: float) -> bool: +def _is_gateway_timeout(exc: ApiResponseError | httpx.TimeoutException, elapsed_seconds: float) -> bool: """Whether a failed blocking `execute` is the hosted gateway's ~30s synchronous cut-off. The elapsed threshold guards against mislabeling a fast `503` (the runner genuinely down) @@ -1541,7 +1582,7 @@ def _is_gateway_timeout(exc: httpx.HTTPStatusError | httpx.TimeoutException, ela return False if isinstance(exc, httpx.TimeoutException): return True - return exc.response.status_code in {503, 504} + return exc.status in {503, 504} def _execute_timeout_message(elapsed_seconds: float) -> str: @@ -1554,18 +1595,25 @@ def _execute_timeout_message(elapsed_seconds: float) -> str: ) -def _is_missing_route_404(response: httpx.Response) -> bool: - """Whether a 404 is an unmatched-route 404 (no platform deployed) rather than the platform's - structured run-not-found 404. The platform wraps its 404s in RFC 7807 problem+json with a stable - `code`; a bare runner returns Starlette's default `{"detail": "Not Found"}` (no `code`). +# The members that make a 404 an answer rather than an absent route: every problem the platform renders +# carries its `code`, and every problem the runner renders carries its `error_type` — the runner's own +# refusals, relayed by the platform unchanged (a `method_ref` with no package behind it is a 404 of the +# runner's). The same test the platform's relay applies to a runner body. +_ANSWERED_404_MEMBERS: frozenset[str] = frozenset({"code", "error_type"}) + + +def _is_missing_route_404(body: str) -> bool: + """Whether a 404's body is an unmatched-route 404 (no run store deployed) rather than a 404 the + platform or the runner answered on purpose. + + The platform renders its 404s (a run not found) as problem documents carrying a stable `code`, and + the runner renders its own (a `method_ref` whose package does not exist) carrying its `error_type`; + a bare runner's unmatched route answers Starlette's default `{"detail": "Not Found"}`, which carries + neither. An empty, non-JSON or non-object body is no answer either. `type` is deliberately not read: + a generic RFC 9457 renderer puts `type: "about:blank"` on an unmatched route too. """ - try: - body = response.json() - except ValueError: - return True - if not isinstance(body, dict): - return True - return "code" not in body + members = ProblemDocument.make_from_body(body).members + return members is None or _ANSWERED_404_MEMBERS.isdisjoint(members) def _parse_retry_after(headers: httpx.Headers) -> int | None: @@ -1580,34 +1628,6 @@ def _parse_retry_after(headers: httpx.Headers) -> int | None: return seconds if seconds >= 0 else None -def _decode_object(text: str) -> dict[str, Any] | None: - """Decode an error body into its JSON object, or `None` for an empty, non-JSON or non-object body.""" - if not text: - return None - try: - parsed = json.loads(text) - except ValueError: - return None - if not isinstance(parsed, dict): - return None - return cast("dict[str, Any]", parsed) - - -def _error_message_of(body: dict[str, Any]) -> str | None: - """Extract a human message from an error body — the platform's problem+json (`detail` string) - and the runner's `{"detail": {"message": ...}}` / `{"message": ...}` shapes. - """ - detail = body.get("detail") - if isinstance(detail, str): - return detail - if isinstance(detail, dict): - message = cast("dict[str, Any]", detail).get("message") - if isinstance(message, str): - return message - top_message = body.get("message") - return top_message if isinstance(top_message, str) else None - - def _run_result_failed(run_id: str, response: httpx.Response) -> RunResultFailed: """Build the failed arm from the results read's `409` problem document. @@ -1619,8 +1639,9 @@ def _run_result_failed(run_id: str, response: httpx.Response) -> RunResultFailed stored result it refuses to read, or one from a platform that predates the member) or with a status this SDK does not know reads as `FAILED`, and its `detail` still says what happened. """ - body = _decode_object(response.text) or {} - message = _error_message_of(body) or "Run finished without a result." + document = ProblemDocument.make_from_body(response.text) + body = document.members or {} + message = document.server_message or "Run finished without a result." raw_status = body.get("run_status") status = RunStatus(raw_status) if isinstance(raw_status, str) and raw_status in _KNOWN_RUN_STATUS_NAMES else RunStatus.FAILED # `error` is validated by the field's own lenient type (`LenientRunErrorReport`): a report whose @@ -1658,111 +1679,57 @@ def _origin_of(base_url: str) -> str: return f"{parsed.scheme}://{parsed.netloc}" -class _ParsedErrorBody(NamedTuple): - """The members pulled out of a `problem+json` / `HTTPException` error body.""" - - error_type: str | None - server_message: str | None - validation_errors: list[ValidationErrorItem] | None - code: str | None - request_id: str | None - type_uri: str | None - title: str | None - error_domain: str | None - error_category: str | None - retryable: bool | None - user_action: UserAction | None - errors: list[FieldError] | None - problem: dict[str, Any] | None - - -_EMPTY_ERROR_BODY = _ParsedErrorBody( - error_type=None, - server_message=None, - validation_errors=None, - code=None, - request_id=None, - type_uri=None, - title=None, - error_domain=None, - error_category=None, - retryable=None, - user_action=None, - errors=None, - problem=None, -) +def _failure_reason(document: ProblemDocument, response: httpx.Response) -> str: + """The reason a non-2xx answer gives, in the order a person is best served by. -# The structured members below are read leniently (best-effort error-path enrichment): an odd shape -# reads as `None` and never masks the underlying failure, which `server_message` and the raw -# `problem` still carry. `validation_errors` items are a closed shape, so the list is validated whole; -# a `FieldError` reads each field leniently, so only a non-object item sets `errors` to `None`. + The problem's `detail`, else its `title`, else the raw body (cut short, since a gateway's HTML + page can be long), else the status text — the order the `mthds` base client's own message uses, + kept identical so a refusal reads the same whichever client raised it. + """ + for candidate in (document.server_message, document.title): + if candidate and candidate.strip(): + return candidate + body = response.text.strip() + if body: + return body if len(body) <= _REASON_BODY_LIMIT else f"{body[:_REASON_BODY_LIMIT]}…" + return response.reason_phrase or "no reason given" + + +# The Pipelex members below are read leniently, like the shared ones `ProblemDocument` reads: an odd +# shape reads as `None` and never masks the underlying failure, which `server_message` and the raw +# `problem` still carry. `validation_errors` items are a closed shape, so the list is narrowed whole; a +# `FieldError` reads each field leniently, so only a non-object item sets `errors` to `None`. _VALIDATION_ERRORS_ADAPTER: TypeAdapter[list[ValidationErrorItem]] = TypeAdapter(list[ValidationErrorItem]) _FIELD_ERRORS_ADAPTER: TypeAdapter[list[FieldError]] = TypeAdapter(list[FieldError]) -def _parse_error_body(body: str) -> _ParsedErrorBody: - """Extract the members of an error body into `_ParsedErrorBody`. +def _narrowed_validation_errors(diagnostics: list[ValidationDiagnostic] | None) -> list[ValidationErrorItem] | None: + """Narrow the protocol's neutral diagnostics to this SDK's `ValidationErrorItem`, or `None`. - The API serializes errors as RFC 9457 problem documents — the platform's (`type`, `title`, - `status`, `code`, `detail`, `instance`, `request_id`, `errors[]`) and the runner's (the same - standard slots plus `error_type`, `error_domain`, `error_category`, `retryable`, `user_action`, - `validation_errors`, …) — and, on older routes, as `{"detail": {"error_type": ..., "message": - ...}}` (HTTPException with dict detail). Both shapes are handled, with top-level `error_type` / - `message` fallbacks. A string member of the wrong type reads as `None`; the whole decoded object - rides `problem`, so no member is lost for being unnamed here. Falls through to empty on a - non-JSON or non-object body. + `ProblemDocument` keeps the list only when every item is a diagnostic, and carries the runner's + locators (`pipe_code`, `field_path`, …) on each item's extras; the narrowing types them. A list + whose items do not all fit — a category this SDK does not know — reads as `None`, the raw list + staying on the error's `problem`. """ - root = _decode_object(body) - if root is None: - return _EMPTY_ERROR_BODY - - error_type: str | None = None - detail = root.get("detail") - if isinstance(detail, dict): - error_type = _str_member(cast("dict[str, Any]", detail), "error_type") - if error_type is None: - error_type = _str_member(root, "error_type") - - validation_errors: list[ValidationErrorItem] | None = None - raw_validation_errors = root.get("validation_errors") - if isinstance(raw_validation_errors, list): - try: - validation_errors = _VALIDATION_ERRORS_ADAPTER.validate_python(raw_validation_errors) - except ValidationError: - validation_errors = None + if diagnostics is None: + return None + try: + return _VALIDATION_ERRORS_ADAPTER.validate_python([diagnostic.model_dump() for diagnostic in diagnostics]) + except ValidationError: + return None - errors: list[FieldError] | None = None - raw_errors = root.get("errors") - if isinstance(raw_errors, list): - try: - errors = _FIELD_ERRORS_ADAPTER.validate_python(raw_errors) - except ValidationError: - errors = None - - # `UserAction` reads each field leniently, so any object validates; a non-object reads as `None`. - raw_user_action = root.get("user_action") - user_action = UserAction.model_validate(raw_user_action) if isinstance(raw_user_action, dict) else None - - raw_retryable = root.get("retryable") - - return _ParsedErrorBody( - error_type=error_type, - server_message=_error_message_of(root), - validation_errors=validation_errors, - code=_str_member(root, "code"), - request_id=_str_member(root, "request_id"), - type_uri=_str_member(root, "type"), - title=_str_member(root, "title"), - error_domain=_str_member(root, "error_domain"), - error_category=_str_member(root, "error_category"), - retryable=raw_retryable if isinstance(raw_retryable, bool) else None, - user_action=user_action, - errors=errors, - problem=root, - ) + +def _field_errors_of(value: Any) -> list[FieldError] | None: + """The platform's field-level `errors[]`, or `None` when it is absent or not a list of objects.""" + if not isinstance(value, list): + return None + try: + return _FIELD_ERRORS_ADAPTER.validate_python(value) + except ValidationError: + return None -def _str_member(body: dict[str, Any], key: str) -> str | None: - """A string member of a decoded body, or `None` when it is absent or not a string.""" - value = body.get(key) - return value if isinstance(value, str) else None +def _non_empty_string_member(members: dict[str, Any], key: str) -> str | None: + """A non-empty string member of a decoded body, or `None` when it is absent, empty or not a string.""" + value = members.get(key) + return value if isinstance(value, str) and value else None diff --git a/pipelex_sdk/error_models.py b/pipelex_sdk/error_models.py index d459fd0..a0efed3 100644 --- a/pipelex_sdk/error_models.py +++ b/pipelex_sdk/error_models.py @@ -6,7 +6,12 @@ (`PipelineRun.error`), and inside the problem document of the results read's `409`, where the client lifts it onto `RunResultFailed.error` and `RunFailedError.error`. The same classification fields (`error_domain`, `user_action`, …) ride a runner-rendered problem document as extension -members, which is why `ApiResponseError` types its `user_action` with the model declared here. +members, but a refused request's `ApiResponseError` reads them through `mthds`'s own problem parse, +so its `user_action` is `mthds.runners.api.problem.UserAction` — kept only whole, `kind` and `detail` +both required — while a stored report's is the lenient `UserAction` declared here, both fields +optional, because a report written by another runner version must never fail the read carrying it. +The two share their field names, so code that shows the next step reads `user_action.detail` on +either, checking it for `None` on a report. Every field is optional and every model is extension-open (`extra="allow"`), for the reason `TokensUsageRecord` gives: the runner adds fields without asking this SDK, and a field this version diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index bc0b842..096f305 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -8,9 +8,10 @@ / TLS / timeout). Distinguished from `ApiResponseError`, which represents a non-2xx response that *did* come back. - `ApiResponseError` — a non-2xx response from the API, carrying the members of its - RFC 9457 problem document: the branch fields `type_uri` (the problem's `type`) and, - on a runner-rendered problem, `error_domain`; the surface-native `code` / `error_type`; - the request id; and the rest (decoupled from the HTTP status). + RFC 9457 problem document. It subclasses `mthds`'s own `ApiResponseError`, narrowing its + `validation_errors` to this SDK's `ValidationErrorItem` and adding the Pipelex members the + standard's client leaves out (`code`, `error_category`, `errors`), so every route — + protocol, lifecycle and product alike — raises this one class. - `PipelineExecuteTimeoutError` — a blocking `execute()` killed by the hosted gateway's ~30s synchronous-request ceiling; points the caller at the durable start+poll path. - `PagingNotTerminatingError` — a paged-list iterator hit its runaway backstop, meaning @@ -42,18 +43,22 @@ from typing import TYPE_CHECKING from mthds.protocol.exceptions import PipelineRequestError +from mthds.runners.api.exceptions import ApiResponseError as MthdsApiResponseError # Explicit re-export (PEP 484 `as` self-alias): the protocol 202-degrade error stays owned by # `mthds`, surfaced here so consumers have a single import home for the run/lifecycle errors. from mthds.runners.api.exceptions import RunStillRunningError as RunStillRunningError # ruff: ignore[useless-import-alias] +from pipelex_sdk.validation_models import ValidationErrorItem + if TYPE_CHECKING: from typing import Any + from mthds.runners.api.problem import UserAction + from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult - from pipelex_sdk.error_models import FieldError, RunErrorReport, UserAction + from pipelex_sdk.error_models import FieldError, RunErrorReport from pipelex_sdk.runs import RunStatus - from pipelex_sdk.validation_models import ValidationErrorItem class ApiUnreachableError(PipelineRequestError): @@ -73,12 +78,23 @@ def __init__(self, message: str, api_url: str, code: str | None = None) -> None: self.code = code -class ApiResponseError(PipelineRequestError): +class ApiResponseError(MthdsApiResponseError[ValidationErrorItem]): """A non-2xx response that DID come back from the API, with its problem document parsed. - Every error the hosted API answers is an RFC 9457 `application/problem+json` document, and this - error carries its members as typed attributes, each `None` when the document did not carry it: - + Every route of `PipelexAPIClient` raises it on a non-2xx answer: the protocol routes it inherits + from `mthds` (`execute`, `start`, `validate`, `models`, `version`), the run status and results + reads, and the product routes. It is `mthds`'s own `ApiResponseError` narrowed to this SDK, so a + handler written against the standard's client (`except mthds.runners.api.exceptions.ApiResponseError`) + catches it too. Every error the hosted API answers is an RFC 9457 `application/problem+json` + document, and this error carries its members as typed attributes, each `None` when the document + did not carry it: + + - **What happened, for a person.** `str(exc)` names the request and the status, gives the reason + (the problem's `detail`, else its `title`, else the raw body, else the status text) and, when + the server advised one, the next step on its own line (`Next step: …`). `server_message` is the + `detail` alone, `title` the stable label of the error class, `user_action` the advised next + step (`kind` and `detail`, the `mthds` `UserAction`), and `error_category` a finer + classification of an inference failure. - **The branch fields.** `type_uri` (the problem's `type`) is the stable URI naming the error class, on every problem. `error_domain` is the coarse class — `input` (the caller can fix it), `config` (a configuration change is needed), `runtime` (a failure during execution) — carried @@ -88,18 +104,20 @@ class ApiResponseError(PipelineRequestError): - **The native codes.** `code` is the platform's own closed code (`conflict`, `not_found`, `pipelex_api_key_limit_reached`, …) and `error_type` the runner's open exception class name. Each is finer than `error_domain` and specific to the surface that emits it. - - **For a person.** `title` is the stable label of the error class, `server_message` the - per-occurrence `detail`, `user_action` the advised next step, and `error_category` a finer - classification of an inference failure. - **For support.** `request_id` correlates the response with the server's logs; it is read from - the body, or from the `X-Request-ID` response header when the body has none. - - **Per-item failures.** `errors` is the platform's field-level list (`field`, `code`, `detail`), - and `validation_errors` the structured diagnostics of a bundle that failed validation. - - `problem` is the decoded document whole, so a member this SDK does not name — `instance`, or the - `run_status` and `error` of a failed run's results read — stays reachable; `response_body` is the - raw text, and `status` / `status_text` the transport's. `problem` is `None` when the body was not - a JSON object. + the body, or from the `X-Request-ID` response header when the body has none. `instance` names + the occurrence. + - **Per-item failures.** `validation_errors` holds the structured diagnostics of a run route's + refusal to run an invalid method (a `422` from `execute` or `start`), typed as + `ValidationErrorItem`, so the failing pipe reads as `validation_errors[0].pipe_code`; it is + `None` when the refusal is not itemized, or when an item does not fit that type (the raw list + stays on `problem`). `errors` is the platform's field-level list (`field`, `code`, `detail`). + + `problem` is the decoded document whole, so a member this SDK does not name — the `run_status` + and `error` of a failed run's results read, say — stays reachable; `response_body` is the raw + text, `status` / `status_text` the transport's, `headers` the answer's headers (lower-case + names), `request_url` the URL requested and `api_url` the configured base URL. `problem` is + `None` when the body was not a JSON object. """ def __init__( @@ -110,38 +128,72 @@ def __init__( status: int, status_text: str, response_body: str, + headers: dict[str, str] | None = None, + request_url: str | None = None, error_type: str | None = None, server_message: str | None = None, validation_errors: list[ValidationErrorItem] | None = None, - code: str | None = None, - request_id: str | None = None, type_uri: str | None = None, title: str | None = None, + instance: str | None = None, + request_id: str | None = None, error_domain: str | None = None, - error_category: str | None = None, retryable: bool | None = None, user_action: UserAction | None = None, - errors: list[FieldError] | None = None, problem: dict[str, Any] | None = None, + code: str | None = None, + error_category: str | None = None, + errors: list[FieldError] | None = None, ) -> None: - super().__init__(message) - self.api_url = api_url - self.status = status - self.status_text = status_text - self.response_body = response_body - self.error_type = error_type - self.server_message = server_message - self.validation_errors = validation_errors + """Build the error: the `mthds` members, then the Pipelex ones. + + Args: + message: What failed and why, e.g. `API POST /v1/start failed (422): `. The next + step is appended on its own line unless `message` already ends with it. + api_url: The configured base URL of the API that answered. + status: The HTTP status code of the answer. + status_text: The HTTP reason phrase of the answer. + response_body: The answer's body, as text. + headers: The answer's headers, names in lower case. + request_url: The URL the request was sent to. + error_type: The runner's exception class name. + server_message: The problem's `detail`, the reason for this occurrence. + validation_errors: The per-error diagnostics of a refused run. + type_uri: The problem's `type`, the stable URI of the error class. + title: The problem's `title`, the stable label of the error class. + instance: The problem's `instance`, the occurrence. + request_id: The request's correlation id. + error_domain: Who can fix the failure: `input`, `config` or `runtime`. + retryable: Whether the same request can succeed later; `None` when unknown. + user_action: The next step the server advises. + problem: The decoded problem document whole. + code: The platform's closed native code. + error_category: The finer classification of an inference failure. + errors: The platform's field-level failures. + """ + super().__init__( + message, + api_url=api_url, + status=status, + status_text=status_text, + response_body=response_body, + headers=headers, + request_url=request_url, + error_type=error_type, + server_message=server_message, + validation_errors=validation_errors, + type_uri=type_uri, + title=title, + instance=instance, + request_id=request_id, + error_domain=error_domain, + retryable=retryable, + user_action=user_action, + problem=problem, + ) self.code = code - self.request_id = request_id - self.type_uri = type_uri - self.title = title - self.error_domain = error_domain self.error_category = error_category - self.retryable = retryable - self.user_action = user_action self.errors = errors - self.problem = problem class PipelineExecuteTimeoutError(PipelineRequestError): diff --git a/pipelex_sdk/prepare_inputs.py b/pipelex_sdk/prepare_inputs.py index ec79e5b..b189c05 100644 --- a/pipelex_sdk/prepare_inputs.py +++ b/pipelex_sdk/prepare_inputs.py @@ -451,11 +451,10 @@ async def prepare_inputs( selected; or a value at a file position is unusable. HTTP(S) URLs and existing `pipelex-storage://` URIs pass through unchanged, and every failure is raised BEFORE any run is created. - httpx.HTTPStatusError: A no-verdict condition from `/v1/validate` — a malformed + ApiResponseError: A no-verdict condition from `/v1/validate` — a malformed selector, an unknown or foreign-org `method_id` (`404`), a stored method with no - source, a fetch failure at the address. `validate` is 200-diagnostic and stays on - the inherited protocol error regime, so a no-verdict failure arrives as the raw - status error rather than the product routes' `ApiResponseError`. + source, a fetch failure at the address. `validate` is 200-diagnostic, so only a + failure to produce any verdict arrives here, as the typed error every route raises. """ selected_files, selected_method_ref, selected_method_id = _resolve_selector(files=files, method_ref=method_ref, method_id=method_id) # Normalized here rather than at its use below, so a mistyped `pipe_ref` is refused on the diff --git a/pyproject.toml b/pyproject.toml index 9dac52f..a5f7025 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ ] dependencies = [ - "mthds==0.16.0", + "mthds==0.17.0", "pydantic>=2.10.6,<3.0.0", "typing-extensions>=4.0.0", "httpx>=0.23.0,<1.0.0", diff --git a/tests/unit/test_api_response_error.py b/tests/unit/test_api_response_error.py index 859d9cd..a8d2cc5 100644 --- a/tests/unit/test_api_response_error.py +++ b/tests/unit/test_api_response_error.py @@ -135,12 +135,66 @@ def test_members_of_the_wrong_shape_read_as_none(self, mocker: MockerFixture, bo assert err.request_id is None assert err.problem == body - def test_a_user_action_field_that_does_not_fit_reads_as_none_and_the_rest_stands(self, mocker: MockerFixture) -> None: - body = {"detail": "x", "user_action": {"kind": 5, "detail": "Retry in a minute."}, "errors": [{"field": "body.label", "code": 3}]} + @pytest.mark.parametrize( + "user_action", + [{"kind": 5, "detail": "Retry in a minute."}, {"kind": "wait_and_retry", "detail": ""}, {"kind": "wait_and_retry"}], + ) + def test_a_user_action_that_is_no_next_step_reads_as_none_and_the_rest_stands(self, mocker: MockerFixture, user_action: dict[str, Any]) -> None: + body = {"detail": "x", "user_action": user_action, "errors": [{"field": "body.label", "code": 3}]} err = self._raise_from(mocker, _response(422, json_body=body)) - assert err.user_action is not None - assert err.user_action.kind is None - assert err.user_action.detail == "Retry in a minute." + # The `mthds` `UserAction` is kept only whole — a string `kind` and a non-empty `detail`. + assert err.user_action is None + assert str(err) == "API GET /v1/billing/subscription failed (422): x" + # A field-level item keeps the fields that fit. assert err.errors is not None assert [(item.field, item.code) for item in err.errors] == [("body.label", None)] + + def test_the_message_carries_the_next_step_on_its_own_line(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(422, json_body=_RUNNER_PROBLEM)) + + assert str(err) == ( + "API GET /v1/billing/subscription failed (422): Input 'document' expects a Document, got an Image.\n" + "Next step: Send a PDF for the 'document' input." + ) + + @pytest.mark.parametrize( + ("response", "reason"), + [ + (_response(409, json_body={"title": "Conflict", "status": 409}), "Conflict"), + (_response(502, text="Bad Gateway"), "Bad Gateway"), + (_response(502, text="x" * 600), "x" * 500 + "…"), + (_response(503, text=" "), "Service Unavailable"), + (_response(418, json_body={"detail": {"error_type": "PipeRunError", "message": "the input is empty"}}), "the input is empty"), + ], + ) + def test_the_reason_falls_back_from_detail_to_title_to_body_to_status_text( + self, mocker: MockerFixture, response: httpx.Response, reason: str + ) -> None: + err = self._raise_from(mocker, response) + + assert str(err) == f"API GET /v1/billing/subscription failed ({response.status_code}): {reason}" + + def test_the_older_detail_object_shape_reads_its_error_type_and_message(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(500, json_body={"detail": {"error_type": "PipeRunError", "message": "the input is empty"}})) + + assert err.error_type == "PipeRunError" + assert err.server_message == "the input is empty" + assert err.code is None + assert err.validation_errors is None + + def test_validation_errors_whose_category_this_sdk_does_not_know_read_as_none(self, mocker: MockerFixture) -> None: + items = [{"category": "a_category_from_tomorrow", "message": "boom", "pipe_code": "summarize"}] + err = self._raise_from(mocker, _response(422, json_body={"detail": "refused", "validation_errors": items})) + + assert err.validation_errors is None + # The raw list stays reachable on the decoded document. + assert err.problem is not None + assert err.problem["validation_errors"] == items + + def test_headers_and_request_url_are_kept_as_plain_data(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(429, json_body={"detail": "slow down"}, headers={"Retry-After": "12"})) + + assert err.headers["retry-after"] == "12" + assert err.request_url == f"{_BASE_URL}/v1/billing/subscription" + assert err.api_url == _BASE_URL diff --git a/tests/unit/test_client_execute.py b/tests/unit/test_client_execute.py index 18f34d5..ebc32b4 100644 --- a/tests/unit/test_client_execute.py +++ b/tests/unit/test_client_execute.py @@ -2,8 +2,10 @@ Mirrors `pipelex-sdk-js/tests/client.test.ts` "execute gateway 30s timeout": a 503/504 — or a client-side request timeout — at/after the ~28s ceiling becomes a clear `PipelineExecuteTimeoutError` -pointing at start+poll, while a fast 503 stays the inherited `httpx.HTTPStatusError` (runner down, -not a timeout) and the 202 async-degrade stays the inherited `RunStillRunningError`. +pointing at start+poll, while a fast 503 stays the `ApiResponseError` every non-2xx raises (runner down, +not a timeout) and the 202 async-degrade stays the inherited `RunStillRunningError`. The translation reads +the typed error the inherited route raises through the client's `_raise_api_response_error` override, so +each case here proves it still fires on that error rather than on httpx's. """ import asyncio @@ -13,7 +15,7 @@ from pytest_mock import MockerFixture from pipelex_sdk.client import PipelexAPIClient -from pipelex_sdk.errors import MissingMainStuffError, PipelineExecuteTimeoutError, RunStillRunningError +from pipelex_sdk.errors import ApiResponseError, MissingMainStuffError, PipelineExecuteTimeoutError, RunStillRunningError _BASE_URL = "http://localhost:8081" @@ -56,14 +58,20 @@ def test_gateway_503_past_ceiling_translates_to_timeout(self, mocker: MockerFixt assert error.elapsed_seconds == 31.0 assert "30s" in str(error) assert "wait_for_result" in str(error) + # The gateway's answer is kept as the cause, typed. + assert isinstance(error.__cause__, ApiResponseError) + assert error.__cause__.status == 503 + assert error.__cause__.request_url == f"{_BASE_URL}/v1/execute" def test_gateway_504_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: client = self._client() mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(504))) mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 29.0]) - with pytest.raises(PipelineExecuteTimeoutError): + with pytest.raises(PipelineExecuteTimeoutError) as exc_info: asyncio.run(client.execute(pipe_code="p")) + assert isinstance(exc_info.value.__cause__, ApiResponseError) + assert exc_info.value.__cause__.status == 504 def test_client_timeout_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: client = self._client() @@ -73,15 +81,16 @@ def test_client_timeout_past_ceiling_translates_to_timeout(self, mocker: MockerF with pytest.raises(PipelineExecuteTimeoutError): asyncio.run(client.execute(pipe_code="p")) - def test_fast_503_stays_inherited_http_status_error(self, mocker: MockerFixture) -> None: + def test_fast_503_stays_an_api_response_error(self, mocker: MockerFixture) -> None: client = self._client() mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(503))) # Failure at 2s — under the ceiling: a genuinely-down runner, not a gateway timeout. mocker.patch("pipelex_sdk.client.monotonic", side_effect=[0.0, 2.0]) - with pytest.raises(httpx.HTTPStatusError) as exc_info: + with pytest.raises(ApiResponseError) as exc_info: asyncio.run(client.execute(pipe_code="p")) - assert not isinstance(exc_info.value, PipelineExecuteTimeoutError) + assert exc_info.value.status == 503 + assert str(exc_info.value) == "API POST /v1/execute failed (503): Service Unavailable" def test_success_resolves_main_stuff(self, mocker: MockerFixture) -> None: client = self._client() diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py index b8fcc92..4b4fed9 100644 --- a/tests/unit/test_client_lifecycle.py +++ b/tests/unit/test_client_lifecycle.py @@ -10,6 +10,7 @@ from pipelex_sdk.client import PipelexAPIClient from pipelex_sdk.errors import ( + ApiResponseError, MissingMainStuffError, RunFailedError, RunLifecycleUnavailableError, @@ -147,15 +148,45 @@ def test_start_bare_runner_missing_route_404_is_lifecycle_unavailable(self, mock with pytest.raises(RunLifecycleUnavailableError) as exc_info: asyncio.run(client.start(pipe_code="answer")) assert exc_info.value.api_url == _BASE_URL + assert f"{_BASE_URL}/v1/start returned 404" in str(exc_info.value) + # Translated from the typed error the inherited route raised, which stays reachable. + assert isinstance(exc_info.value.__context__, ApiResponseError) + assert exc_info.value.__context__.status == 404 - def test_start_structured_404_stays_http_status_error(self, mocker: MockerFixture) -> None: - """A structured platform 404 (carries `code`) is a normal HTTP error, not lifecycle-unavailable.""" + def test_start_runner_refusal_404_stays_api_response_error(self, mocker: MockerFixture) -> None: + """A runner's 404 relayed by the platform (`error_type`, no `code`) is a refusal, not a missing run store.""" + client = self._client() + # The runner's answer to a `method_ref` with no package behind it, as the platform relays it. + body = { + "type": "https://docs.pipelex.com/latest/errors/method-package-not-found-error/", + "title": "Method package not found", + "status": 404, + "detail": "No method package at github.com/x/y/z.", + "instance": "/v1/start", + "request_id": "req-404", + "error_type": "MethodPackageNotFoundError", + "error_domain": "input", + } + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json=body))) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.start(pipe_code="p", method_ref="github.com/x/y/z")) + assert not isinstance(exc_info.value, RunLifecycleUnavailableError) + assert exc_info.value.error_type == "MethodPackageNotFoundError" + assert str(exc_info.value) == "API POST /v1/start failed (404): No method package at github.com/x/y/z." + + def test_start_structured_404_stays_api_response_error(self, mocker: MockerFixture) -> None: + """A structured platform 404 (carries `code`) is a normal API error, not lifecycle-unavailable.""" client = self._client() body = {"code": "NOT_FOUND", "detail": "The requested resource does not exist."} mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json=body))) - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(ApiResponseError) as exc_info: asyncio.run(client.start(pipe_code="answer")) + assert not isinstance(exc_info.value, RunLifecycleUnavailableError) + assert exc_info.value.status == 404 + assert exc_info.value.code == "NOT_FOUND" + assert str(exc_info.value) == "API POST /v1/start failed (404): The requested resource does not exist." # ── get_run_status ─────────────────────────────────────────── @@ -240,6 +271,27 @@ def test_get_run_status_lifecycle_unavailable_on_missing_route(self, mocker: Moc with pytest.raises(RunLifecycleUnavailableError): asyncio.run(client.get_run_status("run_1")) + def test_get_run_status_run_not_found_is_api_response_error(self, mocker: MockerFixture) -> None: + """The platform's structured run-not-found 404 raises the typed error, naming the read and the problem's code.""" + client = self._client() + body = { + "type": "https://pipelex.com/errors/run_not_found", + "title": "Not found", + "status": 404, + "code": "run_not_found", + "detail": "Run not found.", + } + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(404, json=body))) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.get_run_status("run 1")) + exc = exc_info.value + assert not isinstance(exc, RunLifecycleUnavailableError) + assert exc.code == "run_not_found" + assert exc.type_uri == "https://pipelex.com/errors/run_not_found" + assert exc.request_url == f"{_BASE_URL}/v1/runs/run%201/status" + assert str(exc) == "API GET /v1/runs/run%201/status failed (404): Run not found." + # ── get_run_result status mapping ──────────────────────────── def test_get_run_result_completed_keeps_polymorphic_main_stuff(self, mocker: MockerFixture) -> None: @@ -451,6 +503,17 @@ def test_get_run_result_lifecycle_unavailable_on_missing_route(self, mocker: Moc with pytest.raises(RunLifecycleUnavailableError): asyncio.run(client.get_run_result("run_1")) + def test_get_run_result_server_fault_is_api_response_error(self, mocker: MockerFixture) -> None: + """A non-2xx the poll does not map to a state (here a 500) raises the typed error.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(500, json={"detail": "boom", "request_id": "req-500"}))) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.get_run_result("run_1")) + assert exc_info.value.status == 500 + assert exc_info.value.request_id == "req-500" + assert str(exc_info.value) == "API GET /v1/runs/run_1/results failed (500): boom" + # ── execute 202 degrade → re-exported RunStillRunningError ──── def test_execute_202_raises_re_exported_still_running(self, mocker: MockerFixture) -> None: diff --git a/tests/unit/test_client_refused_run.py b/tests/unit/test_client_refused_run.py new file mode 100644 index 0000000..fcb3bf0 --- /dev/null +++ b/tests/unit/test_client_refused_run.py @@ -0,0 +1,217 @@ +"""Tests for a run the plane refuses: `start`, `execute` and `start_and_wait` raise the typed `ApiResponseError`. + +Each case replays a refusal the dev plane answered, byte for byte (`RefusedRunBodies`), and checks that the +error's message and members carry the reason, the failing pipe and the next step — where the inherited +`httpx.HTTPStatusError` regime said only "Client error '422 Unprocessable Entity'". The SDK's error is +`mthds`'s own `ApiResponseError` narrowed, so a handler written against the standard's client catches it too. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +from typing import TYPE_CHECKING, Any + +import httpx +import pytest +from mthds.runners.api.exceptions import ApiResponseError as MthdsApiResponseError + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError +from pipelex_sdk.validation_models import ValidationErrorCategory, ValidationErrorItem +from tests.unit.test_data import RefusedRunBodies + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from pytest_mock import MockerFixture, MockType + +_BASE_URL = "http://localhost:8081" + +_HOSTED_VERSION = {"protocol_version": "0.6.0", "implementation": "pipelex-hosted", "runner_version": "0.9.0"} + + +def _start(client: PipelexAPIClient) -> Coroutine[Any, Any, object]: + return client.start(pipe_code="pitch_product", mthds_contents=['domain = "sales_copy"']) + + +def _execute(client: PipelexAPIClient) -> Coroutine[Any, Any, object]: + return client.execute(pipe_code="pitch_product", mthds_contents=['domain = "sales_copy"']) + + +# Each run route, called with arguments that name something to run: (route, call). +_RUN_ROUTE_CALLS = [pytest.param("start", _start, id="start"), pytest.param("execute", _execute, id="execute")] + +_COMBINE_FAILURE_DETAIL: str = json.loads(RefusedRunBodies.COMBINE_FAILURE_AT_RUN)["detail"] + +# Each refusal: (body, the reason, the next step, what the message says after the status). The next +# step rides its own line — unless the reason already ends with it, as the combine failure's does, in +# which case the message says it once. +_REFUSALS = [ + pytest.param( + RefusedRunBodies.UNKNOWN_MODEL_AT_LOAD, + RefusedRunBodies.UNKNOWN_MODEL_DETAIL, + RefusedRunBodies.UNKNOWN_MODEL_NEXT_STEP, + f"{RefusedRunBodies.UNKNOWN_MODEL_DETAIL}\nNext step: {RefusedRunBodies.UNKNOWN_MODEL_NEXT_STEP}", + id="unknown-model-at-load", + ), + pytest.param( + RefusedRunBodies.COMBINE_FAILURE_AT_RUN, + _COMBINE_FAILURE_DETAIL, + RefusedRunBodies.COMBINE_FAILURE_NEXT_STEP, + _COMBINE_FAILURE_DETAIL, + id="combine-failure-at-run", + ), + pytest.param( + RefusedRunBodies.UNSERVED_MODEL_AT_RUN, + RefusedRunBodies.UNSERVED_MODEL_DETAIL, + RefusedRunBodies.UNSERVED_MODEL_NEXT_STEP, + f"{RefusedRunBodies.UNSERVED_MODEL_DETAIL}\nNext step: {RefusedRunBodies.UNSERVED_MODEL_NEXT_STEP}", + id="unserved-model-at-run", + ), +] + + +def _response(status_code: int, *, text: str | None = None, json_body: object | None = None) -> httpx.Response: + """A wire response: the refusal's raw bytes as `text`, or a JSON body.""" + request = httpx.Request("POST", f"{_BASE_URL}/v1/x") + if json_body is not None: + return httpx.Response(status_code, json=json_body, request=request) + return httpx.Response(status_code, text=text or "", headers={"content-type": "application/problem+json"}, request=request) + + +class TestClientRefusedRun: + def _client(self) -> PipelexAPIClient: + return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) + + def _patch_send(self, mocker: MockerFixture, client: PipelexAPIClient, *responses: httpx.Response) -> MockType: + return mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=list(responses))) + + def _raised(self, mocker: MockerFixture, body: str, call: Callable[[PipelexAPIClient], Coroutine[Any, Any, object]]) -> ApiResponseError: + client = self._client() + self._patch_send(mocker, client, _response(422, text=body)) + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(call(client)) + return exc_info.value + + @pytest.mark.parametrize(("route", "call"), _RUN_ROUTE_CALLS) + @pytest.mark.parametrize(("body", "detail", "next_step", "said"), _REFUSALS) + def test_a_refused_run_raises_the_typed_error_with_its_reason_and_next_step( + self, + mocker: MockerFixture, + route: str, + call: Callable[[PipelexAPIClient], Coroutine[Any, Any, object]], + body: str, + detail: str, + next_step: str, + said: str, + ) -> None: + exc = self._raised(mocker, body, call) + + assert isinstance(exc, MthdsApiResponseError) + assert not isinstance(exc, httpx.HTTPStatusError) + assert str(exc) == f"API POST /v1/{route} failed (422): {said}" + assert exc.status == 422 + assert exc.status_text == "Unprocessable Entity" + assert exc.api_url == _BASE_URL + assert exc.request_url == f"{_BASE_URL}/v1/{route}" + assert exc.headers["content-type"] == "application/problem+json" + assert exc.server_message == detail + assert exc.user_action is not None + assert exc.user_action.detail == next_step + assert exc.error_domain == "input" + assert exc.response_body == body + assert exc.problem == json.loads(body) + + def test_a_bundle_refused_at_load_names_the_failing_pipe_in_typed_diagnostics(self, mocker: MockerFixture) -> None: + exc = self._raised(mocker, RefusedRunBodies.UNKNOWN_MODEL_AT_LOAD, _start) + + assert exc.type_uri == "https://docs.pipelex.com/latest/errors/validate-bundle-error/" + assert exc.title == "Validate bundle" + assert exc.instance == "/v1/execute" + assert exc.request_id == "req_a3dd6900-7909-48d3-b551-0140e73ac7fc" + assert exc.error_type == "ValidateBundleError" + assert exc.error_category == "configuration" + assert exc.retryable is False + assert exc.user_action is not None + assert exc.user_action.kind == "change_input" + assert exc.code is None + assert exc.errors is None + assert exc.validation_errors is not None + assert len(exc.validation_errors) == 1 + item = exc.validation_errors[0] + assert isinstance(item, ValidationErrorItem) + assert item.category == ValidationErrorCategory.PIPE_VALIDATION + assert item.message == RefusedRunBodies.UNKNOWN_MODEL_DETAIL + assert item.error_type == "unknown_model" + assert item.pipe_code == "draft_pitch" + assert item.domain_code == "sales_copy" + assert item.field_path == "pipe.draft_pitch.model" + assert item.field_name == "model" + # The runner's locators this SDK does not declare stay on the item's extras. + assert item.model_extra == { + "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"], + } + + def test_a_run_failed_at_a_combine_step_names_the_pipe_and_leaves_absent_members_none(self, mocker: MockerFixture) -> None: + exc = self._raised(mocker, RefusedRunBodies.COMBINE_FAILURE_AT_RUN, _execute) + + assert exc.error_type == "StuffFactoryError" + assert exc.type_uri == "https://docs.pipelex.com/latest/errors/stuff-factory-error/" + assert exc.request_id == "req_d4212542-63a6-4ddb-87c9-4b968785c8c5" + assert exc.server_message is not None + assert exc.server_message.startswith("Pipe 'analyze_topics' failed (review_topics → analyze_topics)") + assert exc.user_action is not None + assert exc.user_action.kind == "change_input" + # The document carries no classification of an inference failure, no retry verdict and no itemized list. + assert exc.error_category is None + assert exc.retryable is None + assert exc.validation_errors is None + + def test_a_run_failed_on_an_unserved_model_advises_changing_the_model(self, mocker: MockerFixture) -> None: + exc = self._raised(mocker, RefusedRunBodies.UNSERVED_MODEL_AT_RUN, _execute) + + assert exc.error_type == "ModelNotFoundError" + assert exc.title == "Model not found" + assert exc.error_category == "configuration" + assert exc.retryable is False + assert exc.user_action is not None + assert exc.user_action.kind == "change_model" + assert exc.validation_errors is None + + def test_start_and_wait_on_a_refused_method_raises_the_typed_error_without_falling_back(self, mocker: MockerFixture) -> None: + client = self._client() + send = self._patch_send( + mocker, client, _response(200, json_body=_HOSTED_VERSION), _response(422, text=RefusedRunBodies.UNKNOWN_MODEL_AT_LOAD) + ) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.start_and_wait(pipe_code="pitch_product", mthds_contents=['domain = "sales_copy"'])) + + exc = exc_info.value + assert str(exc) == ( + f"API POST /v1/start failed (422): {RefusedRunBodies.UNKNOWN_MODEL_DETAIL}\nNext step: {RefusedRunBodies.UNKNOWN_MODEL_NEXT_STEP}" + ) + assert exc.validation_errors is not None + assert exc.validation_errors[0].pipe_code == "draft_pitch" + # A refusal is no missing run store: the durable path stops here, with no blocking retry of the same method. + assert [call.args[1] for call in send.call_args_list] == [f"{_BASE_URL}/v1/version", f"{_BASE_URL}/v1/start"] + + @pytest.mark.parametrize("duplicate", [copy.copy, copy.deepcopy], ids=["copy", "deepcopy"]) + def test_the_error_survives_copying_with_its_pipelex_members( + self, mocker: MockerFixture, duplicate: Callable[[ApiResponseError], ApiResponseError] + ) -> None: + """Copying goes through the base's `__reduce__`, as pickling across a process boundary does.""" + exc = self._raised(mocker, RefusedRunBodies.UNKNOWN_MODEL_AT_LOAD, _start) + + clone = duplicate(exc) + + assert type(clone) is ApiResponseError + assert str(clone) == str(exc) + assert clone.error_category == "configuration" + assert clone.validation_errors == exc.validation_errors + assert clone.user_action == exc.user_action + assert clone.request_url == exc.request_url diff --git a/tests/unit/test_client_run_fallback.py b/tests/unit/test_client_run_fallback.py index 0689e9f..aec7c4c 100644 --- a/tests/unit/test_client_run_fallback.py +++ b/tests/unit/test_client_run_fallback.py @@ -13,7 +13,7 @@ from pytest_mock import MockerFixture from pipelex_sdk.client import PipelexAPIClient -from pipelex_sdk.errors import ApiUnreachableError, MissingMainStuffError, RunLifecycleUnavailableError +from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError, MissingMainStuffError, RunLifecycleUnavailableError from pipelex_sdk.runs import TokensUsageRecord _BASE_URL = "http://localhost:8081" @@ -454,14 +454,35 @@ def test_self_heals_when_base_only_version_hides_missing_run_store(self, mocker: f"{_BASE_URL}/v1/execute", ] + @pytest.mark.parametrize( + "body", + [ + {"type": "about:blank", "title": "Not Found", "status": 404, "error_type": "MethodPackageNotFoundError", "detail": "No package."}, + {"type": "https://pipelex.com/errors/not_found", "title": "Not found", "status": 404, "code": "not_found", "detail": "No method."}, + ], + ) + def test_a_404_answered_on_purpose_neither_falls_back_nor_demotes_the_client(self, mocker: MockerFixture, body: dict[str, Any]) -> None: + """A runner's or the platform's own 404 on start is a refusal: raised as is, with the lifecycle still believed served.""" + client = self._client() + send = mocker.patch.object(client, "_send", mocker.AsyncMock(side_effect=[_response(200, json=_HOSTED_VERSION), _response(404, json=body)])) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.start_and_wait(pipe_code="p")) + assert not isinstance(exc_info.value, RunLifecycleUnavailableError) + assert exc_info.value.status == 404 + assert _urls(send) == [f"{_BASE_URL}/v1/version", f"{_BASE_URL}/v1/start"] + assert client._lifecycle_available is True + def test_handshake_failure_assumes_hosted(self, mocker: MockerFixture) -> None: """When the /v1/version handshake itself fails, assume hosted and let start surface the real error.""" client = self._client() mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(500, json={"detail": "boom"}))) - # version 500 → assume hosted → start hits the same 500 → raise_for_status → HTTPStatusError. - with pytest.raises(httpx.HTTPStatusError): + # version 500 → assume hosted → start hits the same 500 → ApiResponseError, naming the start. + with pytest.raises(ApiResponseError) as exc_info: asyncio.run(client.start_and_wait(pipe_code="p")) + assert str(exc_info.value) == "API POST /v1/start failed (500): boom" + assert client._lifecycle_available is True def test_lifecycle_primitives_raise_unavailable_on_bare_404(self, mocker: MockerFixture) -> None: """The poll primitives surface a clear RunLifecycleUnavailableError on the bare-runner 404.""" diff --git a/tests/unit/test_client_validate.py b/tests/unit/test_client_validate.py index 0057ab4..7e9bbbf 100644 --- a/tests/unit/test_client_validate.py +++ b/tests/unit/test_client_validate.py @@ -10,6 +10,7 @@ from pytest_mock import MockerFixture, MockType from pipelex_sdk.client import MthdsFile, PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError from pipelex_sdk.validation_models import VALIDATION_VIEW_INPUT_FORM, PipelexInvalidReport, PipelexValidationReport _BASE_URL = "http://localhost:8081" @@ -202,6 +203,37 @@ def test_method_id_selector_is_a_pure_pass_through(self, mocker: MockerFixture) # A produced invalid verdict still parses into the union's invalid arm. assert isinstance(result, PipelexInvalidReport) + @pytest.mark.parametrize( + ("kwargs", "status", "problem"), + [ + ( + {"method_id": "mt_missing"}, + 404, + { + "type": "https://pipelex.com/errors/not_found", + "title": "Not found", + "status": 404, + "code": "not_found", + "detail": "Method not found.", + }, + ), + ({"mthds_contents": ["bundle"]}, 422, {"title": "Unprocessable entity", "status": 422, "detail": "mthds_sources length mismatch."}), + ], + ) + def test_a_no_verdict_answer_raises_api_response_error( + self, mocker: MockerFixture, kwargs: dict[str, object], status: int, problem: dict[str, object] + ) -> None: + """Both wire paths — the selector one built here and the inline one on the inherited seam — raise the typed error.""" + client = self._client() + response = httpx.Response(status, json=problem, request=httpx.Request("POST", f"{_BASE_URL}/v1/validate")) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=response)) + + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.validate(**kwargs)) # type: ignore[arg-type] + assert exc_info.value.status == status + assert exc_info.value.request_url == f"{_BASE_URL}/v1/validate" + assert str(exc_info.value) == f"API POST /v1/validate failed ({status}): {problem['detail']}" + @pytest.mark.parametrize( "kwargs", [ diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py new file mode 100644 index 0000000..81fb6bc --- /dev/null +++ b/tests/unit/test_data.py @@ -0,0 +1,66 @@ +"""Test data constants for the unit suite, grouped by what they stand for.""" + +from typing import ClassVar + + +class RefusedRunBodies: + """Problem documents the dev plane answered when it refused to run a method, as they came off the wire. + + Copied byte for byte from `mthds-python`'s `tests/unit/test_data.py` (`RefusedRunBodies`), where + they were captured on 2026-09-27 through `MthdsAPIClient` against `https://api-dev.pipelex.com` + (pipelex-api v0.29.0 on pipelex 0.67.0): a bundle the runner refused at load with an itemized + diagnostic, a run that failed at a pipe's combine step, and a run that failed on a model the deck + does not serve. Keeping the same bytes in both clients' suites is what shows the two read a refusal + alike. + """ + + UNKNOWN_MODEL_AT_LOAD: ClassVar[str] = ( + '{"type":"https://docs.pipelex.com/latest/errors/validate-bundle-error/","title":"Validate bundle","status":422,' + "\"detail\":\"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","instance":"/v1/execute",' + '"request_id":"req_a3dd6900-7909-48d3-b551-0140e73ac7fc","error_category":"configuration","error_domain":"input",' + '"retryable":false,"error_type":"ValidateBundleError","validation_errors":[{"category":"pipe_validation",' + "\"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","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":"Edit the bundle as each validation error says: apply its suggested fix ' + 'where it has one, after confirming an unsafe one"}}' + ) + UNKNOWN_MODEL_DETAIL: ClassVar[str] = ( + "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" + ) + UNKNOWN_MODEL_NEXT_STEP: ClassVar[str] = ( + "Edit the bundle as each validation error says: apply its suggested fix where it has one, after confirming an unsafe one" + ) + + COMBINE_FAILURE_AT_RUN: ClassVar[str] = ( + '{"type":"https://docs.pipelex.com/latest/errors/stuff-factory-error/","title":"Stuff factory","status":422,' + "\"detail\":\"Pipe 'analyze_topics' failed (review_topics → analyze_topics): PipeParallel 'analyze_topics' cannot " + "combine its branch results into its output 'TopicReview'. Branch 'draft_ideas' gives result 'ideas' as a list, " + "'Idea[]', but field 'ideas' of 'TopicReview' holds a single item. Declare the field as a list in the structure of " + "'TopicReview', with type 'list', item_type 'concept' and item_concept_ref 'Idea', or make branch 'draft_ideas' output " + 'a single \'Idea\'.","instance":"/v1/execute","request_id":"req_d4212542-63a6-4ddb-87c9-4b968785c8c5",' + '"error_domain":"input","error_type":"StuffFactoryError","user_action":{"kind":"change_input","detail":"Branch ' + "'draft_ideas' gives result 'ideas' as a list, 'Idea[]', but field 'ideas' of 'TopicReview' holds a single item. " + "Declare the field as a list in the structure of 'TopicReview', with type 'list', item_type 'concept' and " + "item_concept_ref 'Idea', or make branch 'draft_ideas' output a single 'Idea'.\"}}" + ) + COMBINE_FAILURE_NEXT_STEP: ClassVar[str] = ( + "Branch 'draft_ideas' gives result 'ideas' as a list, 'Idea[]', but field 'ideas' of 'TopicReview' holds a single item. " + "Declare the field as a list in the structure of 'TopicReview', with type 'list', item_type 'concept' and " + "item_concept_ref 'Idea', or make branch 'draft_ideas' output a single 'Idea'." + ) + + UNSERVED_MODEL_AT_RUN: ClassVar[str] = ( + '{"type":"https://docs.pipelex.com/latest/errors/model-not-found-error/","title":"Model not found","status":422,' + "\"detail\":\"Pipe 'condense_article' failed (digest_article → condense_article): Model handle 'gpt-5.1' was not " + 'found in the model deck.","instance":"/v1/execute","request_id":"req_b7d2c66f-b2d5-4dc7-bd4f-6cf98abfbdc0",' + '"error_category":"configuration","error_domain":"input","retryable":false,"error_type":"ModelNotFoundError",' + '"user_action":{"kind":"change_model","detail":"Change the model \'gpt-5.1\' to an LLM the model deck serves."}}' + ) + UNSERVED_MODEL_DETAIL: ClassVar[str] = ( + "Pipe 'condense_article' failed (digest_article → condense_article): Model handle 'gpt-5.1' was not found in the model deck." + ) + UNSERVED_MODEL_NEXT_STEP: ClassVar[str] = "Change the model 'gpt-5.1' to an LLM the model deck serves." diff --git a/tests/unit/test_error_parsing.py b/tests/unit/test_error_parsing.py deleted file mode 100644 index e3acf9d..0000000 --- a/tests/unit/test_error_parsing.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tests for `_parse_error_body` — the problem+json / HTTPException error-body parser.""" - -import pytest - -from pipelex_sdk.client import _parse_error_body - - -class TestParseErrorBody: - def test_detail_dict_extracts_error_type_and_message(self) -> None: - parsed = _parse_error_body('{"detail": {"error_type": "ValidationError", "message": "bad bundle"}}') - assert parsed.error_type == "ValidationError" - assert parsed.server_message == "bad bundle" - assert parsed.code is None - assert parsed.validation_errors is None - - def test_detail_string_is_server_message(self) -> None: - parsed = _parse_error_body('{"detail": "Not authenticated"}') - assert parsed.server_message == "Not authenticated" - assert parsed.error_type is None - - def test_top_level_error_type_and_message_fallback(self) -> None: - parsed = _parse_error_body('{"error_type": "Boom", "message": "top level"}') - assert parsed.error_type == "Boom" - assert parsed.server_message == "top level" - - def test_extracts_rfc9457_code(self) -> None: - parsed = _parse_error_body('{"code": "conflict", "detail": "already exists"}') - assert parsed.code == "conflict" - assert parsed.server_message == "already exists" - - def test_malformed_validation_errors_falls_back_to_none(self) -> None: - parsed = _parse_error_body('{"validation_errors": [{"unexpected": 1}]}') - assert parsed.validation_errors is None - - def test_absent_validation_errors_is_none(self) -> None: - parsed = _parse_error_body('{"detail": "x"}') - assert parsed.validation_errors is None - - @pytest.mark.parametrize("body", ["", "not json", "[]", "5", "null"]) - def test_non_object_bodies_are_empty(self, body: str) -> None: - parsed = _parse_error_body(body) - assert parsed.error_type is None - assert parsed.server_message is None - assert parsed.code is None - assert parsed.validation_errors is None - assert parsed.problem is None - assert parsed.request_id is None diff --git a/uv.lock b/uv.lock index 08be6e5..5fad4ea 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -221,9 +221,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]] @@ -326,7 +326,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", specifier = "==0.16.0" }, + { name = "mthds", specifier = "==0.17.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" }, diff --git a/wip/pr-50-review-notes.md b/wip/pr-50-review-notes.md new file mode 100644 index 0000000..e5b234b --- /dev/null +++ b/wip/pr-50-review-notes.md @@ -0,0 +1,18 @@ +--- +status: active +item: L-260927-424b11 +--- + +# PR #50 — deferred review findings + +These are findings from the `/rev` passes on [PR #50](https://github.com/Pipelex/pipelex-sdk-python/pull/50) (`feature/Refused-start-raises-bare`). Round 1 reviewed `88805ea` and round 2 reviewed `9d397e3`. The findings that were acted on are in `CHANGELOG.md` under `[Unreleased]`. The findings that belong to another repo are ledger items: the copied message-reason helpers are L-260927-9d2df3 (mthds-python), and the JS twin's missing-route 404 is L-260927-578c32 (pipelex-sdk-js). This note keeps the one finding this repo owns and did not act on. + +--- + +## 1. A `200` from `/v1/version` whose body is not JSON escapes the handshake + +**Status:** Unverified (raised by cubic in round 2 and not put to a verifier). It predates this branch and is deferred at the round-2 bar as a defect that does not matter. + +`_supports_run_lifecycle` catches `(ApiResponseError, httpx.HTTPError, ValidationError)` around `self.version()`, and its comment says a body that is no version makes the client assume hosted. The inherited `version()` calls `response.json()` before it validates, though. A `200` whose body is not JSON at all, such as an HTML page from a proxy or a mistyped base URL, raises `json.JSONDecodeError`, which none of the three catches, so `start_and_wait` raises that instead of assuming hosted. `pydantic.ValidationError` and `json.JSONDecodeError` both subclass `ValueError`, so catching `ValueError` in place of `ValidationError` would make the comment true. + +It does not matter much in practice. Assuming hosted leads straight to `start`, which reads the same non-JSON `200` through `response.json()` and fails the same way, so the caller would see the same decode error one request later. The change is worth making if the handshake is ever touched again. From 08c1f5b0e324046c9d0bbeacccb2989ccddb6f5a Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 15:37:08 +0200 Subject: [PATCH 2/2] Release v0.14.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380eb9e..b74b789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.14.0] - 2026-09-27 ### Changed diff --git a/pyproject.toml b/pyproject.toml index a5f7025..18ba80b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pipelex-sdk" -version = "0.13.0" +version = "0.14.0" description = "The Python client for the Pipelex hosted API — the MTHDS Protocol surface plus the durable run lifecycle and the Pipelex product surface, built on the `mthds` protocol base." authors = [{ name = "Evotis S.A.S.", email = "oss@pipelex.com" }] maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }] diff --git a/uv.lock b/uv.lock index 5fad4ea..70f3f09 100644 --- a/uv.lock +++ b/uv.lock @@ -303,7 +303,7 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.13.0" +version = "0.14.0" source = { editable = "." } dependencies = [ { name = "httpx" },