@pipelex/sdk 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/errors.md CHANGED
@@ -1,6 +1,83 @@
1
- # Errors — a failed run's report and a refused request's problem
1
+ # Errors — the verdict every error carries, a failed run's report and a refused request's problem
2
2
 
3
- Two things can go wrong when you call the hosted API, and the SDK hands each one back whole. A **run can fail** after it started: the runner stores why, as its error report, and the SDK gives you that report on the failed run. A **request can be refused**: the server answers a non-2xx RFC 9457 problem document, and the SDK throws an `ApiResponseError` carrying every member of it. This page covers both: which fields each carries, which ones a program branches on, which ones a person reads, and which one support asks for. Either can carry validation items, the structured account of a method refused for what it says, which the [last section](#a-validation-item--validationerroritem) describes.
3
+ Every error the SDK throws says two things a program needs before anything else: whether asking again can succeed, and who can fix the failure. That is the **verdict**, and the [first section](#the-verdict-every-error-carries) describes it. Beyond it, two things can go wrong when you call the hosted API, and the SDK hands each one back whole. A **run can fail** after it started: the runner stores why, as its error report, and the SDK gives you that report on the failed run. A **request can be refused**: the server answers a non-2xx RFC 9457 problem document, and the SDK throws an `ApiResponseError` carrying every member of it. This page covers both: which fields each carries, which ones a program branches on, which ones a person reads, and which one support asks for. Either can carry validation items, the structured account of a method refused for what it says, which the [last section](#a-validation-item--validationerroritem) describes.
4
+
5
+ ## The verdict every error carries
6
+
7
+ Every error class the SDK exports carries `retryable`, a boolean, and `errorDomain`, one of three values, and neither is ever `undefined`: the SDK knows the API, so it decides, and no consumer has to choose its own default. The three domains say who can fix the failure:
8
+
9
+ | `errorDomain` | Who fixes it | Examples |
10
+ |---|---|---|
11
+ | `input` | The caller, by changing the request: the inputs, the method, a reference, an argument. | A malformed bundle, a missing input, a stored method that does not exist. |
12
+ | `config` | Someone changing the environment: the base URL, the credential, the plan, the deployment. | An invalid API key, a plan without credit, a base URL pointing at a bare runner. |
13
+ | `runtime` | Nobody beforehand: a fault during execution or in the service. | A provider outage, a gateway timeout, a run still going when the wait ran out. |
14
+
15
+ Read the pair with `errorVerdictOf`, which takes anything a `catch` holds:
16
+
17
+ ```ts
18
+ import { errorVerdictOf } from "@pipelex/sdk";
19
+
20
+ try {
21
+ await client.startAndWaitForResult({ method_id: "mt_abc123", inputs });
22
+ } catch (err) {
23
+ const verdict = errorVerdictOf(err);
24
+ if (verdict === undefined) throw err; // not an SDK error: a bug, or the caller's own abort
25
+ if (verdict.retryable) return scheduleRetry();
26
+ if (verdict.errorDomain === "input") return askTheUserToFix(err);
27
+ return reportToOperator(err);
28
+ }
29
+ ```
30
+
31
+ `errorVerdictOf` checks the shape rather than the class: it returns the pair from any `Error` carrying a boolean `retryable` and a known `errorDomain`, and `undefined` otherwise. So it reads an error raised by another copy of the SDK installed beside yours, a subclass of your own, and an `mthds` `ApiResponseError` whose runner sent both members. Every class also exposes the two members directly, and they derive from the abstract `PipelexRequestError`, itself a `PipelineRequestError`, so `instanceof PipelineRequestError` still matches all of them.
32
+
33
+ **"Retryable" means a retry can succeed, not that it is safe.** The verdict says whether asking again can plausibly succeed; it says nothing about whether the first attempt had an effect, and it does not depend on the route. A `start` answered with a `500` is retryable by its status, yet it may already have created a run, so a caller that must not start a run twice keeps its own rule for that until the SDK can send an idempotency key.
34
+
35
+ **What carries no verdict.** `errorVerdictOf` returns `undefined` for what is not an SDK failure: the `RangeError` or `TypeError` the SDK raises for an argument of the wrong type or range before any request is sent (a `timeoutMs` out of range, a malformed `appInfo`, an empty artifact selection), which names a bug in the calling code; and the caller's own abort, which the client rethrows untouched. The per-reference errors inside a `downloadArtifacts` verdict are values with a `code`, not thrown errors, and carry none either. An argument the client refuses for what it asks, such as two method selectors given to `validate` or `execute`, or a base URL with a path, is a `RequestArgumentError`, which carries a verdict like every SDK error.
36
+
37
+ ### Each class's verdict
38
+
39
+ | Class | `errorDomain` | `retryable` | Why |
40
+ |---|---|---|---|
41
+ | `ApiResponseError` | the server's, else the fallback | the server's, else no for a sent `input` or `config` domain, else the fallback | See [below](#a-refused-requests-verdict). |
42
+ | `RequestArgumentError` | `input` | no | The client refused a call's arguments before sending any request: no run source given to `execute` or `start`, run sources or method selectors that exclude each other, a selector rule of `validate`, an empty `validateFiles`, a reserved key in `extra`. Its options take an optional `verdict`, and the client declares `config` for a base URL that is not host-only, since it typically comes from `PIPELEX_BASE_URL`. It is a `PipelineRequestError`, as the bare refusal it replaced was, and a refusal the standard's own run-source check raises keeps that original as `cause`. |
43
+ | `ApiUnreachableError` | `config` | yes | The address or the network must be checked, and a later attempt can get through. With the code `ABORT_TIMEOUT`, the SDK's own request timeout, it is `runtime`: the API took the request and did not answer in time. |
44
+ | `PipelineExecuteTimeoutError` | `input` | no | The blocking `execute` cannot outlast the gateway's limit; start the run and poll instead. |
45
+ | `RunFailedError` | the report's | the report's | See [a failed run](#a-failed-run--runerrorreport). |
46
+ | `RunTimeoutError`, `RunStillRunningError` | `runtime` | yes | The run is still going; waiting again, by its id, can succeed. |
47
+ | `RunLifecycleUnavailableError` | `config` | no | The base URL points at a bare runner without the run lifecycle. |
48
+ | `MissingMainStuffError` | `runtime` | no | The API broke its own contract on a completed run. |
49
+ | `PagingNotTerminatingError` | `runtime` | no | A server minting cursors forever; `pageLimit` is the ceiling `iterateMethods` stopped at. |
50
+ | `InputPreparationError` and `EmptyMethodSourceError`, `InvalidLocalSourceError`, `RejectedAssetError` | `input` | no | The asset, the inputs or the method selector must change. The family's base takes an optional `verdict` in its options, so a subclass of your own can declare another. |
51
+ | `UnsupportedUploadCapabilityError`, `UploadAuthenticationError` | `config` | no | The deployment or the credential must change. |
52
+ | `UploadTransportError` | the cause's, else by `code` | the cause's, else by `code` | `uploadFile` wraps the `ApiResponseError` or `ApiUnreachableError` the client threw, and takes its verdict, so a wrapped `402` is `config` and not retryable. Without such a cause, which is always the case from `uploadWithGrant`: `timeout`, `storage_timeout` and `conflict` are `runtime` and retryable, `conflict` because storage documents its `409 ConditionalRequestConflict` as retryable and the grant is not spent by it (a spent grant is the `412` of a `RejectedAssetError` with code `grant_used`); `server_error` is `runtime`, and retryable when [the fallback table](#a-refused-requests-verdict) would call its `status` retryable, any `5xx` but the `501` storage answers for a request it does not implement, or when it carries no status; `unexpected` is `runtime`, and retryable when that table would call its `status` retryable, which is the `408` or `429` storage answers when it times out or throttles the request rather than refusing the file, and not retryable for any other status or none; `unreachable` is `config` and retryable; `redirected` is `config` and not retryable; `invalid_grant_url` is `runtime` and not retryable. |
53
+ | `ArtifactOperationError`, `ScopeUnavailableError` | `runtime` | no | The family's base also takes an optional `verdict`: the SDK declares `input` where it refuses an argument (a `scope`, a bound, a location) and `config` where the environment refuses the download (a runtime with no filesystem, a directory that cannot be created). |
54
+ | `ArtifactAuthenticationError` | `config` | no | The credential must change. Its `verdict` member is the download's result so far, not the error's. |
55
+ | `ArtifactFetchError` | by `code` | by `code` | `invalid_storage_uri`, `forbidden`, `unsupported_url`, `not_found` and `too_large` are `input` and not retryable; `plain_http_refused` is `config` and not retryable; `redirect_refused` and `store_refused` are `runtime` and not retryable; `timeout` and `network` are `runtime` and retryable; `store_error` is `runtime`, and retryable when [the fallback table](#a-refused-requests-verdict) would call its `status` retryable: a `408`, a `429`, or a `5xx` other than `501`. |
56
+ | `CodegenLockError` | `input` | no | The lock is the caller's own file. It extends `Error`, since nothing was requested over the wire, and declares the two members itself. |
57
+
58
+ ### A refused request's verdict
59
+
60
+ An `ApiResponseError` takes each member the server sent validly: `retryable` is the problem document's `retryable` when it is a boolean, and `errorDomain` is its `error_domain` when it is one of the three domains. A member the server did not send, or sent with the wrong type, or (for the domain) outside the three, is absent, and an absent domain takes the fallback's. An absent `retryable` depends on the domain the server sent, since a runner often sends `error_domain` without `retryable`: a sent `input` or `config` domain is not retryable, as a stored report that says nothing is not, because the caller or the environment must change before asking again can succeed; a sent `runtime` domain, or no valid domain at all, takes the fallback's `retryable`. A `retryable` the server sent always wins, whatever the domain.
61
+
62
+ The fallback reads the HTTP status, the platform's `code`, and whether the body is **named**: it carries a platform `code` or a runner `error_type`. A named `404` is something the caller asked for that does not exist; a bare one is a route the deployment does not serve.
63
+
64
+ | Status | `errorDomain` | `retryable` | Why |
65
+ |---|---|---|---|
66
+ | `400`, `413`, `422`, and any `4xx` not listed here | `input` | no | The request itself was refused. |
67
+ | `401`, `402`, `403` | `config` | no | The credential, the plan or the access must change. |
68
+ | `404`, named | `input` | no | The caller named something that does not exist. |
69
+ | `404`, bare | `config` | no | The base URL points at a deployment without the route. |
70
+ | `405` | `config` | no | The deployment does not serve this method on the route. |
71
+ | `408`, `429` | `runtime` | yes | Refused for its timing, not its content. |
72
+ | `409` | `input` | no | The request conflicts with the stored state: a method being deleted, a stale update, a taken label. |
73
+ | `409` with code `pipelex_api_key_limit_reached` | `config` | no | The organization holds as many keys as it may; someone removes one. |
74
+ | `501` | `config` | no | The deployment does not implement it, and asking again will not change that. |
75
+ | `500`, `502`, `503`, `504`, and any `5xx` not listed here | `runtime` | yes | A fault in the service, which may pass. |
76
+ | Anything else | `runtime` | no | A status no refusal carries, such as the `2xx` of an answer the SDK could not read; nothing says a retry helps. |
77
+
78
+ On the platform today the fallback decides both members, since the platform does not send them on its own refusals yet; a runner's problem usually carries its own. **Nothing on the error says which of the two a value came from**: `problemDocument` keeps the document whole, so a reader that must tell a server-sent verdict from a derived one reads it there.
79
+
80
+ **The verdicts are a contract with the Python SDK.** `pipelex-sdk` must apply the same fallback and declare the same verdict on each class it shares with this package. `tests/fixtures/error-verdicts.json` holds every case the two must agree on, the fallback's rows, the server-sent resolution and each class's variants, and this package's test suite drives its own code through it. The Python package is to keep a byte for byte identical copy of the file and drive its own code through the same cases, with a check at the repository's root holding the two copies identical; until that copy is added, nothing holds the Python SDK to the table yet.
4
81
 
5
82
  The shapes are not this SDK's invention. The report is the runner's `ErrorReport` (in `pipelex`), stored and served by the platform exactly as the runner wrote it, and the problem members are the ones the workspace's hosted-envelope spec names. The types live in `src/error-models.ts`; `pipelex-sdk` (Python) carries the same fields under snake_case names.
6
83
 
@@ -8,7 +85,7 @@ The shapes are not this SDK's invention. The report is the runner's `ErrorReport
8
85
 
9
86
  A run that ends without completing (`FAILED`, `CANCELLED`, `TERMINATED` or `TIMED_OUT`) has no result, and the results read answers `409`. The body of that `409` says why: its `detail` names the status and then the report's message, its `run_status` member holds the status, and its `error` member holds the run's stored error report, or `null` when the run has none. The SDK reads the status from `run_status`, falling back to the status word of the `detail` sentence on a platform that does not send the member yet, and types the report as `RunErrorReport`.
10
87
 
11
- The report reaches you in four places, always the same object:
88
+ The report reaches you wherever a failed run surfaces, always the same object:
12
89
 
13
90
  | Where | What you get |
14
91
  |---|---|
@@ -20,6 +97,8 @@ The report reaches you in four places, always the same object:
20
97
 
21
98
  **A bare runner has no durable run, so its failure is a refused request.** Against a bare `pipelex-api` runner, `startAndWaitForResult` runs the method with the blocking `execute`, and a run that fails there answers a non-2xx problem document: the call throws an `ApiResponseError`, not a `RunFailedError`, and the same classification rides its members (`errorDomain`, `type`, `retryable`, `userAction`, `errorType`, `model`, …) as described [below](#a-refused-request--apiresponseerror). A caller that must work against both catches both, as the example does.
22
99
 
100
+ **`RunFailedError`'s verdict comes from the report.** Its `errorDomain` is the report's `error_domain` when it is one of the three domains, and `runtime` otherwise, including a run with no report. Its `retryable` is true only when the report's `retryable` is `true`: a report that says nothing, and a run with none, read as not retryable, since starting the run again spends credit and nothing says it would succeed. The report keeps its own `retryable` as written, so a consumer that words the unknown differently still can.
101
+
23
102
  ```ts
24
103
  import { ApiResponseError, RunFailedError } from "@pipelex/sdk";
25
104
 
@@ -31,7 +110,7 @@ try {
31
110
  const report = err.error; // RunErrorReport | null
32
111
  console.error(err.message); // "Run finished with status FAILED: <the report's message>"
33
112
  if (report?.user_action) console.error(`Next step: ${report.user_action.detail}`);
34
- if (report?.retryable) console.error("A retry can succeed.");
113
+ if (err.retryable) console.error("Running it again can succeed.");
35
114
  console.error(`Run id for support: ${err.runId}`);
36
115
  } else if (err instanceof ApiResponseError) {
37
116
  // A refused request — or, against a bare runner, a run that failed on the blocking path.
@@ -62,6 +141,8 @@ The report's fields, all optional because the runner owns the shape:
62
141
 
63
142
  A field a newer runner adds is reachable through the interface's index signature before this SDK names it.
64
143
 
144
+ **The report is checked field by field, never cast.** Every read that hands one back applies the same check: a value that is not an object reads as no report (`null`); each named field is kept when it has the type the table above gives it and is absent otherwise, so a `message` that is a number or a `retryable` of `"no"` never reaches you as typed; `null` is kept wherever the type allows it, and an empty string is kept, since deciding that a blank field says nothing is presentation. `provider_metadata` and `migration` are checked one level down, `validation_errors` keeps only the items that are objects with a string `category` and `message`, and members the SDK does not name, in the report or in a nested object, are relayed as sent. **`user_action` is kept whole or not at all**: an object whose `kind` and `detail` are both strings, which is what its type promises. This is the one place the check differs from the Python SDK's, which keeps a partial action, its stored-report action being a lenient type of its own. On the run records, the check runs only when the record carries the `error` key, so a server that does not serve it still leaves it absent.
145
+
65
146
  **The report is `null` when the run has none.** A run the platform finalized itself carries no report: one whose start failed, and a timeout, a termination or a vanished workflow resolved by the platform. A cancelled run usually has none either. The status and the `detail` sentence still say what happened, and the absence of a report says nothing about why.
66
147
 
67
148
  **Nothing is stripped, so presentation is yours.** The platform serves the runner's VERBOSE report, so `message` and `provider_metadata.message` can hold a provider's raw text. The SDK types what the platform sends; deciding what a person sees is the consumer's decision. `RunFailedError.message`, being the `detail`, already contains the report's message.
@@ -70,13 +151,15 @@ A field a newer runner adds is reachable through the interface's index signature
70
151
 
71
152
  ## A refused request — `ApiResponseError`
72
153
 
73
- Every non-2xx answer from a `/v1` route is a problem document, and the SDK throws an `ApiResponseError` whose fields are its members. Each field is `undefined` when the document did not carry it, and a member of the wrong type (a numeric `type`, a `retryable` of `"no"`, a `user_action` without a `detail`) reads as absent rather than as a wrong value.
154
+ Every non-2xx answer from a `/v1` route is a problem document, and the SDK throws an `ApiResponseError` whose fields are its members. Each field is `undefined` when the document did not carry it, and a member of the wrong type (a numeric `type`, a `retryable` of `"no"`, a `user_action` without a `detail`) reads as absent rather than as a wrong value. The nested members are checked one level down, exactly as a [stored report's](#a-failed-run--runerrorreport) are: `providerMetadata` and `migration` keep each named field only when it has its type, `validationErrors` keeps only the items that are objects with a string `category` and `message`, and each item of `errors` keeps its `field`, `code` and `detail` only when they are strings; members the SDK does not name are relayed as sent. The verdict is the exception: `errorDomain` and `retryable` are always decided, the server's value when it sent a valid one and otherwise [as the verdict section says](#a-refused-requests-verdict).
155
+
156
+ **An answer the SDK could not read is an `ApiResponseError` too.** A `2xx` whose body is not JSON, such as a gateway's HTML page or an empty body where the route answers one, is no result the SDK can hand back, so every route throws an `ApiResponseError` for it instead of letting the runtime's `SyntaxError` escape. So is a `2xx` whose body is JSON but not the object the route answers — `null`, a number, a string, an array — on every protocol, extension and run-lifecycle route, on `health()`, and on the product routes whose answer the SDK or its caller reads before anything else (`listMethods`, `getMethod`, `createMethod`, `updateMethod`, `listRuns`, `getRunDetail`, `upload`, `requestUploadGrant`, `resolveStorageUrls`), instead of the `TypeError` that reading it would raise. So is an object missing the member those reads depend on: a page of `listMethods` or `listRuns` whose `items` is not an array or whose `next_cursor` is neither a string nor `null`, which `iterateMethods` and `iterateRuns` could not walk or would follow forever; an `upload` answer with no non-empty string `uri`, which names no stored file; and a stored method whose `mthds` field is neither a string nor absent (a `null` one is still no source, which `getMethodClosure` reports as `EmptyMethodSourceError`) or whose `python` field is not the serialized `[{ name, content }]` list, as `getMethod`, `createMethod` and `updateMethod` answer it: that is server data no change to the call fixes, not an argument the caller passed. Its `status` is the `2xx`, `responseBody` the raw text, `cause` what made the answer unreadable (the parse failure, or `mthds`'s refusal of the `python` field; nothing for a body that parsed to something the route does not answer), `problemDocument` and the problem members are `undefined`, and its verdict is the fallback's for a status no refusal carries: `runtime`, not retryable. A product route that answers a `2xx` with no body at all, such as `revokePipelexApiKey`, still resolves to `undefined`, and the other product routes hand their decoded body back as it came.
74
157
 
75
158
  | Field | Wire member | Meaning |
76
159
  |---|---|---|
77
- | `errorDomain` | `error_domain` | Who can fix it: `input`, `config` or `runtime`. **A branch field.** |
160
+ | `errorDomain` | `error_domain`, else the fallback | Who can fix it: `input`, `config` or `runtime`. Always decided. **A branch field.** |
78
161
  | `type` | `type` | The stable URI naming the error class. **A branch field.** |
79
- | `retryable` | `retryable` | Whether retrying the same request can succeed; `undefined` means unknown. |
162
+ | `retryable` | `retryable`, else `false` beside a sent `input` or `config` domain, else the fallback | Whether retrying the same request can succeed. Always decided. |
80
163
  | `userAction` | `user_action` | The next step, `{ kind, detail }`. |
81
164
  | `requestId` | `request_id`, else the `X-Request-ID` header | The id to hand to support: it finds the server's log lines for the request. |
82
165
  | `title` | `title` | The stable human label of the error class. |
@@ -88,10 +171,10 @@ Every non-2xx answer from a `/v1` route is a problem document, and the SDK throw
88
171
  | `migration` | `migration` | A pending configuration migration that explains the failure. |
89
172
  | `validationErrors` | `validation_errors` | The structured diagnostics of a bundle that failed validation, as [validation items](#a-validation-item--validationerroritem). |
90
173
  | `errors` | `errors` | The platform's field-level failures, `{ field, code, detail }` each. |
91
- | `problemDocument` | the whole body | The decoded document, every member named or not; `undefined` when the body was not a JSON object. |
174
+ | `problemDocument` | the whole body | The decoded document, every member named or not; `undefined` when the body was not a JSON object. The record of what the server sent, verdict members included. |
92
175
  | `status`, `statusText`, `responseBody` | the transport | The HTTP status, its text and the raw body. |
93
176
 
94
- **Branch on `errorDomain` and `type`, never on the HTTP status or the message.** That is the rule the hosted-envelope spec sets for every surface: `errorDomain` tells the caller's own mistake from a fault it cannot fix, and `type` names the error class with a URI that stays the same on every occurrence. `code` and `errorType` are each surface's finer native code, and stay available for logs and support. On the platform today `errorDomain` is not emitted yet, and `type` is `https://pipelex.com/errors/<code>`, so branching on `type` there is branching on the code:
177
+ **Branch on `errorDomain` and `type`, never on the HTTP status or the message.** That is the rule the hosted-envelope spec sets for every surface: `errorDomain` tells the caller's own mistake from a fault it cannot fix, and `type` names the error class with a URI that stays the same on every occurrence. `code` and `errorType` are each surface's finer native code, and stay available for logs and support. On the platform today `error_domain` is not emitted yet, so the fallback reads `errorDomain` from the status, and `type` is `https://pipelex.com/errors/<code>`, so branching on `type` there is branching on the code:
95
178
 
96
179
  ```ts
97
180
  import { ApiResponseError } from "@pipelex/sdk";
@@ -110,7 +193,7 @@ try {
110
193
  }
111
194
  ```
112
195
 
113
- The members `mthds`'s own `ApiResponseError` carries (`type`, `title`, `instance`, `requestId`, `errorDomain`, `retryable`, `userAction`) have the same names and types here, and a type-level test pins them to the standard client's declarations, so a consumer reads one vocabulary whichever client raised the error. The rest are the Pipelex members the standard's client leaves to this SDK.
196
+ The members `mthds`'s own `ApiResponseError` carries (`type`, `title`, `instance`, `requestId`, `errorDomain`, `retryable`, `userAction`) have the same names here, and the same types except the verdict: the standard's client leaves `errorDomain` an open string and `retryable` optional, where this SDK decides both. A type-level test pins `ProblemDetails`, the members as parsed, to the standard client's declaration, so a consumer reads one vocabulary whichever client raised the error. The rest are the Pipelex members the standard's client leaves to this SDK.
114
197
 
115
198
  ## A validation item — `ValidationErrorItem`
116
199
 
@@ -27,10 +27,10 @@ The returned **upload record** guarantees, beyond the source identity:
27
27
 
28
28
  | Field | Guarantee |
29
29
  | --- | --- |
30
- | `uri` | The `pipelex-storage://` reference for the uploaded asset. |
30
+ | `uri` | The `pipelex-storage://` reference for the uploaded asset. An upload answer without a non-empty string `uri` names no stored file, so it is a transport failure with code `unexpected`, never a record. |
31
31
  | `contentType` (MIME) | Known client-side at upload time. |
32
32
  | `size` (bytes) | Known client-side at upload time. |
33
- | `filename` | Already in the wire model. |
33
+ | `filename` | Already in the wire model; the name the asset was sent under when the answer names none. |
34
34
  | checksum | **Not present.** Within-preparation dedup relies on source identity, not hashing; cross-preparation dedup is a hosted storage-policy concern (Phase 5). |
35
35
 
36
36
  The MIME type and size are known client-side, so the record is assembled without extending the `/v1/upload` response.
@@ -50,7 +50,7 @@ const grant = await client.requestUploadGrant({
50
50
  // { uri, url, headers, expires_at, max_bytes }
51
51
  ```
52
52
 
53
- The file is described, never sent. The grant (`UploadGrant`) is a presigned, create-only `PUT` for one new object under the caller's organization: `url` is where to send it, `headers` are the signed headers to send unchanged, `uri` is the `pipelex-storage://` reference the object will carry, and `expires_at` is when storage stops accepting it, a few minutes later. The wire is snake_case like every product route, so the grant can travel to the page as JSON without a remap. A declared `size` over the limit is a `413` `ApiResponseError` whose `code` is `payload_too_large`, and a deployment without the route answers a `404`.
53
+ The file is described, never sent. The grant (`UploadGrant`) is a presigned, create-only `PUT` for one new object under the caller's organization: `url` is where to send it, `headers` are the signed headers to send unchanged, `uri` is the `pipelex-storage://` reference the object will carry, and `expires_at` is when storage stops accepting it, a few minutes later. The wire is snake_case like every product route, so the grant can travel to the page as JSON without a remap. A declared `size` over the limit is a `413` `ApiResponseError` whose `code` is `payload_too_large`, a deployment without the route answers a `404`, and a `2xx` whose body is not a JSON object is an `ApiResponseError` the SDK cannot read, `runtime` and not retryable, rather than a grant.
54
54
 
55
55
  **The grant is a bearer capability.** Until it expires, whoever holds it can write that one object, so the SDK never logs it, and its caller should not either. The route never replays a grant for a repeated request: a caller that lost one asks for a new one.
56
56
 
@@ -79,14 +79,16 @@ It sends the file as the raw body of a `PUT` to `grant.url`, with `grant.headers
79
79
  | `403 AccessDenied`, headers "not signed" | `RejectedAssetError` | `unsigned_header` | The request carried a storage header the grant did not sign. |
80
80
  | `400 RequestTimeout` | `UploadTransportError` | `storage_timeout` | Storage stopped waiting for the body and wrote nothing, so the same grant can be retried while it is valid. |
81
81
  | `409 ConditionalRequestConflict` | `UploadTransportError` | `conflict` | Another upload with the same grant was still in progress. Whether the object was written is unknown, so the same-grant retry applies. A grant is meant to be sent once at a time. |
82
+ | `408`, `429` | `UploadTransportError` | `unexpected` | Storage timed out or throttled the request rather than refusing the file, so the error is retryable: a later attempt can succeed, and the same-grant retry applies. |
82
83
  | any other `4xx` | `RejectedAssetError` | `store_refused` | Storage's own message is relayed. |
83
- | `5xx` | `UploadTransportError` | `server_error` | Whether the object was written is unknown: retrying with the same grant either stores it or answers the `412` of a used grant. |
84
+ | `501` | `UploadTransportError` | `server_error` | Storage does not implement the request it was sent, so it wrote nothing, and a retry meets the same answer: the error is not retryable. |
85
+ | any other `5xx` | `UploadTransportError` | `server_error` | Whether the object was written is unknown: retrying with the same grant either stores it or answers the `412` of a used grant. |
84
86
  | a redirect | `UploadTransportError` | `redirected` | Refused, not followed. |
85
87
  | no answer within the time limit | `UploadTransportError` | `timeout` | The limit above ran out before storage answered. Whether the object was written is unknown, so the same-grant retry applies. |
86
88
  | no response | `UploadTransportError` | `unreachable` | Storage unreachable. In a browser a refused cross-origin request looks exactly like this, so the message also points at the page's CSP `connect-src`. If the connection failed after the file went out, the object may have been written, so the message gives the same-grant retry too. |
87
89
  | nothing sent | `UploadTransportError` | `invalid_grant_url` | The grant's `url` is not an absolute `http(s)` URL free of user info, so it is refused before any request. A browser would resolve a relative URL against the page and send the credential there. |
88
90
 
89
- `RejectedAssetError.code` and `UploadTransportError.code` are what a caller branches on — to request a new grant on `grant_used` or `grant_expired`, to retry with the same grant on `timeout` or `server_error`, or to tell a timeout from an unreachable host — rather than the message, the status or the error's `name`, since an expired grant and an unsigned header both answer `403 AccessDenied` and only S3's message tells them apart. The SDK sets `code` on every error it raises. `RejectedAssetError.status` is storage's, and its `filename` is the `File`'s name, or the object name the grant's `uri` ends in for a nameless `Blob`. `UploadTransportError.status` is storage's status when storage answered, and undefined when it did not. Neither error wraps storage's response, and only S3's error code and message reach a message: its error body can echo the signed request, credential included, so it is never kept, and only its first 16 KiB are read, which is where S3 writes its code and message. **No error carries the grant's URL.** The runtime's network error can: Node and Chrome name the whole URL when they cannot build a request from it, and Bun puts it on the error's `path`. So an unreachable storage is described by its origin and by the error names and codes along the cause chain (`TypeError, caused by Error ENOTFOUND`), and the runtime's error is neither relayed nor kept as `cause`. A caller's abort propagates as the signal's own reason, unwrapped, including one that lands while storage's error body is still arriving.
91
+ `RejectedAssetError.code` and `UploadTransportError.code` are what a caller branches on — to request a new grant on `grant_used` or `grant_expired`, to retry with the same grant on `timeout`, or on `server_error` or `unexpected` when the error is `retryable` (a `501` is not), or to tell a timeout from an unreachable host — rather than the message, the status or the error's `name`, since an expired grant and an unsigned header both answer `403 AccessDenied` and only S3's message tells them apart. The SDK sets `code` on every error it raises. `RejectedAssetError.status` is storage's, and its `filename` is the `File`'s name, or the object name the grant's `uri` ends in for a nameless `Blob`. `UploadTransportError.status` is storage's status when storage answered, and undefined when it did not. Neither error wraps storage's response, and only S3's error code and message reach a message: its error body can echo the signed request, credential included, so it is never kept, and only its first 16 KiB are read, which is where S3 writes its code and message. **No error carries the grant's URL.** The runtime's network error can: Node and Chrome name the whole URL when they cannot build a request from it, and Bun puts it on the error's `path`. So an unreachable storage is described by its origin and by the error names and codes along the cause chain (`TypeError, caused by Error ENOTFOUND`), and the runtime's error is neither relayed nor kept as `cause`. A caller's abort propagates as the signal's own reason, unwrapped, including one that lands while storage's error body is still arriving.
90
92
 
91
93
  ### `prepareInputs` — signature-driven input preparation
92
94
 
@@ -220,16 +222,18 @@ Upload is a **hosted Pipelex-product capability**, even though the SDK can be po
220
222
 
221
223
  The contract distinguishes these semantic outcomes, each a typed subclass of `InputPreparationError` (catch the base to handle any preparation failure, a subclass to branch on category):
222
224
 
223
- - **an unresolvable signature** (`InputPreparationError`) — a bare or alias-qualified `pipe_ref`, refused before any request; a closure that does not load (`is_valid: false`, carrying the first error's message); or a pipe selection the route refused (its `422` typed `EntryPipeNotFoundError` or `EntryPipeAmbiguousError`, carrying the server's detail). Any other failure from `/v1/pipe-io` — a malformed selector, an over-limit file, an unknown or foreign-org `method_id`, no package at the address or one with no `.mthds` file, auth, a server fault, a deployment that does not serve the route — stays an `ApiResponseError` and propagates unchanged;
225
+ - **an unresolvable signature** (`InputPreparationError`) — a bare or alias-qualified `pipe_ref`, refused before any request; a closure that does not load (`is_valid: false`, carrying the first validation item's message, else the answer's own `message`); or a pipe selection the route refused (its `422` typed `EntryPipeNotFoundError` or `EntryPipeAmbiguousError`, carrying the server's detail). Any other failure from `/v1/pipe-io` — a malformed selector, an over-limit file, an unknown or foreign-org `method_id`, no package at the address or one with no `.mthds` file, auth, a server fault, a deployment that does not serve the route — stays an `ApiResponseError` and propagates unchanged;
224
226
  - **empty method source** (`EmptyMethodSourceError`, carries `methodId`) — `getMethodClosure` found the stored method but its `mthds` source parses to nothing (the row exists, no runnable source yet). Distinct from the `getMethod` `404` for an unknown/foreign id, which stays an `ApiResponseError`. `prepareInputs` never raises it: it hands the id to the server, which answers a sourceless method with a `422`;
225
227
  - **invalid local source** (`InvalidLocalSourceError`) — missing, unreadable, or a path string outside Node;
226
228
  - **rejected asset** (`RejectedAssetError`) — the server refused it (e.g. a `413` past the service-defined size cap — see "Storage policy" — surfaced as a clear rejection with `code` `too_large`, not a raw transport error), or storage refused an upload with a grant (a used or expired grant, or a file that differs from what the grant signed — see the table above for each `code`);
227
229
  - **unsupported server capability** (`UnsupportedUploadCapabilityError`) — the configured deployment has no upload route;
228
230
  - **authentication / authorization failure** (`UploadAuthenticationError`) — `401` / `403`;
229
- - **transport failure** (`UploadTransportError`) — a network or server fault, or any other unexpected upload failure; its `status` is the HTTP status when a response produced it, and its `code` says which failure it was. From `uploadFile` the code is `server_error` for a `5xx`, `timeout` when the client's own request timeout ran out, `unreachable` when no response came back, and `unexpected` for any other status or failure; `uploadWithGrant`'s codes are in the table above.
231
+ - **transport failure** (`UploadTransportError`) — a network or server fault, or any other unexpected upload failure; its `status` is the HTTP status when a response produced it, and its `code` says which failure it was. From `uploadFile` the code is `server_error` for a `5xx`, `timeout` when the client's own request timeout ran out, `unreachable` when no response came back, and `unexpected` for any other status or failure, a `2xx` answer the SDK cannot read included, such as one with no string `uri`; `uploadWithGrant`'s codes are in the table above.
230
232
 
231
233
  A malformed data URL — no comma, bad base64, bad percent-encoding — is a plain `InputPreparationError`, raised while the asset is read and before anything is uploaded.
232
234
 
235
+ Each of these carries the verdict every SDK error does, `retryable` and `errorDomain` ([errors.md](./errors.md#each-classs-verdict)): the family's own is `input` and not retryable, an unsupported deployment and a refused credential are `config`, and a transport failure takes the verdict of the error it wraps, so a `402` plan refusal wrapped by `uploadFile` is `config` and not retryable, or, wrapping none, the verdict its `code` gives (`timeout`, `storage_timeout` and `conflict` retryable, `server_error` retryable unless storage answered a `501`, `unreachable` and `redirected` the environment's). A pipe I/O answer whose `is_valid` is not a boolean, or whose `input_form` does not describe the pipe it selected, is a plain `InputPreparationError` with a `runtime` verdict, since the route broke its own contract.
236
+
233
237
  All preparation failures are raised **before any run is created**.
234
238
 
235
239
  ## Storage policy (inherited, Phase 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelex/sdk",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "TypeScript SDK for the Pipelex hosted API — execute methods, manage runs, and call the product surface.",
5
5
  "license": "MIT",
6
6
  "repository": {