From 7aa8f5d2d686551f0022f9b4f63a1470d4d5bf52 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 18:07:36 +0200 Subject: [PATCH 1/4] feature: carry a failed run's stored report and the problem document's members A failed run's reason now reaches a caller of pipelex-sdk. RunErrorReport, moved to pipelex_sdk.error_models, types every field of the runner's ErrorReport (all optional, open to what the runner adds) and is the one type for RunPublic.error, PipelineRun.error, RunResultFailed.error and the new RunFailedError.error, so wait_for_result, start_and_wait and download_artifacts raise the report the results read's 409 carries. The status is read from the problem's run_status member; the regular expression over the detail sentence is gone. ApiResponseError now carries the problem document's members: request_id (body, or the X-Request-ID header), type_uri, title, error_domain, error_category, retryable, user_action, the platform's errors[] and the decoded document whole as problem. The README points consumers at error_domain and type_uri as the branch fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FigDssaJrvNcmbnBedi7oq --- CHANGELOG.md | 12 ++ README.md | 33 +++- docs/architecture.md | 17 +- docs/artifact-download.md | 2 +- docs/run-results.md | 7 +- pipelex_sdk/artifacts.py | 2 +- pipelex_sdk/client.py | 214 +++++++++++++++++--------- pipelex_sdk/error_models.py | 148 ++++++++++++++++++ pipelex_sdk/errors.py | 86 +++++++++-- pipelex_sdk/product_models.py | 13 +- pipelex_sdk/runs.py | 16 +- tests/unit/test_api_response_error.py | 136 ++++++++++++++++ tests/unit/test_artifacts.py | 6 +- tests/unit/test_client_lifecycle.py | 199 +++++++++++++++++++++++- tests/unit/test_error_models.py | 64 ++++++++ tests/unit/test_error_parsing.py | 2 + 16 files changed, 837 insertions(+), 120 deletions(-) create mode 100644 pipelex_sdk/error_models.py create mode 100644 tests/unit/test_api_response_error.py create mode 100644 tests/unit/test_error_models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e9eba..2c9744b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Added + +- **A failed run's error report on `RunFailedError`, `RunResultFailed` and `RunRead`**: `RunFailedError.error`, `RunResultFailed.error` and `RunRead.error` (declared on `RunPublic`) carry the run's stored error report typed as `RunErrorReport`, so `wait_for_result`, `start_and_wait` and `download_artifacts` now raise with the reason the runner recorded, not only the status. The message of the error is the platform's `detail`, which names the status and then the report's message. `None` means the run ended with no report, such as a cancelled run. See `docs/run-results.md`. +- **`ApiResponseError` carries the problem document's members**: `request_id` (read from the body, or from the `X-Request-ID` header when the body has none), `type_uri` (the problem's `type`), `title`, `error_domain`, `error_category`, `retryable`, `user_action`, `errors` (the platform's field-level list, typed as `FieldError`) and `problem`, the decoded document whole, for any member the SDK does not name. Branch on `error_domain` and `type_uri`, as the README now says; `code` and `error_type` remain each surface's finer native code. + +### Changed + +- **`RunErrorReport` carries every field of the runner's report and moves to `pipelex_sdk.error_models` (Breaking)**: import it from `pipelex_sdk.error_models` instead of `pipelex_sdk.product_models`. Beside `message` and `error_type` it now declares `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `caller_facing_message`, `validation_errors` and `migration`, every one optional and the model open to fields the runner adds, so `PipelineRun.error` in the run lists reads the whole report too. +- **A failed run's status comes from the results read's `run_status` member (Breaking)**: `get_run_result` no longer parses the status out of the `409`'s `detail` sentence; it reads the problem document's `run_status` member, and a `409` without a status this SDK knows reads as `FAILED`. + ## [v0.12.0] - 2026-09-24 ### Added diff --git a/README.md b/README.md index 705ba83..65bace7 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,30 @@ ack = await client.start(pipe_code="long_pipe", inputs={...}) result = await client.wait_for_result(ack.pipeline_run_id) ``` -### Product routes: branch on `err.code`, not the HTTP status +### When a run fails: `RunFailedError` carries the run's report -The hosted product routes raise a typed `ApiResponseError` carrying the RFC 9457 `code` discriminant. Branch on `err.code`, which is decoupled from the transport status: +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. + +```python +from pipelex_sdk.errors import RunFailedError + +try: + result = await client.wait_for_result(run_id) +except RunFailedError as exc: + report = exc.error + if report is None: + print(f"Run {exc.run_id} ended {exc.status} without a report.") + else: + print(f"{report.title}: {report.user_action.detail if report.user_action else report.message}") + if report.retryable: + ... # the same run may succeed if started again +``` + +Branch on `error_domain` (`input`, `config`, `runtime`), `type_uri` and `retryable`, never on the wording of `message`. The report is the runner's verbose one, so `message` and `provider_metadata` can hold a model provider's raw text: what a person should see of it is your application's decision. The same report is on `RunRead.error` when you read the run's status, on `RunResultFailed.error` from `get_run_result`, and on `PipelineRun.error` in the run lists. + +### API errors: branch on `error_domain` and `type_uri`, not the HTTP status + +A non-2xx answer raises a typed `ApiResponseError` carrying the members of the RFC 9457 problem document. Two of them are the branch fields, the same on every surface of the hosted API: `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed), and `type_uri`, the problem's `type`, a stable URI naming the error class and present on every problem. `error_domain` is `None` on a problem that carries none, so branch on `type_uri` for one specific condition and on `error_domain` for the class: ```python from pipelex_sdk.errors import ApiResponseError @@ -128,18 +149,24 @@ try: created = await client.create_pipelex_api_key(label="ci") print(created.api_key) # plaintext — returned only once except ApiResponseError as exc: - if exc.code == "pipelex_api_key_limit_reached": + if exc.type_uri == "https://pipelex.com/errors/pipelex_api_key_limit_reached": print("Per-account key limit reached — revoke an old key first.") + elif exc.error_domain == "input": + print(f"Fix the request: {exc.server_message}") else: + print(f"Unexpected failure, request id {exc.request_id}") raise ``` +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. + ## Public import paths (no barrel) There is no barrel import — package `__init__.py` files stay empty. Import each symbol from its module: - **Client & construction** — `from pipelex_sdk.client import PipelexAPIClient, DEFAULT_API_BASE_URL, MthdsFile` - **Run lifecycle types** — `from pipelex_sdk.runs import RunStatus, RunPublic, RunRead, RunResults, RunResultState, WaitForResultOptions, PollInfo` +- **Error reports** — `from pipelex_sdk.error_models import RunErrorReport, UserAction, ProviderErrorMetadata, MigrationErrorBlock, FieldError` - **Product wire models** — `from pipelex_sdk.product_models import UserProfile, MethodData, MethodWriteInput, Membership, MembershipsResponse, SubscriptionResponse, PlanView, InvoiceView, OnboardingSubmission, UploadInput, UploadedFile, PipelineRun, ...`, with the catalog-source readers beside them: `method_source_to_contents` turns a fetched `MethodData.mthds` into the `mthds_contents` a run or a validate takes, and `MethodFile` / `parse_method_files` / `serialize_method_files` are the codec for a method's custom PipeFunc `python`. - **Validation verdict types** — `from pipelex_sdk.validation_models import PipelexValidationResult, PipelexValidationReport, PipelexInvalidReport, ValidationErrorItem, SuggestedFix, VALIDATION_VIEW_INPUT_FORM, ...` - **Codegen tree** — `from pipelex_sdk.codegen_writer import write_codegen_tree, CodegenTreeWriteReport` to write one, `from pipelex_sdk.codegen_check import run_codegen_check, CodegenCheckReport, CodegenDrift, DriftCategory` to verify one, with the format primitives in `pipelex_sdk.codegen_lock` (`CodegenLock`, `parse_lock`, `load_lock`, `validate_artifact_path`, ...) and `pipelex_sdk.codegen_stamp` (`STAMPABLE_SUFFIXES`, `is_stampable_artifact_path`, `compute_content_hash`, `parse_stamped`, ...) diff --git a/docs/architecture.md b/docs/architecture.md index 6157bb6..285a526 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,13 +62,13 @@ 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 `(error_type, server_message, validation_errors, code)`, handling both `{"detail": {...}}` and `{"detail": "..."}` shapes plus top-level `error_type` / `message` / `code`, and falling through to empty on a non-JSON or non-object body. `validation_errors` is parsed leniently (best-effort error-path enrichment; only reachable via the out-of-scope build-route 422s). +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. ## Error regimes Three regimes, ported faithfully from the TS SDK (the inherited-protocol vs product split is decision #5 — deliberately not unified): -- **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the RFC 9457 `code` discriminant — consumers branch on `err.code` (e.g. `"conflict"`, `"pipelex_api_key_limit_reached"`), never on the HTTP status. It also carries `status`, `status_text`, `response_body`, `error_type`, `server_message`, and `validation_errors`. +- **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the members of the RFC 9457 problem document. Consumers branch on `err.error_domain` (`input` / `config` / `runtime`) and `err.type_uri` (the problem's `type`, the stable URI naming the error class), 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. `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`. - **`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. @@ -109,7 +109,8 @@ The durable run lifecycle (`pipelex_sdk/runs.py` + the client's lifecycle method `pipelex_sdk/runs.py` **owns** the lifecycle models — they are a Pipelex-branded surface, mirroring `pipelex-sdk-js/src/runs.ts`. They are not imported from `mthds`. During the transition the same shapes still exist in `mthds-python`; that duplication is deliberate (so this SDK is correct regardless of `mthds-python`'s state) and is removed from `mthds-python` later. While the base `MthdsAPIClient` still declares the same lifecycle methods, the client's overrides return this package's own types and so read as incompatible overrides to the type-checker — they carry a narrow `# type: ignore[override]`, which becomes unnecessary (harmless) once the base copies are stripped. - `RunStatus` — the hosted status enum, with `is_terminal` / `is_success` predicates (exhaustive `match`). -- `RunRead` — a run record read through the self-healing status path (adds `degraded` + `retry_after_seconds`). +- `RunRead` — a run record read through the self-healing status path (adds `degraded` + `retry_after_seconds`). Its `error`, declared on `RunPublic`, is the run's stored error report typed as `RunErrorReport`. +- `RunErrorReport` (`pipelex_sdk/error_models.py`, with its nested `UserAction`, `ProviderErrorMetadata` and `MigrationErrorBlock`) — why a run failed: the runner's `ErrorReport` with every field it carries, as the platform stores it and serves it on the status read, the run records and the results read's `409`. It is not a shape this SDK owns: every field is optional and every model extension-open, so a field the runner adds rides `model_extra`, and its enum-ish fields stay plain strings. Nothing is stripped: the report is the runner's VERBOSE one, provider text included, and what a person sees of it is each consumer's presentation. One type everywhere — `RunPublic.error`, `PipelineRun.error`, `RunResultFailed.error` and `RunFailedError.error`. The module also holds `FieldError`, the platform problem document's `errors[]` item. - `RunResults` — result artifacts, every field walked on [`run-results.md`](./run-results.md). `main_stuff` (the resolved main output content) is always present for a completed run: on the hosted path it is the `main_stuff.json` S3 artifact; on the bare-runner blocking path the SDK resolves it from the returned working memory via the response's `main_stuff_name`, so both paths deliver the same shape. Consumers read `main_stuff` directly. The executed graph (`graph_spec`, with `graph_assembly_error` beside it), the three I/O artifacts (`pipe_io_contracts`, `input_form`, `output_form`, typed by import from `mthds.protocol` exactly as on the validate report, with `pipe_io_artifacts_error` beside them) and the usage pair read the same on both paths: the hosted path relays each as an artifact, the blocking path lifts each off `pipe_output` — unwrapping the runner's `pipe_io_artifacts` envelope onto the three sibling fields. `working_memory` reads the same way and is typed by import too, as `mthds`'s `DictWorkingMemoryAbstract` beside the `DictPipeOutputAbstract` that types `pipe_output`: the hosted path relays the `working_memory.json` artifact as its own key, the blocking path lifts `pipe_output.working_memory`, which the standard declares required and which is therefore always set there. A completed run that cannot deliver a main stuff raises `MissingMainStuffError`. Extension-open, so any other server artifact is preserved; and a key the hosted body did not carry is absent from `model_fields_set`, which is how a Python reader tells "not relayed" from "relayed as null" where the JS twin reads `undefined`. - `TokensUsageRecord` — one client-facing usage record per inference call, carried by `RunResults.tokens_usages` on both paths. A mirror of the runtime's own record, not a shape this SDK owns — inference accounting is a Pipelex runtime extension the MTHDS Protocol does not model, so the hosted API is what pins that wire contract: every field is optional and the model is extension-open so pre-contract artifacts (relayed verbatim, never migrated) still parse. See [`run-usage.md`](./run-usage.md) for the field reference, the cost/null semantics, and the old-artifact rules. - `RunResultState` — the single-shot result outcome, a union discriminated on `state` (`running` / `completed` / `failed`). @@ -119,8 +120,8 @@ The durable run lifecycle (`pipelex_sdk/runs.py` + the client's lifecycle method ### Polling surface - **`get_run_status(run_id)`** — `GET /v1/runs/{id}/status` → `RunRead`. Lifts the `Retry-After` header onto `retry_after_seconds`. -- **`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` (terminal status parsed from the message). -- **`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 and `RunTimeoutError` if the budget elapses (the run keeps executing server-side — resume later by id). +- **`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 typed best-effort, because a report whose known fields do not fit their types must not mask the failure: it then reads as `None`, and `message` still carries the reason. +- **`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`. @@ -135,7 +136,7 @@ These poll GETs go through `_send_or_unreachable`, so a transport failure surfac ### Run/lifecycle errors -`RunFailedError`, `RunTimeoutError`, and `RunLifecycleUnavailableError` are owned in `pipelex_sdk/errors.py`. `RunStillRunningError` — the protocol `execute()` 202-degrade error — stays owned by `mthds` and is re-exported from `pipelex_sdk/errors.py` so consumers have a single import home for all run/lifecycle errors. +`RunFailedError`, `RunTimeoutError`, and `RunLifecycleUnavailableError` are owned in `pipelex_sdk/errors.py`. `RunFailedError` carries the run's `status`, its `run_id` and, as `error`, the stored `RunErrorReport` the results read handed over (or `None` for a run that ended with none); its message is the problem's `detail`, so printing it already tells the reason. `wait_for_result`, `start_and_wait` and `download_artifacts` raise it from the same failed arm. `RunStillRunningError` — the protocol `execute()` 202-degrade error — stays owned by `mthds` and is re-exported from `pipelex_sdk/errors.py` so consumers have a single import home for all run/lifecycle errors. ## `validate` override (Pipelex-API presentation + sources + selectors) @@ -221,7 +222,7 @@ Beyond those two the verdicts match, including the drift sentences. One differen ## Pipelex product surface (hosted management routes) -The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.code`, never the HTTP status**. +The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.error_domain` and `.type_uri`, never the HTTP status**, and read `.code` for the platform's finer native code (see Error regimes above). The wire models are snake_case Pydantic v2. Response models are extension-open (`extra="allow"`) so a newly-added server field is preserved, not rejected; input models name exactly what each route accepts. `PipelineRun.status` reuses the run-lifecycle `RunStatus`; `OrgRole`, `PipeStatus`, and the onboarding fields are `StrEnum`s. @@ -252,7 +253,7 @@ The wire models are snake_case Pydantic v2. Response models are extension-open ( **The two iterators stop on different signals, and the difference is in the server.** `iterate_methods` continues through an empty page with a live cursor; `iterate_runs` treats an **empty page as the end**, because the run date bounds are index key conditions and so a run page is never empty-with-a-cursor. Both share the same runaway page ceiling, and both raise `PagingNotTerminatingError` at it: the empty-page stop only catches a server minting fresh cursors while returning *nothing*, so a cursor that cycles across two or more values (`c1 → c2 → c1`) over non-empty pages trips neither that check nor the adjacent-cursor one. The ceiling is the cheap guard against that whole family — tracking every cursor seen would cost unbounded memory for the same protection. - **`PipelineRun` fields the platform genuinely serves as null are typed nullable.** `method_id` is `None` for an ad-hoc run from an inline bundle, which belongs to no stored method; `pipe_code` is `None` for a run that let the bundle's `main_pipe` decide. `org_id`, `created_by_user_id`, and a narrowed `error: RunErrorReport | None` (`message`, `error_type` — the two fields a consumer may rely on out of the runner's verbose report) join them. `RunDetail`, returned only by `get_run_detail`, adds `mthds_contents` and `inputs`: what the run actually executed, and the only record of it, since a method edited since the run no longer describes what happened. Both are left out of the list and the polled status on purpose — their cost scales with page size and poll rate respectively. + **`PipelineRun` fields the platform genuinely serves as null are typed nullable.** `method_id` is `None` for an ad-hoc run from an inline bundle, which belongs to no stored method; `pipe_code` is `None` for a run that let the bundle's `main_pipe` decide. `org_id`, `created_by_user_id`, and `error: RunErrorReport | None` — the run's stored report, typed whole exactly as on the status read — join them. `RunDetail`, returned only by `get_run_detail`, adds `mthds_contents` and `inputs`: what the run actually executed, and the only record of it, since a method edited since the run no longer describes what happened. Both are left out of the list and the polled status on purpose — their cost scales with page size and poll rate respectively. ## Artifact stack (`pipelex_sdk/artifacts.py` + `pipelex_sdk/artifact_models.py`) diff --git a/docs/artifact-download.md b/docs/artifact-download.md index 20aa486..89b6514 100644 --- a/docs/artifact-download.md +++ b/docs/artifact-download.md @@ -138,7 +138,7 @@ Per-item `error.code` is the fetch vocabulary above plus the download's own: `re **What it raises.** Only conditions with no verdict, all typed: -- `RunStillRunningError` (with the retry hint) or `RunFailedError` — a `run_id` naming a run that has not completed; +- `RunStillRunningError` (with the retry hint) or `RunFailedError` — a `run_id` naming a run that has not completed, the latter carrying the run's status and, as `error`, its stored error report; - `FieldNotIncludedError` — the results read never carried the scope's key, so it is absent from `results.model_fields_set`. This is the Python reading of the JS `undefined`: ask for the key and read again; - `ScopeUnavailableError` — the key WAS relayed and its value is `None`, which is the platform saying it has no such artifact for this run (`scope` and `run_id` on the error). Reading by `run_id`, a null `main_stuff` is already `MissingMainStuffError` from `get_run_result`; - `ArtifactAuthenticationError` — the resolve route refused the credential (`401` / `403`), on the first resolve or on a re-resolve part-way through. It carries `verdict`, the result as it stood: the refusal stops the remaining references being taken but lets the fetches already running finish, since they are on presigned links that do not carry the credential, so every file saved is real and listed and the rest are marked `aborted` with a detail naming the credential failure; diff --git a/docs/run-results.md b/docs/run-results.md index 25a215b..e1584b3 100644 --- a/docs/run-results.md +++ b/docs/run-results.md @@ -80,11 +80,16 @@ match state: case RunResultRunning(): print(f"not finished — poll again in {state.retry_after_seconds or 2}s") case RunResultFailed(): - print(f"run ended as {state.status}: {state.message}") + # `state.message` already names the status and the reason; `state.error` is the run's report. + print(state.message) + if state.error is not None and state.error.user_action is not None: + print(f"next step: {state.error.user_action.detail}") ``` `get_run_result` is the single-shot lookup and returns that discriminated state. `wait_for_result(run_id)` drives the same lookup in a loop, honouring the server's `Retry-After`, and returns the `RunResults` directly — raising `RunFailedError` on a terminal non-completed status and `RunTimeoutError` when the budget runs out. +A run that ended without a result has no `RunResults`, but it does have a reason. The results read answers it with a `409` whose problem document carries the run's status and its stored error report, and the failed arm carries both: `status` is the typed `RunStatus`, `message` is the platform's sentence (`Run finished with status FAILED: `), and `error` is the report typed whole as `RunErrorReport` (`pipelex_sdk.error_models`) — `error_type`, `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `validation_errors`, and anything newer on `model_extra`. It is `None` for a run that ended with no report, such as a cancelled one. `RunFailedError` carries the same three as `status`, its message and `error`. Branch on `error_domain`, `type_uri` and `retryable`, never on the wording of `message`; the report is the runner's verbose one, provider text included, so what a person sees of it is the application's decision. + ## `working_memory` — every named stuff of the run `working_memory` is everything the run held when it finished — the inputs it was given, the intermediates it produced and the main output, each under the name the method gave it. It is a declared field on both paths and reads the same on each: on the hosted path the platform relays the `working_memory.json` artifact as its own key, and on the blocking path the SDK lifts it off `pipe_output.working_memory`. The standard declares that member required on the runner's output, so on the blocking path the field always carries a value. diff --git a/pipelex_sdk/artifacts.py b/pipelex_sdk/artifacts.py index 0b2faa7..835bc4a 100644 --- a/pipelex_sdk/artifacts.py +++ b/pipelex_sdk/artifacts.py @@ -852,7 +852,7 @@ async def _read_completed_results(client: ArtifactCapableClient, run_id: str) -> msg = f"Run {run_id} is still running, so it has no artifacts to download yet{hint}" raise RunStillRunningError(msg, run_id=run_id, retry_after_seconds=retry) if isinstance(state, RunResultFailed): - raise RunFailedError(state.message, run_id=run_id, status=state.status) + raise RunFailedError(state.message, run_id=run_id, status=state.status, error=state.error) completed: RunResultCompleted = state return completed.result diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index e27579b..ccc1366 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -20,7 +20,6 @@ import asyncio import json import os -import re from time import monotonic from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, cast from urllib.parse import quote, urlencode, urlparse @@ -52,6 +51,7 @@ ResolveResponse, ResolveResponseAdapter, ) +from pipelex_sdk.error_models import FieldError, RunErrorReport, UserAction from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -140,6 +140,7 @@ # 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`. # 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 @@ -373,6 +374,9 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt 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 ApiResponseError( msg, api_url=self.base_url, @@ -383,6 +387,15 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt 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, ) def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> None: @@ -749,7 +762,8 @@ async def get_run_result(self, run_id: str) -> RunResultState: - HTTP 202 → `running` (in-flight, with the `Retry-After` hint) - HTTP 503 → `running` (DynamoDB/Temporal degraded — retry, never fail a poller) - HTTP 200 → `completed` (with the result artifacts) - - HTTP 409 → `failed` (terminal non-`COMPLETED`) + - HTTP 409 → `failed` (terminal non-`COMPLETED`), carrying the problem's `detail` as `message`, + its `run_status` member as `status` and its `error` member, the run's stored report, typed Raises: RunLifecycleUnavailableError: If the lifecycle routes are absent (a bare runner). @@ -767,12 +781,7 @@ async def get_run_result(self, run_id: str) -> RunResultState: retry_after_seconds=retry_after if retry_after is not None else _DEFAULT_DEGRADED_RETRY_SECONDS, ) if status_code == 409: - message = _parse_error_message(response) or "Run finished without a result." - return RunResultFailed( - pipeline_run_id=run_id, - status=_extract_run_status_from_message(message), - message=message, - ) + return _run_result_failed(run_id, response) self._raise_if_lifecycle_unavailable(response, url) response.raise_for_status() @@ -790,7 +799,8 @@ async def get_run_result(self, run_id: str) -> RunResultState: async def wait_for_result(self, run_id: str, options: WaitForResultOptions | None = None) -> RunResults: """Poll a run to a terminal state and return its result. - Resolves on `COMPLETED`, raises `RunFailedError` on any other terminal status, and raises + Resolves on `COMPLETED`, raises `RunFailedError` on any other terminal status — carrying the + run's status and its stored error report, typed, as `error` — and raises `RunTimeoutError` if `timeout_seconds` elapses first (the run keeps executing server-side — resume later by `run_id`). Honors the server's `Retry-After`. Async-native: cancelling the awaiting task raises `asyncio.CancelledError` out of this loop, leaving the run resumable. @@ -814,7 +824,7 @@ async def wait_for_result(self, run_id: str, options: WaitForResultOptions | Non return state.result if isinstance(state, RunResultFailed): msg = state.message - raise RunFailedError(msg, run_id=run_id, status=state.status) + raise RunFailedError(msg, run_id=run_id, status=state.status, error=state.error) # state is RunResultRunning — decide whether to keep waiting. attempt += 1 @@ -1567,17 +1577,23 @@ def _parse_retry_after(headers: httpx.Headers) -> int | None: return seconds if seconds >= 0 else None -def _parse_error_message(response: httpx.Response) -> str | None: - """Extract a human message from an error body — handles the platform's problem+json (`detail` - string) and the runner's `{"detail": {"message": ...}}` / `{"message": ...}` shapes. - """ +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: - raw = response.json() + parsed = json.loads(text) except ValueError: return None - if not isinstance(raw, dict): + if not isinstance(parsed, dict): return None - body = cast("dict[str, Any]", raw) + 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 @@ -1589,14 +1605,35 @@ def _parse_error_message(response: httpx.Response) -> str | None: return top_message if isinstance(top_message, str) else None -def _extract_run_status_from_message(message: str) -> RunStatus: - """Pull the status word out of a 409 detail ("Run finished with status FAILED; ..."), defaulting - to FAILED if the shape ever changes. +def _run_result_failed(run_id: str, response: httpx.Response) -> RunResultFailed: + """Build the failed arm from the results read's `409` problem document. + + The platform's document carries `detail` (`Run finished with status : `, or + `...; no result available` when the run has no report) and two extension members: `run_status`, + the run's terminal status — named so because a problem's own `status` is the HTTP status — and + `error`, the run's stored error report or `null`. The status is read from `run_status`, never + parsed back out of the sentence. A `409` without that member (the one this route answers for a + 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." + 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 + return RunResultFailed(pipeline_run_id=run_id, status=status, message=message, error=_run_error_report_of(body.get("error"))) + + +def _run_error_report_of(raw: object) -> RunErrorReport | None: + """Type a stored error report, best-effort: this is the failure path, so a report whose known + fields do not fit their types must not mask the failure it explains — `detail` still carries the + report's message, and the run's status read serves the report again. """ - match = re.search(r"status\s+([A-Z_]+)", message) - if match and match.group(1) in _KNOWN_RUN_STATUS_NAMES: - return RunStatus(match.group(1)) - return RunStatus.FAILED + if not isinstance(raw, dict): + return None + try: + return RunErrorReport.model_validate(raw) + except ValidationError: + return None def _is_valid_base_url(value: str) -> bool: @@ -1630,61 +1667,68 @@ def _origin_of(base_url: str) -> str: class _ParsedErrorBody(NamedTuple): - """The fields pulled out of a `problem+json` / `HTTPException` error body.""" + """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, +) - -_EMPTY_ERROR_BODY = _ParsedErrorBody(error_type=None, server_message=None, validation_errors=None, code=None) - -# The build routes' 422s carry a top-level `validation_errors[]`. Validated leniently -# (best-effort error-path enrichment) so an odd shape never masks the underlying failure. +# The structured members below are validated 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_ADAPTER: TypeAdapter[list[ValidationErrorItem]] = TypeAdapter(list[ValidationErrorItem]) +_FIELD_ERRORS_ADAPTER: TypeAdapter[list[FieldError]] = TypeAdapter(list[FieldError]) def _parse_error_body(body: str) -> _ParsedErrorBody: - """Extract `error_type` / `message` / `validation_errors` / `code` from an error body. - - The API serializes errors as `{"detail": {"error_type": ..., "message": ...}}` - (HTTPException with dict detail) or `{"detail": "..."}` (auth 401s and RFC 7807 - problems); both shapes are handled, with top-level `error_type` / `message` - fallbacks. The product routes' RFC 9457 `problem+json` adds a stable top-level - `code` discriminant. Falls through to empty on a non-JSON or non-object body. + """Extract the members of an error body into `_ParsedErrorBody`. + + 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. """ - if not body: - return _EMPTY_ERROR_BODY - try: - parsed = json.loads(body) - except ValueError: - return _EMPTY_ERROR_BODY - if not isinstance(parsed, dict): + root = _decode_object(body) + if root is None: return _EMPTY_ERROR_BODY - root = cast("dict[str, Any]", parsed) error_type: str | None = None - server_message: str | None = None detail = root.get("detail") if isinstance(detail, dict): - detail_dict = cast("dict[str, Any]", detail) - raw_error_type = detail_dict.get("error_type") - if isinstance(raw_error_type, str): - error_type = raw_error_type - raw_message = detail_dict.get("message") - if isinstance(raw_message, str): - server_message = raw_message - elif isinstance(detail, str): - server_message = detail + error_type = _str_member(cast("dict[str, Any]", detail), "error_type") if error_type is None: - top_error_type = root.get("error_type") - if isinstance(top_error_type, str): - error_type = top_error_type - if server_message is None: - top_message = root.get("message") - if isinstance(top_message, str): - server_message = top_message + error_type = _str_member(root, "error_type") validation_errors: list[ValidationErrorItem] | None = None raw_validation_errors = root.get("validation_errors") @@ -1692,14 +1736,44 @@ def _parse_error_body(body: str) -> _ParsedErrorBody: try: validation_errors = _VALIDATION_ERRORS_ADAPTER.validate_python(raw_validation_errors) except ValidationError: - # Best-effort error-path enrichment: an odd validation_errors shape (only - # reachable via the out-of-scope /v1/build/* 422s) must not mask the - # underlying API failure — server_message still carries the problem. validation_errors = None - code: str | None = None - raw_code = root.get("code") - if isinstance(raw_code, str): - code = raw_code + 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 + + user_action: UserAction | None = None + raw_user_action = root.get("user_action") + if isinstance(raw_user_action, dict): + try: + user_action = UserAction.model_validate(raw_user_action) + except ValidationError: + user_action = 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, + ) + - return _ParsedErrorBody(error_type=error_type, server_message=server_message, validation_errors=validation_errors, code=code) +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 diff --git a/pipelex_sdk/error_models.py b/pipelex_sdk/error_models.py new file mode 100644 index 0000000..b55b482 --- /dev/null +++ b/pipelex_sdk/error_models.py @@ -0,0 +1,148 @@ +"""Error-report wire models — a failed run's stored report, and the typed members of a problem document. + +**The runner owns these shapes; this SDK follows them.** A run that fails reports why as the runner's +`ErrorReport` (`pipelex.base_exceptions`), which the hosted platform stores whole on the run row as +`error` and serves as it was stored: on the status read (`RunRead.error`), on the run records +(`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. + +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 +does not name must ride `model_extra` rather than fail the parse of a body whose whole point is to +say what went wrong. The enum-ish fields (`error_domain`, `error_category`, `user_action.kind`) are +open sets on the wire and stay plain `str`, never frozen enums, so a value the runner adds is not an +SDK break; their known values are listed where they are declared. + +**Nothing is stripped.** The platform serves the runner's VERBOSE report, so `message` and +`provider_metadata` can hold the provider's raw text. Deciding what of it a person should see is +each consumer's presentation, not this SDK's; the report arrives here whole. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from pipelex_sdk.validation_models import ValidationErrorItem + + +class UserAction(BaseModel): + """The next step a report advises — the runner's `UserAction`. + + `kind` names the category of advice, so a consumer can render consistent guidance; `detail` is + the free-form, error-specific text (a billing URL, a retry hint, the model to change). + """ + + model_config = ConfigDict(extra="allow") + + #: Known values: `wait_and_retry`, `check_billing`, `check_credentials`, `change_input`, + #: `change_model`, `contact_support`, `unknown`. + kind: str | None = None + detail: str | None = None + + +class ProviderErrorMetadata(BaseModel): + """What the inference provider's SDK said about a failed call — the runner's `ProviderErrorMetadata`. + + Present on a report whose failure came back from a model provider. `message` is the provider + SDK's own text, relayed raw. The provider's response body never crosses the wire: the runner + excludes it from every serialization. + """ + + model_config = ConfigDict(extra="allow") + + provider: str | None = None + sdk_exception_type: str | None = None + message: str | None = None + #: The provider's HTTP status, when it answered one. + status_code: int | None = None + #: The provider's own request id — what its support desk asks for. + request_id: str | None = None + retry_after_seconds: float | None = None + provider_error_code: str | None = None + + +class MigrationErrorBlock(BaseModel): + """A pending configuration migration that explains the failure — the runner's `MigrationErrorBlock`. + + Present only on a configuration failure whose raiser scanned the host's configuration + directories; a consumer branches on its presence. `plans` is carried opaquely: it is the shape + `pipelex-agent migrate --dry-run --format json` emits, which no published package declares. + """ + + model_config = ConfigDict(extra="allow") + + #: The command that applies whatever can be applied without a decision. + remedy: str | None = None + #: Whether running `remedy` would rewrite any file. + would_write: bool | None = None + #: Whether something here is a person's to resolve rather than the tool's. + needs_attention: bool | None = None + plans: list[dict[str, Any]] | None = None + + +class RunErrorReport(BaseModel): + """Why a run failed — the runner's `ErrorReport`, typed with every field it carries. + + The one type for a failed run's report wherever the SDK hands it back: `RunPublic.error` (and so + `RunRead.error` on the status read), `PipelineRun.error` on the run records, `RunResultFailed.error` + on the results read's `409`, and `RunFailedError.error` when `wait_for_result`, `start_and_wait` + or an artifact download raises for a run that ended without a result. + + **Branch on `error_domain`, `type_uri` and `retryable`**, never on the wording of `message`. + `error_type` is the runner's open-ended exception class name: finer than `error_domain`, useful in + a support line, but not a closed set to match against. + + A report is `None` where the run has none — a cancelled, terminated or timed-out run, or one the + platform finalized itself — so the absence of a report says nothing about why. + """ + + model_config = ConfigDict(extra="allow") + + #: The runner's exception class name (`LLMCompletionError`, `SandboxProvisioningError`, …) — an + #: open set, for display and support, not for branching. + error_type: str | None = None + #: What went wrong, as the runner wrote it. On the VERBOSE report the platform serves, it can + #: carry a provider's raw text. + message: str | None = None + #: A stable human label for the error class (`LLM completion`). + title: str | None = None + #: The stable URI naming the error class — a branch field, and where its documentation lives. + type_uri: str | None = None + #: Where the error comes from, the coarse branch field. Known values: `input` (the caller can fix + #: it), `config` (a configuration change is needed), `runtime` (a failure during execution). + error_domain: str | None = None + #: A finer classification of an inference failure, when the runner has one. Known values: + #: `transient`, `configuration`, `content`, `capacity`, `ambiguous`, `unknown`. + error_category: str | None = None + #: Whether retrying the same run can succeed. `None` means unknown, never "no". + retryable: bool | None = None + user_action: UserAction | None = None + #: The model the failing call used, when the failure is an inference failure. + model: str | None = None + #: The provider the failing call reached, when the failure is an inference failure. + provider: str | None = None + provider_metadata: ProviderErrorMetadata | None = None + #: True when `message` was written as caller-facing copy. The runner emits it only when true. + caller_facing_message: bool | None = None + #: The structured diagnostics of a bundle that failed validation — the same items the validate + #: report and a `422`'s `ApiResponseError.validation_errors` carry. + validation_errors: list[ValidationErrorItem] | None = None + migration: MigrationErrorBlock | None = None + + +class FieldError(BaseModel): + """One field-level failure of a request, an item of the platform problem document's `errors[]`. + + `field` is the dotted path to the offending attribute, `code` a stable sub-code + (`invalid_format`, `out_of_range`, …), `detail` optional human text. + """ + + model_config = ConfigDict(extra="allow") + + field: str | None = None + code: str | None = None + detail: str | None = None diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index 3ae8d6a..6bda146 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -7,9 +7,10 @@ - `ApiUnreachableError` — the HTTP exchange never produced a response (DNS / connect / 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 parsed - problem-details and, for the product routes, the stable RFC 9457 `code` discriminant - a consumer branches on (decoupled from the HTTP status). +- `ApiResponseError` — a non-2xx response from the API, carrying the members of its + RFC 9457 problem document: the branch fields `error_domain` and `type_uri` (the + problem's `type`), the surface-native `code` / `error_type`, the request id, and the + rest (decoupled from the HTTP status). - `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 @@ -47,7 +48,10 @@ from mthds.runners.api.exceptions import RunStillRunningError as RunStillRunningError # ruff: ignore[useless-import-alias] if TYPE_CHECKING: + from typing import Any + from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult + from pipelex_sdk.error_models import FieldError, RunErrorReport, UserAction from pipelex_sdk.runs import RunStatus from pipelex_sdk.validation_models import ValidationErrorItem @@ -70,15 +74,31 @@ def __init__(self, message: str, api_url: str, code: str | None = None) -> None: class ApiResponseError(PipelineRequestError): - """A non-2xx response that DID come back from the API. - - Carries the parsed RFC 7807 problem-details (`error_type`, `server_message`) and, - for the build routes' 422s, the structured `validation_errors` list. - - `code` is the product routes' stable RFC 9457 `problem+json` discriminant - (`conflict`, `not_found`, `pipelex_api_key_limit_reached`, …) — the field a - consumer branches on, decoupled from the HTTP status. `None` for any error body - that carries no `code` (the protocol/build routes' `detail`-shaped problems). + """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: + + - **The branch fields.** `error_domain` is the coarse class a consumer branches on — `input` (the + caller can fix it), `config` (a configuration change is needed), `runtime` (a failure during + execution) — and `type_uri` (the problem's `type`) is the stable URI naming the error class. + `retryable` says whether a blind retry can succeed, `None` meaning unknown. Branch on these, + never on the HTTP status or on the wording of a message. + - **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. """ def __init__( @@ -93,6 +113,15 @@ def __init__( 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, + 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, ) -> None: super().__init__(message) self.api_url = api_url @@ -103,6 +132,15 @@ def __init__( self.server_message = server_message self.validation_errors = validation_errors 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): @@ -123,16 +161,30 @@ def __init__(self, message: str, elapsed_seconds: float) -> None: class RunFailedError(PipelineRequestError): """Raised when a run reaches a terminal state that is not `COMPLETED`. - Surfaced from `wait_for_result` / `get_run_result` when the platform answers a - result lookup with HTTP 409 (`FAILED`, `CANCELLED`, `TERMINATED`, - `TIMED_OUT`). `run_id` and `status` let callers report the outcome precisely; - `status` stays the typed `RunStatus` enum so callers can match/case on it. + Surfaced by `wait_for_result`, `start_and_wait` and `download_artifacts` when the platform + answers the results read with HTTP 409 (`FAILED`, `CANCELLED`, `TERMINATED`, `TIMED_OUT`). + + - `status` is the run's terminal status, the typed `RunStatus` enum, read from the problem's + `run_status` member — so callers can match/case on it. + - `error` is the run's stored error report, typed whole as `RunErrorReport`: 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`. Branch on `error.error_domain`, `error.type_uri` and `error.retryable`; show + `error.user_action` as the next step. It is the runner's VERBOSE report, so `message` and + `provider_metadata` can hold a provider's raw text — deciding what a person sees is yours. + `None` when the run ended with no stored report (a cancelled, terminated or timed-out run, or + one the platform finalized itself). + - The exception's own message is the problem's `detail`, which names the status and then the + report's message (`Run finished with status FAILED: `), so printing the error already + tells the reason. + - `run_id` locates the run, for a status read or a support request. """ - def __init__(self, message: str, run_id: str, status: RunStatus) -> None: + def __init__(self, message: str, run_id: str, status: RunStatus, error: RunErrorReport | None = None) -> None: super().__init__(message) self.run_id = run_id self.status = status + self.error = error class RunTimeoutError(PipelineRequestError): diff --git a/pipelex_sdk/product_models.py b/pipelex_sdk/product_models.py index eb0f5cc..89a87f9 100644 --- a/pipelex_sdk/product_models.py +++ b/pipelex_sdk/product_models.py @@ -24,6 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_serializer, field_validator from pipelex_sdk._pydantic_utils import empty_list_factory_of +from pipelex_sdk.error_models import RunErrorReport from pipelex_sdk.runs import RunStatus # ── User profile (`/v1/me`) ───────────────────────────────────────────── @@ -603,18 +604,6 @@ class PipeStatus(StrEnum): SKIPPED = "skipped" -class RunErrorReport(BaseModel): - """A failed run's error, narrowed to the two fields a consumer may rely on. - - The runner's own report is considerably more verbose; only these two are contractual. - """ - - model_config = ConfigDict(extra="allow") - - message: str | None = None - error_type: str | None = None - - class PipelineRun(BaseModel): """One run record in a method's run list — `GET /v1/runs?method_id=…`.""" diff --git a/pipelex_sdk/runs.py b/pipelex_sdk/runs.py index 818c80a..59b811b 100644 --- a/pipelex_sdk/runs.py +++ b/pipelex_sdk/runs.py @@ -47,6 +47,8 @@ from mthds.runners.api.models import DictPipeOutputAbstract, DictWorkingMemoryAbstract from pydantic import BaseModel, ConfigDict, Field +from pipelex_sdk.error_models import RunErrorReport + if TYPE_CHECKING: from collections.abc import Callable @@ -146,6 +148,10 @@ class RunPublic(BaseModel): status: RunStatus created_at: str finished_at: str | None = None + #: Why the run failed — the runner's report as the platform stored it, whole and typed (see + #: `pipelex_sdk.error_models`). `None` for a run that has not failed, and for one that ended with + #: no stored report (cancelled, terminated, timed out, or finalized by the platform itself). + error: RunErrorReport | None = None class RunRead(RunPublic): @@ -339,12 +345,20 @@ class RunResultCompleted(BaseModel): class RunResultFailed(BaseModel): - """HTTP 409 — the run reached a terminal non-`COMPLETED` status.""" + """HTTP 409 — the run reached a terminal non-`COMPLETED` status. + + Built from the platform's problem document: `message` is its `detail`, which names the status and + then the report's own message (`Run finished with status FAILED: `), `status` is its + `run_status` member, and `error` is its `error` member, the run's stored report typed whole — the + same object the status read serves as `RunRead.error`. `error` is `None` for a run that ended with + no stored report. + """ state: Literal["failed"] = "failed" pipeline_run_id: str status: RunStatus message: str + error: RunErrorReport | None = None RunResultState: TypeAlias = Annotated[ diff --git a/tests/unit/test_api_response_error.py b/tests/unit/test_api_response_error.py new file mode 100644 index 0000000..21c84ec --- /dev/null +++ b/tests/unit/test_api_response_error.py @@ -0,0 +1,136 @@ +"""Tests for the members `ApiResponseError` carries off a problem document, driven through a product route.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +import httpx +import pytest + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.errors import ApiResponseError + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + +_BASE_URL = "http://localhost:8081" + +# A platform request-validation 422 as its error handler renders it: every standard member, the +# request id in the body and on the header, and the field-level `errors[]`. +_PLATFORM_422: dict[str, Any] = { + "type": "https://pipelex.com/errors/validation_failed", + "title": "Unprocessable entity", + "status": 422, + "code": "validation_failed", + "detail": "Request validation failed.", + "instance": "urn:pipelex:request:req-422", + "request_id": "req-422", + "errors": [{"field": "body.label", "code": "string_too_long", "detail": "String should have at most 64 characters"}], +} + +# A runner-rendered problem (`ErrorReport.to_problem_document`) as the hosted API relays it: the +# standard slots plus the classification extension members, including a member this SDK does not name. +_RUNNER_PROBLEM: dict[str, Any] = { + "type": "https://docs.pipelex.com/latest/errors/pipeline-input-error/", + "title": "Pipeline input", + "status": 422, + "detail": "Input 'document' expects a Document, got an Image.", + "request_id": "req-runner", + "error_type": "PipelineInputError", + "error_domain": "input", + "error_category": "content", + "retryable": False, + "user_action": {"kind": "change_input", "detail": "Send a PDF for the 'document' input."}, + "location": "cv_screening.mthds:screen", +} + + +def _response(status_code: int, *, json_body: object | None = None, text: str | None = None, headers: dict[str, str] | None = None) -> httpx.Response: + request = httpx.Request("GET", f"{_BASE_URL}/x") + if json_body is not None: + return httpx.Response(status_code, json=json_body, headers=headers or {}, request=request) + return httpx.Response(status_code, text=text or "", headers=headers or {}, request=request) + + +class TestApiResponseError: + def _raise_from(self, mocker: MockerFixture, response: httpx.Response) -> ApiResponseError: + client = PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=response)) + with pytest.raises(ApiResponseError) as exc_info: + asyncio.run(client.get_subscription()) + return exc_info.value + + def test_platform_problem_exposes_every_member(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(422, json_body=_PLATFORM_422, headers={"X-Request-ID": "req-422"})) + + assert err.status == 422 + assert err.code == "validation_failed" + assert err.type_uri == "https://pipelex.com/errors/validation_failed" + assert err.title == "Unprocessable entity" + assert err.server_message == "Request validation failed." + assert err.request_id == "req-422" + assert err.errors is not None + assert [(item.field, item.code, item.detail) for item in err.errors] == [ + ("body.label", "string_too_long", "String should have at most 64 characters"), + ] + assert err.problem == _PLATFORM_422 + assert err.error_domain is None + assert err.retryable is None + assert err.user_action is None + + def test_runner_problem_exposes_the_classification_members(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(422, json_body=_RUNNER_PROBLEM)) + + assert err.type_uri == "https://docs.pipelex.com/latest/errors/pipeline-input-error/" + assert err.title == "Pipeline input" + assert err.error_type == "PipelineInputError" + assert err.error_domain == "input" + assert err.error_category == "content" + assert err.retryable is False + assert err.user_action is not None + assert err.user_action.kind == "change_input" + assert err.user_action.detail == "Send a PDF for the 'document' input." + assert err.request_id == "req-runner" + assert err.server_message == "Input 'document' expects a Document, got an Image." + assert err.code is None + # A member the SDK does not name stays reachable on the decoded document. + assert err.problem is not None + assert err.problem["location"] == "cv_screening.mthds:screen" + + def test_request_id_falls_back_to_the_header(self, mocker: MockerFixture) -> None: + body = {key: value for key, value in _PLATFORM_422.items() if key != "request_id"} + err = self._raise_from(mocker, _response(422, json_body=body, headers={"X-Request-ID": "req-from-header"})) + + assert err.request_id == "req-from-header" + + def test_request_id_from_the_header_when_the_body_is_not_a_problem(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(502, text="Bad Gateway", headers={"X-Request-ID": "req-edge"})) + + assert err.request_id == "req-edge" + assert err.problem is None + assert err.response_body == "Bad Gateway" + + def test_body_request_id_wins_over_the_header(self, mocker: MockerFixture) -> None: + err = self._raise_from(mocker, _response(422, json_body=_PLATFORM_422, headers={"X-Request-ID": "req-other"})) + + assert err.request_id == "req-422" + + @pytest.mark.parametrize( + "body", + [ + {"detail": "x", "retryable": "no", "user_action": "retry later", "errors": "none", "error_domain": 3, "type": None}, + {"detail": "x", "user_action": {"kind": 5}, "errors": [7]}, + ], + ) + def test_members_of_the_wrong_shape_read_as_none(self, mocker: MockerFixture, body: dict[str, Any]) -> None: + err = self._raise_from(mocker, _response(409, json_body=body)) + + assert err.server_message == "x" + assert err.retryable is None + assert err.user_action is None + assert err.errors is None + assert err.error_domain is None + assert err.type_uri is None + assert err.request_id is None + assert err.problem == body diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py index 3de0cc9..94ede1c 100644 --- a/tests/unit/test_artifacts.py +++ b/tests/unit/test_artifacts.py @@ -37,6 +37,7 @@ locate_artifacts, resolve_artifacts, ) +from pipelex_sdk.error_models import RunErrorReport from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -680,11 +681,14 @@ def test_raises_run_still_running_with_the_retry_hint(self, tmp_path: Path) -> N asyncio.run(download_artifacts(client, dir_path=tmp_path, run_id=_RUN_ID)) def test_raises_run_failed_for_a_run_that_ended_without_a_result(self, tmp_path: Path) -> None: - client = _FakeClient(run_result=RunResultFailed(pipeline_run_id=_RUN_ID, status=RunStatus.FAILED, message="the run failed")) + report = RunErrorReport(error_type="SandboxProvisioningError", message="Snapshot is building", error_domain="runtime", retryable=True) + failed = RunResultFailed(pipeline_run_id=_RUN_ID, status=RunStatus.FAILED, message="the run failed", error=report) + client = _FakeClient(run_result=failed) with pytest.raises(RunFailedError) as caught: asyncio.run(download_artifacts(client, dir_path=tmp_path, run_id=_RUN_ID)) assert caught.value.status == RunStatus.FAILED assert caught.value.run_id == _RUN_ID + assert caught.value.error == report def test_raises_field_not_included_when_the_scope_key_was_never_relayed(self, tmp_path: Path) -> None: client = _FakeClient() diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py index a42bf6a..8279ff7 100644 --- a/tests/unit/test_client_lifecycle.py +++ b/tests/unit/test_client_lifecycle.py @@ -1,6 +1,7 @@ """Tests for `PipelexAPIClient`'s durable run-lifecycle surface (start/status/results/wait), httpx mocked.""" import asyncio +from typing import Any import httpx import pytest @@ -28,6 +29,70 @@ _BASE_URL = "http://localhost:8081" +# A runner `ErrorReport` in its VERBOSE form, as the platform stores it on the run row and serves it +# on the status read and in the results read's 409 — here the gateway refusing a model, the case +# recorded live on the dev API: every inference field of the report is set. +_MODEL_REFUSED_REPORT: dict[str, Any] = { + "error_type": "LLMCompletionError", + "message": "Error code: 400 - model 'gpt-6-astra' is not enabled for this organization", + "title": "LLM completion", + "type_uri": "https://docs.pipelex.com/latest/errors/llm-completion-error/", + "error_category": "configuration", + "error_domain": "config", + "retryable": False, + "user_action": {"kind": "change_input", "detail": "The provider rejected the request — review the prompt, parameters, and inputs."}, + "model": "gpt-6-astra", + "provider": "pipelex_gateway", + "provider_metadata": { + "provider": "pipelex_gateway", + "sdk_exception_type": "BadRequestError", + "message": "Error code: 400 - model 'gpt-6-astra' is not enabled for this organization", + "status_code": 400, + "request_id": "req_provider_1", + "provider_error_code": "model_not_enabled", + }, +} + +# A bundle fault, the report's other shape: caller-facing, with the structured validation items. +_BUNDLE_FAULT_REPORT: dict[str, Any] = { + "error_type": "ValidateBundleError", + "message": "Pipe 'summarize' names an unknown concept 'Sumary'.", + "title": "Bundle validation", + "type_uri": "https://docs.pipelex.com/latest/errors/validate-bundle-error/", + "error_domain": "input", + "retryable": False, + "caller_facing_message": True, + "validation_errors": [ + { + "category": "pipe_validation", + "message": "Pipe 'summarize' names an unknown concept 'Sumary'.", + "error_type": "unknown_concept", + "pipe_code": "summarize", + "missing_concept_code": "Sumary", + } + ], +} + + +def _failed_results_problem(run_status: str, error: dict[str, Any] | None) -> dict[str, Any]: + """The results read's 409 exactly as the platform renders it for a run that ended without completing.""" + if error is not None: + detail = f"Run finished with status {run_status}: {error['message']}" + else: + detail = f"Run finished with status {run_status}; no result available" + return { + "type": "https://pipelex.com/errors/conflict", + "title": "Conflict", + "status": 409, + "code": "conflict", + "detail": detail, + "instance": "urn:pipelex:request:req-409", + "request_id": "req-409", + "errors": [], + "run_status": run_status, + "error": error, + } + def _response(status_code: int, *, json: object = None, headers: dict[str, str] | None = None) -> httpx.Response: """Build a constructed httpx.Response with a request attached (so raise_for_status works).""" @@ -105,6 +170,40 @@ def test_get_run_status_populates_degraded_and_retry_after(self, mocker: MockerF assert run.degraded is True assert run.retry_after_seconds == 7 + def test_get_run_status_types_the_stored_report(self, mocker: MockerFixture) -> None: + """A status read whose body carries `error` exposes the stored report on `RunRead.error`, typed whole.""" + client = self._client() + body = { + "pipeline_run_id": "run_1", + "status": "FAILED", + "created_at": "2026-06-10T00:00:00Z", + "finished_at": "2026-06-10T00:01:00Z", + "error": _BUNDLE_FAULT_REPORT, + "degraded": False, + } + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=body))) + + run = asyncio.run(client.get_run_status("run_1")) + assert run.status == RunStatus.FAILED + assert run.error is not None + assert run.error.error_type == "ValidateBundleError" + assert run.error.error_domain == "input" + assert run.error.caller_facing_message is True + assert run.error.validation_errors is not None + assert run.error.validation_errors[0].pipe_code == "summarize" + assert run.error.validation_errors[0].missing_concept_code == "Sumary" + assert run.error.model_dump(exclude_none=True) == _BUNDLE_FAULT_REPORT + assert run.model_extra == {} + + def test_get_run_status_without_error_reads_none(self, mocker: MockerFixture) -> None: + """A run that has not failed carries no report: `error` is None, whether absent or null.""" + client = self._client() + body = {"pipeline_run_id": "run_1", "status": "RUNNING", "created_at": "2026-06-10T00:00:00Z", "error": None} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=body))) + + run = asyncio.run(client.get_run_status("run_1")) + assert run.error is None + def test_get_run_status_lifecycle_unavailable_on_missing_route(self, mocker: MockerFixture) -> None: """A bare-runner 404 on the status route becomes RunLifecycleUnavailableError.""" client = self._client() @@ -218,16 +317,88 @@ def test_get_run_result_degraded_503_defaults_retry(self, mocker: MockerFixture) assert isinstance(state, RunResultRunning) assert state.retry_after_seconds == 5 - def test_get_run_result_failed_extracts_status(self, mocker: MockerFixture) -> None: - """A 409 maps to RunResultFailed with the terminal status parsed from the detail message.""" + def test_get_run_result_failed_carries_the_typed_report(self, mocker: MockerFixture) -> None: + """A 409 carrying the run's status and its stored report maps to RunResultFailed with the whole report, typed.""" + client = self._client() + body = _failed_results_problem("FAILED", _MODEL_REFUSED_REPORT) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == RunStatus.FAILED + assert state.message == f"Run finished with status FAILED: {_MODEL_REFUSED_REPORT['message']}" + report = state.error + assert report is not None + assert report.error_type == "LLMCompletionError" + assert report.title == "LLM completion" + assert report.type_uri == "https://docs.pipelex.com/latest/errors/llm-completion-error/" + assert report.error_domain == "config" + assert report.error_category == "configuration" + assert report.retryable is False + assert report.user_action is not None + assert report.user_action.kind == "change_input" + assert report.model == "gpt-6-astra" + assert report.provider == "pipelex_gateway" + assert report.provider_metadata is not None + assert report.provider_metadata.status_code == 400 + assert report.provider_metadata.provider_error_code == "model_not_enabled" + # Nothing is stripped: the typed report dumps back to exactly what the platform stored. + assert report.model_dump(exclude_none=True) == _MODEL_REFUSED_REPORT + + @pytest.mark.parametrize("run_status", [RunStatus.FAILED, RunStatus.CANCELLED, RunStatus.TERMINATED, RunStatus.TIMED_OUT]) + def test_get_run_result_failed_without_a_report_reads_its_status(self, mocker: MockerFixture, run_status: RunStatus) -> None: + """A 409 whose `error` is null is a report-less failure with the status the `run_status` member names.""" + client = self._client() + body = _failed_results_problem(run_status, None) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == run_status + assert state.error is None + assert state.message == f"Run finished with status {run_status}; no result available" + + def test_get_run_result_failed_reads_the_status_member_not_the_sentence(self, mocker: MockerFixture) -> None: + """The status comes from `run_status`; the sentence is never parsed, so a detail naming another word does not win.""" client = self._client() - body = {"code": "CONFLICT", "detail": "Run finished with status TIMED_OUT; no result available"} + body = _failed_results_problem("CANCELLED", None) + body["detail"] = "Run finished with status TIMED_OUT; no result available" mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) state = asyncio.run(client.get_run_result("run_1")) assert isinstance(state, RunResultFailed) - assert state.status == RunStatus.TIMED_OUT - assert "TIMED_OUT" in state.message + assert state.status == RunStatus.CANCELLED + + @pytest.mark.parametrize( + "body", + [ + {"code": "conflict", "detail": "Run finished with status TIMED_OUT; no result available"}, + {"code": "conflict", "detail": "refused", "run_status": "SOMETHING_NEW"}, + {"code": "conflict", "detail": "refused", "run_status": 7}, + ], + ) + def test_get_run_result_failed_without_a_known_status_member_reads_failed(self, mocker: MockerFixture, body: dict[str, Any]) -> None: + """A 409 without a `run_status` this SDK knows reads as FAILED, the status every such answer shares.""" + client = self._client() + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == RunStatus.FAILED + assert state.message == body["detail"] + assert state.error is None + + def test_get_run_result_failed_with_a_malformed_report_still_fails_with_its_reason(self, mocker: MockerFixture) -> None: + """A report whose known fields do not fit their types reads as None; the failure and its detail survive.""" + client = self._client() + body = _failed_results_problem("FAILED", {"message": "boom", "validation_errors": "not a list"}) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == RunStatus.FAILED + assert state.message == "Run finished with status FAILED: boom" + assert state.error is None def test_get_run_result_lifecycle_unavailable_on_missing_route(self, mocker: MockerFixture) -> None: """A bare-runner 404 on the results route becomes RunLifecycleUnavailableError.""" @@ -289,6 +460,24 @@ def test_wait_for_result_raises_run_failed(self, mocker: MockerFixture) -> None: assert exc_info.value.run_id == "run_1" assert exc_info.value.status == RunStatus.CANCELLED + def test_wait_for_result_raises_run_failed_with_the_report(self, mocker: MockerFixture) -> None: + """Over the recorded 409, wait_for_result raises RunFailedError carrying the status, the detail and the whole report.""" + client = self._client() + body = _failed_results_problem("FAILED", _MODEL_REFUSED_REPORT) + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + with pytest.raises(RunFailedError) as exc_info: + asyncio.run(client.wait_for_result("run_1")) + err = exc_info.value + assert err.run_id == "run_1" + assert err.status == RunStatus.FAILED + assert str(err) == f"Run finished with status FAILED: {_MODEL_REFUSED_REPORT['message']}" + assert err.error is not None + assert err.error.error_domain == "config" + assert err.error.user_action is not None + assert err.error.user_action.detail == _MODEL_REFUSED_REPORT["user_action"]["detail"] + assert err.error.model_dump(exclude_none=True) == _MODEL_REFUSED_REPORT + def test_wait_for_result_times_out(self, mocker: MockerFixture) -> None: """When the run never terminates and the timeout elapses, RunTimeoutError is raised (run survives).""" client = self._client() diff --git a/tests/unit/test_error_models.py b/tests/unit/test_error_models.py new file mode 100644 index 0000000..96475b5 --- /dev/null +++ b/tests/unit/test_error_models.py @@ -0,0 +1,64 @@ +"""Tests for pipelex_sdk.error_models — the runner's error report, typed whole and open to what it adds.""" + +from typing import Any + +from pipelex_sdk.error_models import RunErrorReport + +# A configuration failure carrying the migration block, the one report field the recorded run +# responses do not exercise. +_STALE_CONFIG_REPORT: dict[str, Any] = { + "error_type": "PipelexConfigError", + "message": "The configuration under .pipelex/ uses a retired key.", + "title": "Pipelex config", + "type_uri": "https://docs.pipelex.com/latest/errors/pipelex-config-error/", + "error_domain": "config", + "migration": { + "remedy": "pipelex-agent migrate", + "would_write": True, + "needs_attention": False, + "plans": [{"path": ".pipelex/pipelex.toml", "operations": [{"kind": "rename_key", "from": "a", "to": "b"}]}], + }, +} + + +class TestErrorModels: + def test_the_migration_block_is_typed(self) -> None: + report = RunErrorReport.model_validate(_STALE_CONFIG_REPORT) + + assert report.migration is not None + assert report.migration.remedy == "pipelex-agent migrate" + assert report.migration.would_write is True + assert report.migration.needs_attention is False + assert report.migration.plans == _STALE_CONFIG_REPORT["migration"]["plans"] + assert report.model_dump(exclude_none=True) == _STALE_CONFIG_REPORT + + def test_fields_the_runner_adds_ride_model_extra_at_every_level(self) -> None: + raw: dict[str, Any] = { + "error_type": "PipelineExecutionError", + "message": "Pipe 'summarize' failed", + "location": "two_steps > summarize", + "user_action": {"kind": "change_model", "detail": "Pick a served model.", "link": "https://docs.pipelex.com"}, + "provider_metadata": {"provider": "openai", "status_code": 404, "trace": "abc"}, + } + report = RunErrorReport.model_validate(raw) + + assert report.model_extra == {"location": "two_steps > summarize"} + assert report.user_action is not None + assert report.user_action.model_extra == {"link": "https://docs.pipelex.com"} + assert report.provider_metadata is not None + assert report.provider_metadata.model_extra == {"trace": "abc"} + assert report.model_dump(exclude_none=True) == raw + + def test_enum_like_fields_accept_values_this_version_does_not_know(self) -> None: + raw: dict[str, Any] = {"error_domain": "network", "error_category": "brand_new", "user_action": {"kind": "wait_for_quota"}} + report = RunErrorReport.model_validate(raw) + + assert report.error_domain == "network" + assert report.error_category == "brand_new" + assert report.user_action is not None + assert report.user_action.kind == "wait_for_quota" + + def test_an_empty_report_parses(self) -> None: + report = RunErrorReport.model_validate({}) + + assert report.model_dump(exclude_none=True) == {} diff --git a/tests/unit/test_error_parsing.py b/tests/unit/test_error_parsing.py index f44f4a2..e3acf9d 100644 --- a/tests/unit/test_error_parsing.py +++ b/tests/unit/test_error_parsing.py @@ -43,3 +43,5 @@ def test_non_object_bodies_are_empty(self, body: str) -> 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 From d11b9951c8cec2e9a4708ce96f89cf1462179efe Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 18:20:39 +0200 Subject: [PATCH 2/4] fix: read a stored report leniently everywhere, and say where error_domain is carried A run's stored report is written by whichever runner version ran it and never migrated, so the status read and the run lists no longer fail on one that drifted: every field of the report models reads leniently (a value that does not fit its type reads as None and the rest stands), and LenientRunErrorReport types RunPublic.error, PipelineRun.error and RunResultFailed.error, so a value that is not a report reads as None too. The results read's 409 path now uses the same field type instead of its own fallback. The platform's own problem documents carry no error_domain, so the README example, the architecture doc, the ApiResponseError docstring and the changelog now branch on type_uri there and on error_domain only where a runner-rendered problem carries it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FigDssaJrvNcmbnBedi7oq --- CHANGELOG.md | 4 +- README.md | 8 ++-- docs/architecture.md | 4 +- pipelex_sdk/client.py | 34 +++++----------- pipelex_sdk/error_models.py | 57 +++++++++++++++++++-------- pipelex_sdk/errors.py | 17 ++++---- pipelex_sdk/product_models.py | 4 +- pipelex_sdk/runs.py | 9 +++-- tests/unit/test_api_response_error.py | 12 +++++- tests/unit/test_client_lifecycle.py | 49 +++++++++++++++++++++-- tests/unit/test_error_models.py | 26 ++++++++++++ 11 files changed, 157 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9744b..19a2414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ ### Added - **A failed run's error report on `RunFailedError`, `RunResultFailed` and `RunRead`**: `RunFailedError.error`, `RunResultFailed.error` and `RunRead.error` (declared on `RunPublic`) carry the run's stored error report typed as `RunErrorReport`, so `wait_for_result`, `start_and_wait` and `download_artifacts` now raise with the reason the runner recorded, not only the status. The message of the error is the platform's `detail`, which names the status and then the report's message. `None` means the run ended with no report, such as a cancelled run. See `docs/run-results.md`. -- **`ApiResponseError` carries the problem document's members**: `request_id` (read from the body, or from the `X-Request-ID` header when the body has none), `type_uri` (the problem's `type`), `title`, `error_domain`, `error_category`, `retryable`, `user_action`, `errors` (the platform's field-level list, typed as `FieldError`) and `problem`, the decoded document whole, for any member the SDK does not name. Branch on `error_domain` and `type_uri`, as the README now says; `code` and `error_type` remain each surface's finer native code. +- **`ApiResponseError` carries the problem document's members**: `request_id` (read from the body, or from the `X-Request-ID` header when the body has none), `type_uri` (the problem's `type`), `title`, `error_domain`, `error_category`, `retryable`, `user_action`, `errors` (the platform's field-level list, typed as `FieldError`) and `problem`, the decoded document whole, for any member the SDK does not name. Branch on `type_uri`, and on `error_domain` where a runner-rendered problem carries it, as the README now says; `code` and `error_type` remain each surface's native code. ### Changed -- **`RunErrorReport` carries every field of the runner's report and moves to `pipelex_sdk.error_models` (Breaking)**: import it from `pipelex_sdk.error_models` instead of `pipelex_sdk.product_models`. Beside `message` and `error_type` it now declares `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `caller_facing_message`, `validation_errors` and `migration`, every one optional and the model open to fields the runner adds, so `PipelineRun.error` in the run lists reads the whole report too. +- **`RunErrorReport` carries every field of the runner's report and moves to `pipelex_sdk.error_models` (Breaking)**: import it from `pipelex_sdk.error_models` instead of `pipelex_sdk.product_models`. Beside `message` and `error_type` it now declares `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `caller_facing_message`, `validation_errors` and `migration`, every one optional, the model open to fields the runner adds, and each field read leniently — a value that does not fit its type reads as `None` rather than failing the status read, the run list or the results read that carries the report — so `PipelineRun.error` in the run lists reads the whole report too. - **A failed run's status comes from the results read's `run_status` member (Breaking)**: `get_run_result` no longer parses the status out of the `409`'s `detail` sentence; it reads the problem document's `run_status` member, and a `409` without a status this SDK knows reads as `FAILED`. ## [v0.12.0] - 2026-09-24 diff --git a/README.md b/README.md index 65bace7..562aa52 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,9 @@ except RunFailedError as exc: Branch on `error_domain` (`input`, `config`, `runtime`), `type_uri` and `retryable`, never on the wording of `message`. The report is the runner's verbose one, so `message` and `provider_metadata` can hold a model provider's raw text: what a person should see of it is your application's decision. The same report is on `RunRead.error` when you read the run's status, on `RunResultFailed.error` from `get_run_result`, and on `PipelineRun.error` in the run lists. -### API errors: branch on `error_domain` and `type_uri`, not the HTTP status +### API errors: branch on `type_uri` and `error_domain`, not the HTTP status -A non-2xx answer raises a typed `ApiResponseError` carrying the members of the RFC 9457 problem document. Two of them are the branch fields, the same on every surface of the hosted API: `error_domain`, the coarse class (`input` means the caller can fix it, `config` that a configuration change is needed, `runtime` that execution failed), and `type_uri`, the problem's `type`, a stable URI naming the error class and present on every problem. `error_domain` is `None` on a problem that carries none, so branch on `type_uri` for one specific condition and on `error_domain` for the class: +A non-2xx answer raises a typed `ApiResponseError` carrying the members of the RFC 9457 problem document. 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: ```python from pipelex_sdk.errors import ApiResponseError @@ -151,14 +151,14 @@ try: except ApiResponseError as exc: if exc.type_uri == "https://pipelex.com/errors/pipelex_api_key_limit_reached": print("Per-account key limit reached — revoke an old key first.") - elif exc.error_domain == "input": + elif exc.type_uri == "https://pipelex.com/errors/validation_failed": print(f"Fix the request: {exc.server_message}") else: print(f"Unexpected failure, request id {exc.request_id}") raise ``` -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`, `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. ## Public import paths (no barrel) diff --git a/docs/architecture.md b/docs/architecture.md index 285a526..546b0af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ The `problem+json` / `HTTPException` error body is parsed by `_parse_error_body` Three regimes, ported faithfully from the TS SDK (the inherited-protocol vs product split is decision #5 — deliberately not unified): -- **Product routes** raise a typed `ApiResponseError` (subclass of `PipelineRequestError`) carrying the members of the RFC 9457 problem document. Consumers branch on `err.error_domain` (`input` / `config` / `runtime`) and `err.type_uri` (the problem's `type`, the stable URI naming the error class), 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. `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`. +- **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`. - **`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. @@ -222,7 +222,7 @@ Beyond those two the verdicts match, including the drift sentences. One differen ## Pipelex product surface (hosted management routes) -The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.error_domain` and `.type_uri`, never the HTTP status**, and read `.code` for the platform's finer native code (see Error regimes above). +The hosted catalog/account routes the webapp drives (`pipelex_sdk/product_models.py` + the client's product methods). Every route rides the same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract as the protocol routes, and goes through `_request_product`, which maps a non-2xx `problem+json` to a typed `ApiResponseError` — **consumers branch on `.type_uri` (and on `.error_domain` where a runner-rendered problem carries it), never the HTTP status**, and read `.code` for the platform's native code (see Error regimes above). The wire models are snake_case Pydantic v2. Response models are extension-open (`extra="allow"`) so a newly-added server field is preserved, not rejected; input models name exactly what each route accepts. `PipelineRun.status` reuses the run-lifecycle `RunStatus`; `OrgRole`, `PipeStatus`, and the onboarding fields are `StrEnum`s. diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index ccc1366..03d84b2 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -51,7 +51,7 @@ ResolveResponse, ResolveResponseAdapter, ) -from pipelex_sdk.error_models import FieldError, RunErrorReport, UserAction +from pipelex_sdk.error_models import FieldError, UserAction from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -1620,20 +1620,9 @@ def _run_result_failed(run_id: str, response: httpx.Response) -> RunResultFailed message = _error_message_of(body) 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 - return RunResultFailed(pipeline_run_id=run_id, status=status, message=message, error=_run_error_report_of(body.get("error"))) - - -def _run_error_report_of(raw: object) -> RunErrorReport | None: - """Type a stored error report, best-effort: this is the failure path, so a report whose known - fields do not fit their types must not mask the failure it explains — `detail` still carries the - report's message, and the run's status read serves the report again. - """ - if not isinstance(raw, dict): - return None - try: - return RunErrorReport.model_validate(raw) - except ValidationError: - return None + # `error` is validated by the field's own lenient type (`LenientRunErrorReport`): a report whose + # known fields do not fit keeps the ones that do, and one that is not a report reads as `None`. + return RunResultFailed.model_validate({"pipeline_run_id": run_id, "status": status, "message": message, "error": body.get("error")}) def _is_valid_base_url(value: str) -> bool: @@ -1700,9 +1689,10 @@ class _ParsedErrorBody(NamedTuple): problem=None, ) -# The structured members below are validated 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. +# 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`. _VALIDATION_ERRORS_ADAPTER: TypeAdapter[list[ValidationErrorItem]] = TypeAdapter(list[ValidationErrorItem]) _FIELD_ERRORS_ADAPTER: TypeAdapter[list[FieldError]] = TypeAdapter(list[FieldError]) @@ -1746,13 +1736,9 @@ def _parse_error_body(body: str) -> _ParsedErrorBody: except ValidationError: errors = None - user_action: UserAction | None = None + # `UserAction` reads each field leniently, so any object validates; a non-object reads as `None`. raw_user_action = root.get("user_action") - if isinstance(raw_user_action, dict): - try: - user_action = UserAction.model_validate(raw_user_action) - except ValidationError: - user_action = None + user_action = UserAction.model_validate(raw_user_action) if isinstance(raw_user_action, dict) else None raw_retryable = root.get("retryable") diff --git a/pipelex_sdk/error_models.py b/pipelex_sdk/error_models.py index b55b482..d459fd0 100644 --- a/pipelex_sdk/error_models.py +++ b/pipelex_sdk/error_models.py @@ -15,6 +15,14 @@ open sets on the wire and stay plain `str`, never frozen enums, so a value the runner adds is not an SDK break; their known values are listed where they are declared. +**A report never fails the read that carries it.** The platform stores the report as the runner +that wrote it sent it and never migrates it, so a row can outlive the runner version it was written +by. Every field of these models is therefore read leniently: a known field whose value does not fit +its type — a validation item with a category this SDK does not know, a `status_code` that is not a +number, a `user_action` that is not an object — reads as `None`, and the rest of the report stands. +`LenientRunErrorReport` extends that to the report as a whole: an `error` that is not an object at +all reads as `None`, so a status read, a run list page or a results read still answers. + **Nothing is stripped.** The platform serves the runner's VERBOSE report, so `message` and `provider_metadata` can hold the provider's raw text. Deciding what of it a person should see is each consumer's presentation, not this SDK's; the report arrives here whole. @@ -22,29 +30,46 @@ from __future__ import annotations -from typing import Any +from typing import Annotated, Any, TypeAlias -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, ValidationError, ValidatorFunctionWrapHandler, WrapValidator, field_validator from pipelex_sdk.validation_models import ValidationErrorItem -class UserAction(BaseModel): +def _none_when_it_does_not_fit(value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + """Validate `value`, or read it as `None` when it does not fit its declared type.""" + try: + return handler(value) + except ValidationError: + return None + + +class _LenientReportPart(BaseModel): + """Base of every model here: extension-open, and a known field that does not fit its type reads as `None`.""" + + model_config = ConfigDict(extra="allow") + + @field_validator("*", mode="wrap") + @classmethod + def _read_a_field_that_does_not_fit_as_none(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + return _none_when_it_does_not_fit(value, handler) + + +class UserAction(_LenientReportPart): """The next step a report advises — the runner's `UserAction`. `kind` names the category of advice, so a consumer can render consistent guidance; `detail` is the free-form, error-specific text (a billing URL, a retry hint, the model to change). """ - model_config = ConfigDict(extra="allow") - #: Known values: `wait_and_retry`, `check_billing`, `check_credentials`, `change_input`, #: `change_model`, `contact_support`, `unknown`. kind: str | None = None detail: str | None = None -class ProviderErrorMetadata(BaseModel): +class ProviderErrorMetadata(_LenientReportPart): """What the inference provider's SDK said about a failed call — the runner's `ProviderErrorMetadata`. Present on a report whose failure came back from a model provider. `message` is the provider @@ -52,8 +77,6 @@ class ProviderErrorMetadata(BaseModel): excludes it from every serialization. """ - model_config = ConfigDict(extra="allow") - provider: str | None = None sdk_exception_type: str | None = None message: str | None = None @@ -65,7 +88,7 @@ class ProviderErrorMetadata(BaseModel): provider_error_code: str | None = None -class MigrationErrorBlock(BaseModel): +class MigrationErrorBlock(_LenientReportPart): """A pending configuration migration that explains the failure — the runner's `MigrationErrorBlock`. Present only on a configuration failure whose raiser scanned the host's configuration @@ -73,8 +96,6 @@ class MigrationErrorBlock(BaseModel): `pipelex-agent migrate --dry-run --format json` emits, which no published package declares. """ - model_config = ConfigDict(extra="allow") - #: The command that applies whatever can be applied without a decision. remedy: str | None = None #: Whether running `remedy` would rewrite any file. @@ -84,7 +105,7 @@ class MigrationErrorBlock(BaseModel): plans: list[dict[str, Any]] | None = None -class RunErrorReport(BaseModel): +class RunErrorReport(_LenientReportPart): """Why a run failed — the runner's `ErrorReport`, typed with every field it carries. The one type for a failed run's report wherever the SDK hands it back: `RunPublic.error` (and so @@ -100,8 +121,6 @@ class RunErrorReport(BaseModel): platform finalized itself — so the absence of a report says nothing about why. """ - model_config = ConfigDict(extra="allow") - #: The runner's exception class name (`LLMCompletionError`, `SandboxProvisioningError`, …) — an #: open set, for display and support, not for branching. error_type: str | None = None @@ -134,15 +153,19 @@ class RunErrorReport(BaseModel): migration: MigrationErrorBlock | None = None -class FieldError(BaseModel): +class FieldError(_LenientReportPart): """One field-level failure of a request, an item of the platform problem document's `errors[]`. `field` is the dotted path to the offending attribute, `code` a stable sub-code (`invalid_format`, `out_of_range`, …), `detail` optional human text. """ - model_config = ConfigDict(extra="allow") - field: str | None = None code: str | None = None detail: str | None = None + + +#: The type of every `error` field that holds a run's report: `RunPublic.error`, `PipelineRun.error` +#: and `RunResultFailed.error`. A report is read field by field as `RunErrorReport` says, and a value +#: that is not a report at all reads as `None`, so the read carrying it always answers. +LenientRunErrorReport: TypeAlias = Annotated[RunErrorReport | None, WrapValidator(_none_when_it_does_not_fit)] diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index 6bda146..bc0b842 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -8,9 +8,9 @@ / 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 `error_domain` and `type_uri` (the - problem's `type`), the surface-native `code` / `error_type`, the request id, and the - rest (decoupled from the HTTP status). + 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). - `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 @@ -79,11 +79,12 @@ class ApiResponseError(PipelineRequestError): 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: - - **The branch fields.** `error_domain` is the coarse class a consumer branches on — `input` (the - caller can fix it), `config` (a configuration change is needed), `runtime` (a failure during - execution) — and `type_uri` (the problem's `type`) is the stable URI naming the error class. - `retryable` says whether a blind retry can succeed, `None` meaning unknown. Branch on these, - never on the HTTP status or on the wording of a message. + - **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 + by the problems the runner renders and `None` on the platform's own, which name their class by + `type_uri` alone. `retryable` says whether a blind retry can succeed, `None` meaning unknown. + Branch on these, never on the HTTP status or on the wording of a message. - **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. diff --git a/pipelex_sdk/product_models.py b/pipelex_sdk/product_models.py index 89a87f9..21b5b29 100644 --- a/pipelex_sdk/product_models.py +++ b/pipelex_sdk/product_models.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_serializer, field_validator from pipelex_sdk._pydantic_utils import empty_list_factory_of -from pipelex_sdk.error_models import RunErrorReport +from pipelex_sdk.error_models import LenientRunErrorReport from pipelex_sdk.runs import RunStatus # ── User profile (`/v1/me`) ───────────────────────────────────────────── @@ -623,7 +623,7 @@ class PipelineRun(BaseModel): workflow_id: str | None = None status: RunStatus result_url: str | None = None - error: RunErrorReport | None = None + error: LenientRunErrorReport = None pipe_statuses: dict[str, PipeStatus] | None = None created_at: str finished_at: str | None = None diff --git a/pipelex_sdk/runs.py b/pipelex_sdk/runs.py index 59b811b..7c67daf 100644 --- a/pipelex_sdk/runs.py +++ b/pipelex_sdk/runs.py @@ -47,7 +47,7 @@ from mthds.runners.api.models import DictPipeOutputAbstract, DictWorkingMemoryAbstract from pydantic import BaseModel, ConfigDict, Field -from pipelex_sdk.error_models import RunErrorReport +from pipelex_sdk.error_models import LenientRunErrorReport if TYPE_CHECKING: from collections.abc import Callable @@ -150,8 +150,9 @@ class RunPublic(BaseModel): finished_at: str | None = None #: Why the run failed — the runner's report as the platform stored it, whole and typed (see #: `pipelex_sdk.error_models`). `None` for a run that has not failed, and for one that ended with - #: no stored report (cancelled, terminated, timed out, or finalized by the platform itself). - error: RunErrorReport | None = None + #: no stored report (cancelled, terminated, timed out, or finalized by the platform itself). Read + #: leniently, so a report written by another runner version never fails the read carrying it. + error: LenientRunErrorReport = None class RunRead(RunPublic): @@ -358,7 +359,7 @@ class RunResultFailed(BaseModel): pipeline_run_id: str status: RunStatus message: str - error: RunErrorReport | None = None + error: LenientRunErrorReport = None RunResultState: TypeAlias = Annotated[ diff --git a/tests/unit/test_api_response_error.py b/tests/unit/test_api_response_error.py index 21c84ec..859d9cd 100644 --- a/tests/unit/test_api_response_error.py +++ b/tests/unit/test_api_response_error.py @@ -120,7 +120,7 @@ def test_body_request_id_wins_over_the_header(self, mocker: MockerFixture) -> No "body", [ {"detail": "x", "retryable": "no", "user_action": "retry later", "errors": "none", "error_domain": 3, "type": None}, - {"detail": "x", "user_action": {"kind": 5}, "errors": [7]}, + {"detail": "x", "user_action": ["retry"], "errors": [7]}, ], ) def test_members_of_the_wrong_shape_read_as_none(self, mocker: MockerFixture, body: dict[str, Any]) -> None: @@ -134,3 +134,13 @@ def test_members_of_the_wrong_shape_read_as_none(self, mocker: MockerFixture, bo assert err.type_uri is None 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}]} + 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." + assert err.errors is not None + assert [(item.field, item.code) for item in err.errors] == [("body.label", None)] diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py index 8279ff7..b8fcc92 100644 --- a/tests/unit/test_client_lifecycle.py +++ b/tests/unit/test_client_lifecycle.py @@ -195,6 +195,34 @@ def test_get_run_status_types_the_stored_report(self, mocker: MockerFixture) -> assert run.error.model_dump(exclude_none=True) == _BUNDLE_FAULT_REPORT assert run.model_extra == {} + def test_get_run_status_with_a_report_from_another_runner_version_still_answers(self, mocker: MockerFixture) -> None: + """A stored report whose known fields drifted keeps what fits: the status read never fails on its report.""" + client = self._client() + drifted: dict[str, Any] = { + "error_type": "ValidateBundleError", + "message": "Pipe 'summarize' is invalid.", + "error_domain": "input", + "retryable": "perhaps", + "user_action": "Fix the bundle.", + "provider_metadata": {"provider": "openai", "status_code": "unknown"}, + "validation_errors": [{"category": "a_category_from_a_newer_runner", "message": "x"}], + } + body = {"pipeline_run_id": "run_1", "status": "FAILED", "created_at": "2026-06-10T00:00:00Z", "error": drifted} + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(200, json=body))) + + run = asyncio.run(client.get_run_status("run_1")) + assert run.status == RunStatus.FAILED + assert run.error is not None + assert run.error.error_type == "ValidateBundleError" + assert run.error.message == "Pipe 'summarize' is invalid." + assert run.error.error_domain == "input" + assert run.error.retryable is None + assert run.error.user_action is None + assert run.error.provider_metadata is not None + assert run.error.provider_metadata.provider == "openai" + assert run.error.provider_metadata.status_code is None + assert run.error.validation_errors is None + def test_get_run_status_without_error_reads_none(self, mocker: MockerFixture) -> None: """A run that has not failed carries no report: `error` is None, whether absent or null.""" client = self._client() @@ -388,16 +416,31 @@ def test_get_run_result_failed_without_a_known_status_member_reads_failed(self, assert state.message == body["detail"] assert state.error is None - def test_get_run_result_failed_with_a_malformed_report_still_fails_with_its_reason(self, mocker: MockerFixture) -> None: - """A report whose known fields do not fit their types reads as None; the failure and its detail survive.""" + def test_get_run_result_failed_with_a_drifted_report_keeps_what_fits(self, mocker: MockerFixture) -> None: + """A report field that does not fit its type reads as None and the rest of the report stands; the failure survives.""" client = self._client() - body = _failed_results_problem("FAILED", {"message": "boom", "validation_errors": "not a list"}) + body = _failed_results_problem("FAILED", {"message": "boom", "error_domain": "runtime", "validation_errors": "not a list"}) mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) state = asyncio.run(client.get_run_result("run_1")) assert isinstance(state, RunResultFailed) assert state.status == RunStatus.FAILED assert state.message == "Run finished with status FAILED: boom" + assert state.error is not None + assert state.error.message == "boom" + assert state.error.error_domain == "runtime" + assert state.error.validation_errors is None + + def test_get_run_result_failed_with_an_error_that_is_not_a_report_reads_none(self, mocker: MockerFixture) -> None: + """An `error` member that is not an object reads as None; the failed arm still answers.""" + client = self._client() + body = _failed_results_problem("FAILED", None) + body["error"] = "the runner crashed" + mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(409, json=body))) + + state = asyncio.run(client.get_run_result("run_1")) + assert isinstance(state, RunResultFailed) + assert state.status == RunStatus.FAILED assert state.error is None def test_get_run_result_lifecycle_unavailable_on_missing_route(self, mocker: MockerFixture) -> None: diff --git a/tests/unit/test_error_models.py b/tests/unit/test_error_models.py index 96475b5..f8061c4 100644 --- a/tests/unit/test_error_models.py +++ b/tests/unit/test_error_models.py @@ -3,6 +3,7 @@ from typing import Any from pipelex_sdk.error_models import RunErrorReport +from pipelex_sdk.product_models import RunPage # A configuration failure carrying the migration block, the one report field the recorded run # responses do not exercise. @@ -58,6 +59,31 @@ def test_enum_like_fields_accept_values_this_version_does_not_know(self) -> None assert report.user_action is not None assert report.user_action.kind == "wait_for_quota" + def test_a_run_list_page_answers_whatever_its_reports_hold(self) -> None: + """One run whose stored report drifted, or is not a report at all, never fails the page it sits on.""" + page = RunPage.model_validate( + { + "items": [ + { + "pipeline_run_id": "run_1", + "status": "FAILED", + "created_at": "2026-06-10T00:00:00Z", + "error": {"message": "boom", "validation_errors": [{"category": "brand_new", "message": "x"}]}, + }, + {"pipeline_run_id": "run_2", "status": "FAILED", "created_at": "2026-06-10T00:00:00Z", "error": "not a report"}, + {"pipeline_run_id": "run_3", "status": "COMPLETED", "created_at": "2026-06-10T00:00:00Z"}, + ], + "next_cursor": None, + } + ) + + first, second, third = page.items + assert first.error is not None + assert first.error.message == "boom" + assert first.error.validation_errors is None + assert second.error is None + assert third.error is None + def test_an_empty_report_parses(self) -> None: report = RunErrorReport.model_validate({}) From f1c2da96afb1d8be91f5940851a19c9be8095e22 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 18:27:57 +0200 Subject: [PATCH 3/4] docs: describe the lenient report reading as it now works The get_run_result bullet still said a drifted report reads as None; since the lenient models, a drifted report keeps the fields that fit and only an error that is not an object reads as None. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FigDssaJrvNcmbnBedi7oq --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 546b0af..3616b53 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,7 +120,7 @@ The durable run lifecycle (`pipelex_sdk/runs.py` + the client's lifecycle method ### Polling surface - **`get_run_status(run_id)`** — `GET /v1/runs/{id}/status` → `RunRead`. Lifts the `Retry-After` header onto `retry_after_seconds`. -- **`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 typed best-effort, because a report whose known fields do not fit their types must not mask the failure: it then reads as `None`, and `message` still carries the reason. +- **`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`. From ffcb5c6e6cefa9f5f63b9de80c87d13c64735932 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sat, 26 Sep 2026 18:38:09 +0200 Subject: [PATCH 4/4] docs: scope ApiResponseError to the product routes, mark RunRead.error breaking The README said every non-2xx answer raises ApiResponseError, while the protocol routes and the run status and results reads still raise httpx.HTTPStatusError; it now names the routes it covers. RunRead.error used to be the raw dict on model_extra and is now the typed report, a break the changelog now records. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FigDssaJrvNcmbnBedi7oq --- CHANGELOG.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a2414..eb735ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,13 @@ ### Added -- **A failed run's error report on `RunFailedError`, `RunResultFailed` and `RunRead`**: `RunFailedError.error`, `RunResultFailed.error` and `RunRead.error` (declared on `RunPublic`) carry the run's stored error report typed as `RunErrorReport`, so `wait_for_result`, `start_and_wait` and `download_artifacts` now raise with the reason the runner recorded, not only the status. The message of the error is the platform's `detail`, which names the status and then the report's message. `None` means the run ended with no report, such as a cancelled run. See `docs/run-results.md`. +- **A failed run's error report on `RunFailedError` and `RunResultFailed`**: `RunFailedError.error` and `RunResultFailed.error` carry the run's stored error report typed as `RunErrorReport`, so `wait_for_result`, `start_and_wait` and `download_artifacts` now raise with the reason the runner recorded, not only the status. The message of the error is the platform's `detail`, which names the status and then the report's message. `None` means the run ended with no report, such as a cancelled run. See `docs/run-results.md`. - **`ApiResponseError` carries the problem document's members**: `request_id` (read from the body, or from the `X-Request-ID` header when the body has none), `type_uri` (the problem's `type`), `title`, `error_domain`, `error_category`, `retryable`, `user_action`, `errors` (the platform's field-level list, typed as `FieldError`) and `problem`, the decoded document whole, for any member the SDK does not name. Branch on `type_uri`, and on `error_domain` where a runner-rendered problem carries it, as the README now says; `code` and `error_type` remain each surface's native code. ### Changed - **`RunErrorReport` carries every field of the runner's report and moves to `pipelex_sdk.error_models` (Breaking)**: import it from `pipelex_sdk.error_models` instead of `pipelex_sdk.product_models`. Beside `message` and `error_type` it now declares `title`, `type_uri`, `error_domain`, `error_category`, `retryable`, `user_action`, `model`, `provider`, `provider_metadata`, `caller_facing_message`, `validation_errors` and `migration`, every one optional, the model open to fields the runner adds, and each field read leniently — a value that does not fit its type reads as `None` rather than failing the status read, the run list or the results read that carries the report — so `PipelineRun.error` in the run lists reads the whole report too. +- **`RunRead.error` is the typed report, no longer a raw dict (Breaking)**: `error` is now declared on `RunPublic`, so the status read's report is a `RunErrorReport` rather than the dict that rode `model_extra`; read `run.error.message` where code read `run.error["message"]` or `run.model_extra["error"]`. - **A failed run's status comes from the results read's `run_status` member (Breaking)**: `get_run_result` no longer parses the status out of the `409`'s `detail` sentence; it reads the problem document's `run_status` member, and a `409` without a status this SDK knows reads as `FAILED`. ## [v0.12.0] - 2026-09-24 diff --git a/README.md b/README.md index 562aa52..1fc42a7 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,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 raises a typed `ApiResponseError` carrying the members of the RFC 9457 problem document. 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: +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: ```python from pipelex_sdk.errors import ApiResponseError