pipelex-api 0.71.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. pipelex_api/__init__.py +0 -0
  2. pipelex_api/api.toml +21 -0
  3. pipelex_api/api_config.py +144 -0
  4. pipelex_api/bundle.py +243 -0
  5. pipelex_api/disclosure.py +43 -0
  6. pipelex_api/error_types.py +80 -0
  7. pipelex_api/error_uri.py +45 -0
  8. pipelex_api/errors.py +130 -0
  9. pipelex_api/exception_handlers.py +693 -0
  10. pipelex_api/json_body.py +182 -0
  11. pipelex_api/limits.py +67 -0
  12. pipelex_api/main.py +221 -0
  13. pipelex_api/method_cache.py +241 -0
  14. pipelex_api/method_source.py +215 -0
  15. pipelex_api/middleware.py +209 -0
  16. pipelex_api/openapi_responses.py +186 -0
  17. pipelex_api/openapi_schema.py +83 -0
  18. pipelex_api/problem_document.py +134 -0
  19. pipelex_api/py.typed +0 -0
  20. pipelex_api/routes/__init__.py +23 -0
  21. pipelex_api/routes/health.py +24 -0
  22. pipelex_api/routes/pipelex/__init__.py +21 -0
  23. pipelex_api/routes/pipelex/agent/__init__.py +11 -0
  24. pipelex_api/routes/pipelex/agent/concept.py +60 -0
  25. pipelex_api/routes/pipelex/agent/models.py +49 -0
  26. pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
  27. pipelex_api/routes/pipelex/build/__init__.py +11 -0
  28. pipelex_api/routes/pipelex/build/inputs.py +192 -0
  29. pipelex_api/routes/pipelex/build/output.py +163 -0
  30. pipelex_api/routes/pipelex/build/runner.py +236 -0
  31. pipelex_api/routes/pipelex/codegen.py +164 -0
  32. pipelex_api/routes/pipelex/crate_ops.py +331 -0
  33. pipelex_api/routes/pipelex/pipe_io.py +186 -0
  34. pipelex_api/routes/pipelex/pipeline.py +938 -0
  35. pipelex_api/routes/pipelex/resolve.py +81 -0
  36. pipelex_api/routes/pipelex/tools.py +111 -0
  37. pipelex_api/routes/pipelex/utils.py +6 -0
  38. pipelex_api/routes/pipelex/validate.py +473 -0
  39. pipelex_api/routes/version.py +51 -0
  40. pipelex_api/schemas/__init__.py +0 -0
  41. pipelex_api/schemas/models.py +653 -0
  42. pipelex_api/security.py +284 -0
  43. pipelex_api-0.71.0.dist-info/METADATA +188 -0
  44. pipelex_api-0.71.0.dist-info/RECORD +46 -0
  45. pipelex_api-0.71.0.dist-info/WHEEL +4 -0
  46. pipelex_api-0.71.0.dist-info/licenses/LICENSE +95 -0
