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.
- pipelex_api/__init__.py +0 -0
- pipelex_api/api.toml +21 -0
- pipelex_api/api_config.py +144 -0
- pipelex_api/bundle.py +243 -0
- pipelex_api/disclosure.py +43 -0
- pipelex_api/error_types.py +80 -0
- pipelex_api/error_uri.py +45 -0
- pipelex_api/errors.py +130 -0
- pipelex_api/exception_handlers.py +693 -0
- pipelex_api/json_body.py +182 -0
- pipelex_api/limits.py +67 -0
- pipelex_api/main.py +221 -0
- pipelex_api/method_cache.py +241 -0
- pipelex_api/method_source.py +215 -0
- pipelex_api/middleware.py +209 -0
- pipelex_api/openapi_responses.py +186 -0
- pipelex_api/openapi_schema.py +83 -0
- pipelex_api/problem_document.py +134 -0
- pipelex_api/py.typed +0 -0
- pipelex_api/routes/__init__.py +23 -0
- pipelex_api/routes/health.py +24 -0
- pipelex_api/routes/pipelex/__init__.py +21 -0
- pipelex_api/routes/pipelex/agent/__init__.py +11 -0
- pipelex_api/routes/pipelex/agent/concept.py +60 -0
- pipelex_api/routes/pipelex/agent/models.py +49 -0
- pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
- pipelex_api/routes/pipelex/build/__init__.py +11 -0
- pipelex_api/routes/pipelex/build/inputs.py +192 -0
- pipelex_api/routes/pipelex/build/output.py +163 -0
- pipelex_api/routes/pipelex/build/runner.py +236 -0
- pipelex_api/routes/pipelex/codegen.py +164 -0
- pipelex_api/routes/pipelex/crate_ops.py +331 -0
- pipelex_api/routes/pipelex/pipe_io.py +186 -0
- pipelex_api/routes/pipelex/pipeline.py +938 -0
- pipelex_api/routes/pipelex/resolve.py +81 -0
- pipelex_api/routes/pipelex/tools.py +111 -0
- pipelex_api/routes/pipelex/utils.py +6 -0
- pipelex_api/routes/pipelex/validate.py +473 -0
- pipelex_api/routes/version.py +51 -0
- pipelex_api/schemas/__init__.py +0 -0
- pipelex_api/schemas/models.py +653 -0
- pipelex_api/security.py +284 -0
- pipelex_api-0.71.0.dist-info/METADATA +188 -0
- pipelex_api-0.71.0.dist-info/RECORD +46 -0
- pipelex_api-0.71.0.dist-info/WHEEL +4 -0
- 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)
|