@@ -0,0 +1,693 @@
1
+ """Global FastAPI exception handlers that translate any failure into RFC 7807.
2
+
3
+ The single place the API renders a failure into an HTTP response. Every
4
+ handler produces the same `application/problem+json` shape: an `ApiError`
5
+ (raised by the `pipelex_api.errors` 4xx/5xx helpers) carries a pre-built problem
6
+ document; a FastAPI `RequestValidationError` from automatic request
7
+ validation is rendered into one; every `PipelexError` is turned into a
8
+ problem document built from its `ErrorReport`; each orchestrator plugin's
9
+ transport-fault mapper (contributed through the plugin SPI's
10
+ `add_http_error_mapper`, discovered at app construction) gets its own handler
11
+ rendering the `ErrorReport` it produces; everything else collapses to a
12
+ catch-all 500 that discloses under VERBOSE and is sanitized under STRICT, like
13
+ every other response here. The base names no orchestrator and imports no orchestrator SDK
14
+ — the Temporal transport classification that used to live here is now owned by
15
+ the `pipelex-temporal` plugin and reaches us only as a mapper. Routes
16
+ therefore no longer need to catch and shape errors themselves.
17
+
18
+ This module is deliberately import-side-effect-free — no env var reads, no
19
+ app construction. `pipelex_api/main.py` calls `register_exception_handlers(app,
20
+ disclosure_mode=...)` at startup with the already-resolved disclosure mode
21
+ (its own fail-fast read is what keeps production strict on a bad
22
+ `ERROR_DISCLOSURE` value). Keeping the handlers in their own module lets
23
+ tests import `register_exception_handlers` without dragging in the
24
+ production app's startup chain (`Pipelex.make`, `get_auth_dependency`,
25
+ router wiring), so a misconfigured env var can't crash test collection of
26
+ every module that imports a handler at once.
27
+ """
28
+
29
+ import math
30
+ from collections.abc import Awaitable, Callable, Mapping
31
+ from typing import TYPE_CHECKING, Any, cast
32
+
33
+ from fastapi import FastAPI, Request, Response
34
+ from fastapi.exceptions import RequestValidationError
35
+ from fastapi.responses import JSONResponse
36
+ from pipelex import log
37
+ from pipelex.base_exceptions import DisclosureMode, ErrorDomain, ErrorReport, PipelexError
38
+ from pipelex.plugins.registrar import HttpErrorMapperFn
39
+ from starlette.requests import ClientDisconnect
40
+
41
+ from pipelex_api.error_types import ErrorType
42
+ from pipelex_api.errors import ApiError
43
+ from pipelex_api.middleware import request_id_of
44
+ from pipelex_api.problem_document import (
45
+ PROBLEM_JSON_MEDIA_TYPE,
46
+ build_problem_document,
47
+ build_problem_document_from_api_error,
48
+ with_request_context,
49
+ )
50
+
51
+ if TYPE_CHECKING:
52
+ from pipelex_api.security import RequestUser
53
+
54
+ # A Starlette/FastAPI async exception handler: `(request, exc) -> response`.
55
+ _ExceptionHandler = Callable[[Request, Exception], Awaitable[Response]]
56
+
57
+ # The value of the `event` field every error record carries: the one key a log sink filters this
58
+ # server's error stream on, whichever of the handlers below produced the record.
59
+ API_ERROR_EVENT = "api_error"
60
+
61
+
62
+ def _user_id_of(request: Request) -> str | None:
63
+ """Return the authenticated caller's id, when one is on the request.
64
+
65
+ `pipelex_api.security._set_request_user` stores a `RequestUser` on
66
+ `request.state.user` after a successful auth check (jwt, or
67
+ `TRUST_FORWARDED_IDENTITY_HEADERS=true`). The global error-log sites read
68
+ that here so an operator can tie a failure to a caller without grepping
69
+ multiple log lines by `request_id` — and without each route having to log
70
+ `user=<id>` itself before raising (which Phase 3 removed). Returns `None`
71
+ pre-auth, on the static-API-key surface (no per-caller identity), or for
72
+ `AUTH_MODE=none` without the forwarded-identity opt-in: `_emit_api_error`
73
+ drops `None`-valued fields, so the attribute is absent from the record
74
+ rather than carried as `user_id: null`.
75
+ """
76
+ user: RequestUser | None = getattr(request.state, "user", None)
77
+ return user.user_id if user is not None else None
78
+
79
+
80
+ def _pipe_code_of(request: Request) -> str | None:
81
+ """Return the body-derived `pipe_code` when `_parse_request` bound one.
82
+
83
+ `pipelex_api.routes.pipelex.pipeline._parse_request` writes `pipe_code` onto
84
+ `request.state` right after the body decodes (before
85
+ `_validate_extras` / `from_body`), normalized through
86
+ `_coerce_correlation_field` — empty / non-string / oversized inputs become
87
+ `None`, so a caller cannot inflate every record the request emits. Returns
88
+ `None` for routes that don't use `_parse_request`, for requests whose body
89
+ never decoded (`_decode_body` raised 422), and for bodies that legitimately
90
+ omitted `pipe_code` (the `mthds_contents`-only invocation).
91
+ `_emit_api_error` drops `None`-valued fields.
92
+ """
93
+ return getattr(request.state, "pipe_code", None)
94
+
95
+
96
+ def _pipeline_run_id_of(request: Request) -> str | None:
97
+ """Return the parsed `pipeline_run_id` when `_parse_request` bound one on the request.
98
+
99
+ Same shape as `_pipe_code_of`. Source: the raw body's `pipeline_run_id` (the
100
+ protocol wire field — the pipelex runtime internals keep calling it
101
+ `pipeline_run_id`), normalized through `_coerce_correlation_field` at the
102
+ binding site (empty string → `None`, oversized → truncated). Letting the
103
+ field ride every error log lets an operator correlate the API-side failure
104
+ with the worker-side traces that share the same run id, without each
105
+ backend frame having to forward it.
106
+ """
107
+ return getattr(request.state, "pipeline_run_id", None)
108
+
109
+
110
+ def _request_fields(request: Request) -> dict[str, Any]:
111
+ """Return the request-scoped attributes every error record carries.
112
+
113
+ Single source of truth for the `route` / `user_id` / `pipe_code` /
114
+ `pipeline_run_id` set, so the three log paths (`_log_error_report`,
115
+ `_log_api_authored_error`, `handle_unexpected_error`) cannot drift.
116
+
117
+ `request_id` is deliberately NOT here. `RequestIdMiddleware` binds it on the
118
+ runtime's log context for the whole request, so it is already an attribute of
119
+ every record emitted underneath — including the ones pipelex emits from inside
120
+ a run, which this module never sees. Repeating it would give one value two
121
+ sources. `route` is here rather than on that context because the runtime
122
+ reserves the context for its three run identifiers, and a route path is not
123
+ one of them; a field is the seam it offers for everything else.
124
+
125
+ Each value is `None` when the corresponding state is not bound on this request;
126
+ `_emit_api_error` drops those, so an unbound identifier is absent from the
127
+ record rather than carried as `pipe_code: null`.
128
+ """
129
+ return {
130
+ "route": request.url.path,
131
+ "user_id": _user_id_of(request),
132
+ "pipe_code": _pipe_code_of(request),
133
+ "pipeline_run_id": _pipeline_run_id_of(request),
134
+ }
135
+
136
+
137
+ def _retry_after_header(report: ErrorReport) -> dict[str, str]:
138
+ """Return a `Retry-After` header dict when a retry hint applies to this response.
139
+
140
+ Emitted only when the response is itself a retry invitation — the
141
+ provider-429 passthrough (`report.http_status == 429`) — so a hint never
142
+ rides a 500/422 where `Retry-After` is meaningless. The provider's
143
+ `retry_after_seconds` is rounded up and clamped to a non-negative integer;
144
+ a non-finite value (a provider sending `Retry-After: inf` / `nan`) is
145
+ dropped rather than crashing `math.ceil`. Empty dict when no usable hint
146
+ applies.
147
+ """
148
+ if report.http_status != 429:
149
+ return {}
150
+ metadata = report.provider_metadata
151
+ if metadata is None:
152
+ return {}
153
+ seconds = metadata.retry_after_seconds
154
+ if seconds is None or not math.isfinite(seconds):
155
+ return {}
156
+ return {"Retry-After": str(max(0, math.ceil(seconds)))}
157
+
158
+
159
+ def _error_summary(attributes: Mapping[str, Any]) -> str:
160
+ """Return the human-readable message an `api_error` record carries.
161
+
162
+ Built from server-authored values only — the HTTP status and the error type,
163
+ both of which the API or pipelex chose. Nothing a caller supplied ever reaches
164
+ the message: `detail` is caller-controlled on several routes, and the route
165
+ path is percent-decoded from the request line, so both ride fields instead,
166
+ where a structured sink serializes them as values and a crafted newline stays
167
+ inside one. The message says which failure it is; the fields say everything
168
+ about it.
169
+ """
170
+ status = attributes.get("status")
171
+ error_type = attributes.get("error_type")
172
+ return f"API error {status}: {error_type}" if error_type else f"API error {status}"
173
+
174
+
175
+ def _emit_api_error(*, fields: dict[str, Any], as_error: bool) -> None:
176
+ """Emit one `event=api_error` record: a summary message, everything else a record attribute.
177
+
178
+ The fields are handed to the runtime's log call as `fields=`, so each one
179
+ becomes an attribute of the record and the selected sink decides how it goes
180
+ on the wire — a key of its own on the JSON sink's line, an OTLP attribute on
181
+ the collector's. Nothing is flattened into the message here any more, which
182
+ is what retires the API's own `key=value` rendering and its escaping with it.
183
+
184
+ `None`-valued fields are dropped, so an identifier this request never bound is
185
+ absent rather than carried as a null. `as_error` picks the level — `error`
186
+ (with the traceback) for operator-actionable failures, `warning` for
187
+ `INPUT`-domain caller mistakes.
188
+ """
189
+ attributes = {key: value for key, value in fields.items() if value is not None}
190
+ summary = _error_summary(attributes)
191
+ if as_error:
192
+ log.error(summary, fields=attributes, include_exception=True)
193
+ else:
194
+ log.warning(summary, fields=attributes)
195
+
196
+
197
+ def _emit_at_error_level(status: int) -> bool:
198
+ """Decide the log disposition from the final HTTP status, not the error domain.
199
+
200
+ A 5xx is a server fault — log at `error` with a traceback. A 4xx is
201
+ client-facing (a caller mistake or a benign conflict) — log at `warning`
202
+ without a traceback. Keying off the post-override status (see
203
+ ``_ERROR_TYPE_STATUS_OVERRIDES``) is what keeps an API-level 4xx override —
204
+ e.g. ``PipelineManagerAlreadyExistsError`` mapped to 409 — out of the error
205
+ dashboards, while still covering the `INPUT`-domain 422 caller mistakes and
206
+ the provider-429 passthrough without a second per-error-type registry.
207
+ """
208
+ return status >= 500
209
+
210
+
211
+ def _log_error_report(report: ErrorReport, *, request: Request, status: int | None = None) -> None:
212
+ """Emit the structured error record for a handled `ErrorReport`.
213
+
214
+ Disposition follows the final HTTP status (see ``_emit_at_error_level``):
215
+ a 4xx is client-facing and logs at `warning` without a traceback (caller
216
+ mistakes, the provider-429 passthrough, and API-level 4xx overrides like
217
+ the 409 conflict), a 5xx logs at `error` with the traceback. The fields
218
+ mirror the response so the two never drift.
219
+ `user_id` rides every record when auth bound a caller — without it, the
220
+ storage / pipeline-backend leg of a failure carries only `request_id` and
221
+ `route`, and tying the failure to the caller requires correlating the
222
+ request id across unrelated records (Phase 3 deleted the per-route
223
+ `log.error(... user=...)` lines those failures used to emit).
224
+
225
+ ``status`` defaults to ``report.http_status`` and exists so the caller can
226
+ pass the post-override value (see ``_ERROR_TYPE_STATUS_OVERRIDES``) — the
227
+ record then agrees with the HTTP status actually sent rather than the
228
+ domain default.
229
+ """
230
+ effective_status = status if status is not None else report.http_status
231
+ fields: dict[str, Any] = {
232
+ "event": API_ERROR_EVENT,
233
+ **_request_fields(request),
234
+ "error_type": report.error_type,
235
+ "error_category": report.error_category,
236
+ "error_domain": report.error_domain,
237
+ "retryable": report.retryable,
238
+ "status": effective_status,
239
+ "provider": report.provider,
240
+ "model": report.model,
241
+ }
242
+ metadata = report.provider_metadata
243
+ if metadata is not None:
244
+ fields["provider_status_code"] = metadata.status_code
245
+ fields["provider_request_id"] = metadata.request_id
246
+ _emit_api_error(fields=fields, as_error=_emit_at_error_level(effective_status))
247
+
248
+
249
+ def _log_api_authored_error(*, document: dict[str, Any], status: int, request: Request) -> None:
250
+ """Emit the structured error record for an API-authored error response.
251
+
252
+ Shares the disposition rule and the common-key set of `_log_error_report`
253
+ so every error response — a pipelex `ErrorReport` translated to RFC 7807
254
+ *or* an API-authored 4xx/5xx raised by an `pipelex_api.errors` helper — produces
255
+ one `event=api_error` record a downstream sink can filter uniformly on
256
+ `event`, `request_id`, `route`, `error_type`, `error_domain`, `retryable`,
257
+ and `status`. Without this, an API-owned 500 (a `raise_internal_server_error`
258
+ site — `/version`'s missing-package case is the canonical example)
259
+ would land with zero operator output, since `handle_api_error` only
260
+ serializes the response.
261
+
262
+ Where the two helpers differ — by design, matching what each side actually
263
+ has to log:
264
+ - API-authored docs add `detail`, always safe because
265
+ `build_problem_document_from_api_error` does not apply strict-disclosure
266
+ redaction (only `build_problem_document` does for pipelex domain errors).
267
+ Carrying the message preserves the operator-facing cause in the record.
268
+ - API-authored docs omit `error_category`, `provider`, `model`, and
269
+ `provider_metadata.*` — those are inference-domain classifiers pipelex
270
+ sets only on classifiable failures and the API never authors itself.
271
+
272
+ A 4xx logs at `warning` without a traceback; a 5xx logs at `error` with
273
+ the traceback — same status-keyed disposition rule `_log_error_report`
274
+ uses (see ``_emit_at_error_level``), so a sink dedup'ing by level sees one
275
+ shape.
276
+ """
277
+ fields: dict[str, Any] = {
278
+ "event": API_ERROR_EVENT,
279
+ **_request_fields(request),
280
+ "error_type": document.get("error_type"),
281
+ "error_domain": document.get("error_domain"),
282
+ "retryable": document.get("retryable"),
283
+ "status": status,
284
+ "detail": document.get("detail"),
285
+ }
286
+ _emit_api_error(fields=fields, as_error=_emit_at_error_level(status))
287
+
288
+
289
+ def _json_safe_report(report: ErrorReport) -> ErrorReport:
290
+ """Return a report whose floats Starlette's JSON encoder can render.
291
+
292
+ `JSONResponse` encodes with `allow_nan=False`, so a non-finite
293
+ `provider_metadata.retry_after_seconds` — a provider sending `Retry-After:
294
+ inf` / `nan`, which pipelex's header parser passes through unchecked —
295
+ would crash the whole response render, not just the `Retry-After` header.
296
+ Null the unusable hint out; the rest of the report renders intact.
297
+ """
298
+ metadata = report.provider_metadata
299
+ if metadata is None:
300
+ return report
301
+ seconds = metadata.retry_after_seconds
302
+ if seconds is None or math.isfinite(seconds):
303
+ return report
304
+ safe_metadata = metadata.model_copy(update={"retry_after_seconds": None})
305
+ return report.model_copy(update={"provider_metadata": safe_metadata})
306
+
307
+
308
+ _ERROR_TYPE_STATUS_OVERRIDES: dict[str, int] = {
309
+ # Async execution disabled is a deliberate deployment configuration, not
310
+ # an unexpected runtime fault. Mapping it to 501 Not Implemented (rather
311
+ # than the CONFIG-domain default of 500) tells clients "this server does
312
+ # not provide this functionality" without inviting retries.
313
+ "AsyncExecutionNotEnabledError": 501,
314
+ # A submission reusing a pipeline_run_id that is still registered (a
315
+ # genuinely concurrent duplicate — completed/failed runs free their entry
316
+ # on the way out) is a client-visible conflict, not a server fault.
317
+ # Mapping it to 409 Conflict (rather than the no-domain default of 500)
318
+ # tells clients "this id is currently in use": resubmit after the
319
+ # in-flight run finishes, or pick a fresh id.
320
+ "PipelineManagerAlreadyExistsError": 409,
321
+ # The `method_ref` resolution failures (pipelex `MethodRefError` subclasses). The library
322
+ # declares no `error_domain` on them, so without an override each would default to 500 —
323
+ # wrong for what are overwhelmingly caller-fixable conditions. Each entry keeps the class
324
+ # name as the distinct `error_type` the caller (or an agent) branches on:
325
+ # - A reference that does not match the `<address>[@<tag>]` grammar: the caller's own
326
+ # input, plainly fixable → 422.
327
+ "MethodRefParseError": 422,
328
+ # - The repository could not be fetched (nonexistent repo, a `@tag` that is not a tag,
329
+ # network refusal). The dominant cause is a mistyped address or tag — caller-fixable —
330
+ # so 422; the message carries git's own explanation for the rarer upstream faults.
331
+ "MethodFetchError": 422,
332
+ # - The repository was fetched but holds no package matching the address by manifest
333
+ # identity: the referenced resource does not exist → 404 (mirroring the hosted
334
+ # platform's unknown-`method_id` mapping); the message lists what the repo does contain.
335
+ "MethodPackageNotFoundError": 404,
336
+ # - More than one package matches the address: the caller must disambiguate with the
337
+ # package's full address → 422.
338
+ "MethodPackageAmbiguityError": 422,
339
+ # - The selected package exceeds the fetched-package ceilings: the referenced content
340
+ # cannot be processed under this deployment's bounds → 422 (not 413, which is about
341
+ # the request entity itself).
342
+ "MethodPackageTooLargeError": 422,
343
+ # - The method's Python declares structure classes, whether it was sent as a bundle or
344
+ # fetched by `method_ref`: a policy refusal (hosted execution accepts MTHDS concepts and
345
+ # sandboxed PipeFuncs, not in-process Python) → 403, the same status as the bundle
346
+ # transport's custom-code gate.
347
+ "MethodStructuresRefusedError": 403,
348
+ }
349
+
350
+
351
+ def _http_status_for(report: ErrorReport) -> int:
352
+ """Return the HTTP status to use for this report.
353
+
354
+ Defers to ``report.http_status`` for the standard mapping (provider-429
355
+ passthrough + ``error_domain`` -> 4xx/5xx) and applies a per-error-type
356
+ override last. Overrides live in ``_ERROR_TYPE_STATUS_OVERRIDES`` so the
357
+ decisions are discoverable in one place — keep the dict small and each
358
+ entry justified.
359
+ """
360
+ return _ERROR_TYPE_STATUS_OVERRIDES.get(report.error_type, report.http_status)
361
+
362
+
363
+ def _problem_response(report: ErrorReport, *, request: Request, disclosure_mode: DisclosureMode) -> JSONResponse:
364
+ """Build the RFC 7807 `JSONResponse` for an `ErrorReport` and log the entry.
365
+
366
+ Shared by the `PipelexError` handler and every orchestrator-mapper handler:
367
+ both produce an `ErrorReport`, so both render and log it identically. The report is first
368
+ made JSON-safe (see `_json_safe_report`); the status code then comes from
369
+ `_http_status_for(report)` (the report's own mapping plus any API-layer
370
+ override registered in ``_ERROR_TYPE_STATUS_OVERRIDES``), and the response
371
+ body from the Phase 1 problem-document builder under the caller-supplied
372
+ `disclosure_mode` (`register_exception_handlers` binds the app's effective
373
+ mode into the handlers it registers).
374
+ """
375
+ report = _json_safe_report(report)
376
+ request_id = request_id_of(request)
377
+ status = _http_status_for(report)
378
+ document = build_problem_document(
379
+ report,
380
+ instance=request.url.path,
381
+ request_id=request_id,
382
+ disclosure_mode=disclosure_mode,
383
+ )
384
+ # Keep the RFC 7807 ``status`` member aligned with the actual HTTP status
385
+ # when an API-layer override has bumped it away from ``report.http_status``;
386
+ # otherwise the two surfaces (header vs body) would silently disagree.
387
+ document["status"] = status
388
+ _log_error_report(report, request=request, status=status)
389
+ return JSONResponse(
390
+ status_code=status,
391
+ content=document,
392
+ media_type=PROBLEM_JSON_MEDIA_TYPE,
393
+ headers=_retry_after_header(report),
394
+ )
395
+
396
+
397
+ def problem_response_from_error_report(report: ErrorReport, *, request: Request) -> JSONResponse:
398
+ """Render an `ErrorReport` value through the same RFC 7807 path as raised errors.
399
+
400
+ Most `ErrorReport`s reach this module by raising a `PipelexError` or by an
401
+ orchestrator transport mapper. `/validate` can also receive an `ErrorReport`
402
+ as a registry-returned value; when that value is a backend/config/runtime
403
+ fault rather than a validation verdict, it must still keep the same status,
404
+ disclosure, logging, and retry headers as the global handler path.
405
+ """
406
+ disclosure_mode = getattr(request.app.state, "error_disclosure_mode", DisclosureMode.VERBOSE)
407
+ return _problem_response(report, request=request, disclosure_mode=disclosure_mode)
408
+
409
+
410
+ async def handle_pipelex_error(request: Request, exc: Exception, *, disclosure_mode: DisclosureMode) -> Response:
411
+ """Translate any pipelex `PipelexError` into an RFC 7807 problem response.
412
+
413
+ The single place in the API that consumes a `PipelexError`'s `ErrorReport`.
414
+ `to_error_report()` walks the `__cause__` chain, so a wrapper exception
415
+ still surfaces the classification of the underlying failure. `exc` is typed
416
+ `Exception` to match Starlette's handler contract; FastAPI only routes a
417
+ `PipelexError` here, so the cast is sound. An orchestrator's workflow
418
+ failure that is itself a `PipelexError` (e.g. the Temporal plugin's
419
+ `WorkflowExecutionError`, which already carries a structured `ErrorReport`)
420
+ resolves here via Starlette's MRO walk; only a *bare* orchestrator-SDK
421
+ transport error (no `PipelexError` in its MRO) is routed to that plugin's
422
+ own mapper-backed handler instead.
423
+ """
424
+ report = cast("PipelexError", exc).to_error_report()
425
+ return _problem_response(report, request=request, disclosure_mode=disclosure_mode)
426
+
427
+
428
+ def _make_orchestrator_error_handler(mapper: HttpErrorMapperFn, *, disclosure_mode: DisclosureMode) -> "_ExceptionHandler":
429
+ """Wrap one plugin-contributed error mapper into a FastAPI exception handler.
430
+
431
+ The plugin owns the *classification* (it maps its bare transport/runtime
432
+ exception — e.g. `temporalio.TemporalError` — to a structured `ErrorReport`);
433
+ core owns the *transport* exception type (resolved lazily by the registrar);
434
+ the API owns the *presentation* (the same RFC 7807 + `DisclosureMode`
435
+ rendering every `ErrorReport` gets via `_problem_response`). This is what
436
+ lets the base render an orchestrator's transport fault correctly while
437
+ naming — and importing — no orchestrator SDK. `exc` is typed `Exception` to
438
+ match Starlette's handler contract; FastAPI only routes the mapper's
439
+ registered `exc_type` here.
440
+ """
441
+
442
+ async def _handler(request: Request, exc: Exception) -> Response:
443
+ report = mapper(exc)
444
+ return _problem_response(report, request=request, disclosure_mode=disclosure_mode)
445
+
446
+ return _handler
447
+
448
+
449
+ async def handle_unexpected_error(request: Request, exc: Exception) -> Response:
450
+ """Catch-all for any failure matched by no more-specific handler.
451
+
452
+ Covers anything that is not an `ApiError`, a `RequestValidationError`, a
453
+ `PipelexError`, or an orchestrator plugin's mapped transport exception — so on
454
+ the orchestrator-agnostic base (which installs no mapper), an *unmapped* bare
455
+ transport/runtime error collapses here to a 500.
456
+
457
+ One of the two `except Exception`-equivalent sites the project sanctions —
458
+ the outermost handler of an API. Disclosure follows the app's
459
+ `disclosure_mode`, exactly like every other handler in this module: VERBOSE
460
+ puts `Class: message` in `detail` so a caller can see what actually broke;
461
+ STRICT keeps the fully sanitized body (no exception class, no `str(exc)`).
462
+ A traceback reaches the client in neither mode — the class name and full
463
+ traceback go to the operator log, correlated by request id.
464
+
465
+ Honoring the mode here is what stops the catch-all from being a silent hole.
466
+ An unclassified failure is *precisely* the one a caller cannot diagnose from
467
+ a request id alone, and it is the only response in the API that used to
468
+ contradict a deployment's explicit choice to disclose: a VERBOSE deployment
469
+ got real messages for every classified error and a dead end for the one
470
+ error nobody classified. STRICT still redacts, so a deployment that wants
471
+ nothing leaked keeps that.
472
+
473
+ The mode is read off `app.state` (like `problem_response_from_error_report`)
474
+ rather than bound as a parameter, so registering this handler stays
475
+ signature-free.
476
+
477
+ This handler also covers a failure *inside* another handler (e.g. a corrupt
478
+ `to_error_report()`): Starlette's `ServerErrorMiddleware` wraps the others,
479
+ so such a failure still lands here rather than a bodyless default.
480
+ """
481
+ request_id = request_id_of(request)
482
+ _emit_api_error(
483
+ fields={
484
+ "event": API_ERROR_EVENT,
485
+ **_request_fields(request),
486
+ "error_type": type(exc).__name__,
487
+ "error_category": "unknown",
488
+ "error_domain": ErrorDomain.RUNTIME,
489
+ "status": 500,
490
+ },
491
+ as_error=True,
492
+ )
493
+ disclosure_mode = getattr(request.app.state, "error_disclosure_mode", DisclosureMode.VERBOSE)
494
+ if disclosure_mode is DisclosureMode.STRICT:
495
+ detail = "An unexpected error occurred. The request id is included for support."
496
+ else:
497
+ detail = f"{type(exc).__name__}: {exc}"
498
+ # Route through the same builder every other API-authored 500 uses, so the
499
+ # catch-all shape never drifts from `handle_api_error`'s output. The one
500
+ # catch-all-specific addition is `error_category: "unknown"` — by definition
501
+ # the failure was not classifiable, and the marker lets a downstream sink
502
+ # tell a catch-all 500 apart from a classified `CONFIG`-domain one.
503
+ document = build_problem_document_from_api_error(
504
+ ErrorType.INTERNAL_SERVER_ERROR,
505
+ detail,
506
+ 500,
507
+ instance=request.url.path,
508
+ request_id=request_id,
509
+ error_domain=ErrorDomain.RUNTIME,
510
+ )
511
+ document["error_category"] = "unknown"
512
+ return JSONResponse(status_code=500, content=document, media_type=PROBLEM_JSON_MEDIA_TYPE)
513
+
514
+
515
+ async def handle_api_error(request: Request, exc: Exception) -> Response:
516
+ """Render an API-authored `ApiError` as an RFC 7807 problem response.
517
+
518
+ `ApiError` is raised by the `raise_*` helpers in `pipelex_api.errors` for the API's
519
+ own 4xx/5xx — request validation, auth, payload limits, misconfiguration.
520
+ The helper builds the problem document at raise time, where it holds no
521
+ `Request`, so this handler stamps the two request-scoped members (`instance`
522
+ and `request_id`) onto a copy of it — the same two values, read from the same
523
+ place, as the pipelex-error and request-validation paths use. It then
524
+ serializes that document, re-attaches any `WWW-Authenticate` challenge header,
525
+ and emits the structured `event=api_error` record so an API-owned 500 from any
526
+ route is observable (a `raise_internal_server_error` site like `version.py`'s
527
+ missing-package case is the canonical example — it has no preceding
528
+ `log.error` at the call site). `exc` is typed `Exception` to match Starlette's
529
+ handler contract; FastAPI only routes an `ApiError` here, so the cast is sound.
530
+ """
531
+ api_error = cast("ApiError", exc)
532
+ document = with_request_context(
533
+ api_error.document,
534
+ instance=request.url.path,
535
+ request_id=request_id_of(request),
536
+ )
537
+ _log_api_authored_error(document=document, status=api_error.status_code, request=request)
538
+ return JSONResponse(
539
+ status_code=api_error.status_code,
540
+ content=document,
541
+ media_type=PROBLEM_JSON_MEDIA_TYPE,
542
+ headers=api_error.headers,
543
+ )
544
+
545
+
546
+ def _summarize_request_validation_error(exc: RequestValidationError) -> str:
547
+ """Render FastAPI's per-field validation failures as one human-readable string.
548
+
549
+ `RequestValidationError.errors()` is a list of per-field dicts; RFC 7807's
550
+ `detail` is a single human-readable string, so each entry is rendered as
551
+ `<location>: <message>` and the lot joined. Only `loc` and `msg` are read —
552
+ both are plain strings / ints — so nothing unserializable from a crafted
553
+ request body can reach the response.
554
+ """
555
+ parts: list[str] = []
556
+ for error in exc.errors():
557
+ location = ".".join(str(item) for item in error.get("loc", ()))
558
+ message = error.get("msg") or "Invalid input"
559
+ parts.append(f"{location}: {message}" if location else message)
560
+ return "; ".join(parts) or "Request validation failed"
561
+
562
+
563
+ # The `error_type` a FastAPI-validated body failing on exactly one of these fields carries,
564
+ # so a client branches on the same value whichever route refused the field. `/execute` and
565
+ # `/start` read their extras by hand and classify them the same way in `pipelex_api.routes.pipelex.pipeline`.
566
+ _BODY_FIELD_ERROR_TYPES: dict[str, ErrorType] = {
567
+ "analytics_groups": ErrorType.INVALID_ANALYTICS_GROUPS,
568
+ }
569
+
570
+
571
+ def _request_validation_error_type(exc: RequestValidationError) -> ErrorType:
572
+ """Classify a request validation failure by the body field that failed.
573
+
574
+ Only a failure confined to one classified body field gets that field's type; anything
575
+ else — several fields, a query parameter, a model-level validator — keeps the generic
576
+ `ValidationError`, because naming one field would hide the others.
577
+ """
578
+ failed_fields: set[str] = set()
579
+ for error in exc.errors():
580
+ location = tuple(error.get("loc", ()))
581
+ if len(location) < 2 or location[0] != "body":
582
+ return ErrorType.VALIDATION_ERROR
583
+ failed_fields.add(str(location[1]))
584
+ if len(failed_fields) != 1:
585
+ return ErrorType.VALIDATION_ERROR
586
+ return _BODY_FIELD_ERROR_TYPES.get(failed_fields.pop(), ErrorType.VALIDATION_ERROR)
587
+
588
+
589
+ async def handle_request_validation_error(request: Request, exc: Exception) -> Response:
590
+ """Translate FastAPI's automatic request validation failure into RFC 7807.
591
+
592
+ FastAPI raises `RequestValidationError` when a request body, query, or path
593
+ parameter fails the endpoint's declared Pydantic model — an extra field
594
+ under `extra="forbid"`, a `min_length` / `max_length` breach, a missing or
595
+ mistyped field, a `field_validator` raising `ValueError`, malformed JSON. Its
596
+ built-in handler would answer with a bare `{"detail": [...]}` /
597
+ `application/json` body; registering this handler overrides that so an
598
+ endpoint's caller-input rejection is the same `application/problem+json`
599
+ shape as an explicit `raise_validation_error` — one error contract across
600
+ every endpoint. `exc` is typed `Exception` to match Starlette's handler
601
+ contract; FastAPI only routes a `RequestValidationError` here, so the cast
602
+ is sound.
603
+ """
604
+ validation_error = cast("RequestValidationError", exc)
605
+ document = build_problem_document_from_api_error(
606
+ _request_validation_error_type(validation_error),
607
+ _summarize_request_validation_error(validation_error),
608
+ 422,
609
+ instance=request.url.path,
610
+ request_id=request_id_of(request),
611
+ error_domain=ErrorDomain.INPUT,
612
+ )
613
+ # Same `event=api_error` record as an explicit `raise_validation_error`,
614
+ # so FastAPI's automatic-validation 422s aren't silent in operator logs.
615
+ _log_api_authored_error(document=document, status=422, request=request)
616
+ return JSONResponse(status_code=422, content=document, media_type=PROBLEM_JSON_MEDIA_TYPE)
617
+
618
+
619
+ async def handle_client_disconnect(request: Request, exc: Exception) -> Response: # noqa: ARG001 — Starlette's handler contract
620
+ """Answer a client that left before its request body arrived in full with a 400, logged as a caller's failure.
621
+
622
+ Starlette raises `ClientDisconnect` from a body read when the client goes away mid-upload: from the
623
+ nesting check of a `JsonBodyRoute`, and from the run routes, which read their raw body themselves.
624
+ Nobody is left to read the answer, so what matters is the record it leaves: a 400 `BadRequest` at
625
+ `warning`, as FastAPI's own body read answered it, never a 500 server fault logged with a traceback.
626
+ """
627
+ document = build_problem_document_from_api_error(
628
+ ErrorType.BAD_REQUEST,
629
+ "The client disconnected before the request body was received in full.",
630
+ 400,
631
+ instance=request.url.path,
632
+ request_id=request_id_of(request),
633
+ error_domain=ErrorDomain.INPUT,
634
+ )
635
+ _log_api_authored_error(document=document, status=400, request=request)
636
+ return JSONResponse(status_code=400, content=document, media_type=PROBLEM_JSON_MEDIA_TYPE)
637
+
638
+
639
+ def register_exception_handlers(
640
+ app: FastAPI,
641
+ *,
642
+ disclosure_mode: DisclosureMode = DisclosureMode.VERBOSE,
643
+ http_error_mappers: dict[type[Exception], HttpErrorMapperFn] | None = None,
644
+ ) -> None:
645
+ """Register the app-level exception handlers on `app`.
646
+
647
+ Resolution is most-specific-first: an API-authored `ApiError` →
648
+ `handle_api_error`; a FastAPI `RequestValidationError` (automatic
649
+ request-body / parameter validation) → `handle_request_validation_error`; a
650
+ Starlette `ClientDisconnect` (the client left mid-upload) →
651
+ `handle_client_disconnect`; a
652
+ `PipelexError` (including an orchestrator plugin's `PipelexError`-derived
653
+ workflow failure) → `handle_pipelex_error`; a bare orchestrator-SDK transport
654
+ error → that plugin's mapper-backed handler (see `http_error_mappers`);
655
+ anything else → `handle_unexpected_error`. Registering `RequestValidationError`
656
+ overrides FastAPI's built-in handler so its automatic-validation failures answer
657
+ in the same `application/problem+json` shape as every other error, not the
658
+ default `{"detail": [...]}`. Shared by the production app and by the unit
659
+ tests, which register the same handlers on a throwaway app.
660
+
661
+ `http_error_mappers` is the `{exc_type: to_error_report}` map an orchestrator
662
+ plugin contributes through the plugin SPI (`PluginRegistrar.add_http_error_mapper`,
663
+ read back via `get_http_error_mappers`). The caller resolves it at app
664
+ construction — `pipelex_api.main` builds the registrar and passes it; the base resolves
665
+ an empty map (it installs no orchestrator plugin), so no transport handler is
666
+ registered and the only fallback for an unclassified failure stays the catch-all
667
+ 500. Each entry registers one handler that runs the mapper and renders the
668
+ resulting `ErrorReport` through the shared RFC 7807 + disclosure path — so core
669
+ and the API name no orchestrator SDK. Keeping the parameter explicit (rather than
670
+ calling `build_registrar` in here) preserves this module's import-side-effect-free
671
+ contract: tests register handlers on a throwaway app without a booted Pipelex, and
672
+ a test contributes a synthetic mapper to prove the seam without installing a plugin.
673
+
674
+ `disclosure_mode` is captured by the handlers that render a pipelex `ErrorReport`
675
+ (`handle_pipelex_error` and every mapper handler) via the closures below —
676
+ production passes the startup-resolved value (`pipelex_api.main.ERROR_DISCLOSURE_MODE`);
677
+ tests pass whatever the test needs and the default (`VERBOSE`) covers the common
678
+ case. The other handlers don't render an `ErrorReport`, so they register
679
+ directly — `handle_unexpected_error` still honors the mode, reading it back off
680
+ `app.state` (set below) rather than through a closure.
681
+ """
682
+ app.state.error_disclosure_mode = disclosure_mode
683
+
684
+ async def _pipelex_error(request: Request, exc: Exception) -> Response:
685
+ return await handle_pipelex_error(request, exc, disclosure_mode=disclosure_mode)
686
+
687
+ app.add_exception_handler(ApiError, handle_api_error)
688
+ app.add_exception_handler(RequestValidationError, handle_request_validation_error)
689
+ app.add_exception_handler(ClientDisconnect, handle_client_disconnect)
690
+ app.add_exception_handler(PipelexError, _pipelex_error)
691
+ for exc_type, mapper in (http_error_mappers or {}).items():
692
+ app.add_exception_handler(exc_type, _make_orchestrator_error_handler(mapper, disclosure_mode=disclosure_mode))
693
+ app.add_exception_handler(Exception, handle_unexpected_error)