unstructured-platform-plugins 0.0.45__tar.gz → 0.1.0__tar.gz
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.
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/PKG-INFO +4 -4
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/pyproject.toml +2 -2
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/__version__.py +1 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/api_generator.py +178 -49
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/main.py +10 -0
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/__init__.py +1 -0
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/error_audience_v1.py +28 -0
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/invocation_context_v1.py +89 -0
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/invocation_context.py +125 -0
- unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/invocation_settings.py +536 -0
- unstructured_platform_plugins-0.0.45/unstructured_platform_plugins/__version__.py +0 -1
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/.gitignore +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/LICENSE.md +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/README.md +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/otel.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/etl_uvicorn/utils.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/__init__.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/filedata_meta.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/json_schema.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/model.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/usage.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/schema/utils.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/type_hints.py +0 -0
- {unstructured_platform_plugins-0.0.45 → unstructured_platform_plugins-0.1.0}/unstructured_platform_plugins/validate_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: unstructured_platform_plugins
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform
|
|
5
5
|
License-File: LICENSE.md
|
|
6
6
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -10,12 +10,11 @@ Classifier: Intended Audience :: Science/Research
|
|
|
10
10
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
16
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.11
|
|
19
18
|
Requires-Dist: click
|
|
20
19
|
Requires-Dist: dataclasses-json
|
|
21
20
|
Requires-Dist: fastapi
|
|
@@ -23,6 +22,7 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc
|
|
|
23
22
|
Requires-Dist: opentelemetry-instrumentation-fastapi
|
|
24
23
|
Requires-Dist: requests
|
|
25
24
|
Requires-Dist: unstructured-ingest
|
|
25
|
+
Requires-Dist: utic-invocation-settings<1.0.0,>=0.5.0
|
|
26
26
|
Requires-Dist: uvicorn
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unstructured_platform_plugins"
|
|
3
3
|
description = "Wrapper to convert arbitrary code into a uvicorn/fastapi implementation for Unstructured Platform"
|
|
4
|
-
requires-python = ">=3.
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
5
|
classifiers = [
|
|
6
6
|
"Development Status :: 4 - Beta",
|
|
7
7
|
"Intended Audience :: Developers",
|
|
@@ -10,7 +10,6 @@ classifiers = [
|
|
|
10
10
|
"License :: OSI Approved :: Apache Software License",
|
|
11
11
|
"Operating System :: OS Independent",
|
|
12
12
|
"Programming Language :: Python :: 3",
|
|
13
|
-
"Programming Language :: Python :: 3.10",
|
|
14
13
|
"Programming Language :: Python :: 3.11",
|
|
15
14
|
"Programming Language :: Python :: 3.12",
|
|
16
15
|
"Programming Language :: Python :: 3.13",
|
|
@@ -24,6 +23,7 @@ dependencies = [
|
|
|
24
23
|
"fastapi",
|
|
25
24
|
"click",
|
|
26
25
|
"unstructured-ingest",
|
|
26
|
+
"utic-invocation-settings>=0.5.0,<1.0.0",
|
|
27
27
|
"opentelemetry-instrumentation-fastapi",
|
|
28
28
|
"opentelemetry-exporter-otlp-proto-grpc",
|
|
29
29
|
"dataclasses-json"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0" # pragma: no cover
|
|
@@ -3,8 +3,7 @@ import hashlib
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
7
|
-
from typing import Any, Callable, Optional, Union
|
|
6
|
+
from typing import Any, Callable, Optional, Union, get_origin
|
|
8
7
|
|
|
9
8
|
from fastapi import FastAPI, HTTPException, status
|
|
10
9
|
from fastapi.responses import StreamingResponse
|
|
@@ -13,7 +12,7 @@ from pydantic import BaseModel, Field, create_model
|
|
|
13
12
|
from starlette.responses import RedirectResponse
|
|
14
13
|
from typing_extensions import deprecated
|
|
15
14
|
from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
|
|
16
|
-
from unstructured_ingest.error import UnstructuredIngestError
|
|
15
|
+
from unstructured_ingest.error import UnstructuredIngestError, UserError
|
|
17
16
|
from uvicorn.config import LOG_LEVELS
|
|
18
17
|
from uvicorn.importer import import_from_string
|
|
19
18
|
|
|
@@ -26,6 +25,14 @@ from unstructured_platform_plugins.etl_uvicorn.utils import (
|
|
|
26
25
|
get_schema_dict,
|
|
27
26
|
map_inputs,
|
|
28
27
|
)
|
|
28
|
+
from unstructured_platform_plugins.generated.error_audience_v1 import ErrorAudience
|
|
29
|
+
from unstructured_platform_plugins.invocation_settings import (
|
|
30
|
+
add_metadata_route,
|
|
31
|
+
current_invocation_context,
|
|
32
|
+
current_invocation_settings,
|
|
33
|
+
install_invocation_envelope,
|
|
34
|
+
invocation_envelope,
|
|
35
|
+
)
|
|
29
36
|
from unstructured_platform_plugins.schema import FileDataMeta, NewRecord, UsageData
|
|
30
37
|
from unstructured_platform_plugins.schema.json_schema import (
|
|
31
38
|
schema_to_base_model,
|
|
@@ -46,6 +53,16 @@ class MessageChannels(BaseModel):
|
|
|
46
53
|
warnings: list[str] = Field(default_factory=list)
|
|
47
54
|
|
|
48
55
|
|
|
56
|
+
class PluginErrorMetadata(BaseModel):
|
|
57
|
+
"""Canonical metadata nested at ``plugin_error`` on a failed legacy response."""
|
|
58
|
+
|
|
59
|
+
error_type: str
|
|
60
|
+
error_reason: str
|
|
61
|
+
dependency: Optional[str] = None
|
|
62
|
+
audience: Optional[ErrorAudience] = None
|
|
63
|
+
retryable: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
49
66
|
def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
|
|
50
67
|
msg = None
|
|
51
68
|
if logger.level == LOG_LEVELS.get("debug", logging.NOTSET):
|
|
@@ -62,21 +79,90 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
|
|
|
62
79
|
logger.log(level=logger.level, msg=msg)
|
|
63
80
|
|
|
64
81
|
|
|
82
|
+
def _error_attr(error: BaseException, name: str) -> Any:
|
|
83
|
+
"""Read an attribute off a raised error, treating a raising property as absent.
|
|
84
|
+
|
|
85
|
+
Runs while an exception handler is building the sanitized response; an
|
|
86
|
+
attribute access that itself raises must not replace that response with a
|
|
87
|
+
raw 500.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return getattr(error, name, None)
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_str(value: object) -> str:
|
|
96
|
+
"""str() on a plugin-supplied error can itself raise; never let that escape the handler.
|
|
97
|
+
|
|
98
|
+
An escape replaces the sanitized envelope with a raw HTTP 500, which the
|
|
99
|
+
controller's preflight reads as fail-open — a plugin-reported failure would
|
|
100
|
+
silently become a proceed.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
return str(value)
|
|
104
|
+
except Exception:
|
|
105
|
+
return "<unrenderable error>"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def failure_category_of(error: BaseException) -> Optional[str]:
|
|
109
|
+
"""Return the error's failure_category only when it is a plain string."""
|
|
110
|
+
category = _error_attr(error, "failure_category")
|
|
111
|
+
return category if isinstance(category, str) else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def plugin_error_of(error: BaseException) -> Optional[PluginErrorMetadata]:
|
|
115
|
+
"""Map the legacy UserError family onto the canonical plugin-error envelope.
|
|
116
|
+
|
|
117
|
+
The ratified ErrorAudience vocabulary owns the wire spelling. A non-user failure remains
|
|
118
|
+
unclassified: orchestrators must not infer actionability from its HTTP status.
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(error, UserError):
|
|
121
|
+
return None
|
|
122
|
+
return PluginErrorMetadata(
|
|
123
|
+
error_type="configuration",
|
|
124
|
+
error_reason=failure_category_of(error) or "invalid_input",
|
|
125
|
+
audience=ErrorAudience.USER,
|
|
126
|
+
retryable=False,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def status_code_of(error: BaseException) -> int:
|
|
131
|
+
"""Return the error's status_code only when it is an int in the HTTP range, else 500."""
|
|
132
|
+
status_code = _error_attr(error, "status_code")
|
|
133
|
+
if (
|
|
134
|
+
isinstance(status_code, int)
|
|
135
|
+
and not isinstance(status_code, bool)
|
|
136
|
+
and 100 <= status_code <= 599
|
|
137
|
+
):
|
|
138
|
+
return status_code
|
|
139
|
+
return status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
140
|
+
|
|
141
|
+
|
|
65
142
|
async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any:
|
|
66
143
|
kwargs = kwargs or {}
|
|
67
144
|
if inspect.iscoroutinefunction(func):
|
|
68
145
|
return await func(**kwargs)
|
|
69
|
-
|
|
70
|
-
|
|
146
|
+
# to_thread copies contextvars into the worker thread, so OpenTelemetry
|
|
147
|
+
# context (and any wide-event adoption inside func) survives the hop;
|
|
148
|
+
# run_in_executor does not.
|
|
149
|
+
return await asyncio.to_thread(func, **kwargs)
|
|
71
150
|
|
|
72
151
|
|
|
73
152
|
def check_precheck_func(precheck_func: Callable):
|
|
74
|
-
|
|
75
|
-
|
|
153
|
+
try:
|
|
154
|
+
# eval_str resolves postponed/string annotations ('list', 'None')
|
|
155
|
+
sig = inspect.signature(precheck_func, eval_str=True)
|
|
156
|
+
except (NameError, TypeError):
|
|
157
|
+
sig = inspect.signature(precheck_func)
|
|
158
|
+
inputs = list(sig.parameters.values())
|
|
76
159
|
outputs = sig.return_annotation
|
|
77
160
|
if len(inputs) == 1:
|
|
78
161
|
i = inputs[0]
|
|
79
|
-
|
|
162
|
+
annotation_is_list = (
|
|
163
|
+
i.annotation is sig.empty or i.annotation is list or get_origin(i.annotation) is list
|
|
164
|
+
)
|
|
165
|
+
if i.name != "usage" or not annotation_is_list:
|
|
80
166
|
raise ValueError("the only input available for precheck is usage which must be a list")
|
|
81
167
|
if outputs not in [None, sig.empty]:
|
|
82
168
|
raise ValueError(f"no output should exist for precheck function, found: {outputs}")
|
|
@@ -117,9 +203,15 @@ def wrap_in_fastapi(
|
|
|
117
203
|
func: Callable,
|
|
118
204
|
plugin_id: str,
|
|
119
205
|
precheck_func: Optional[Callable] = None,
|
|
206
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
120
207
|
) -> FastAPI:
|
|
121
208
|
try:
|
|
122
|
-
return _wrap_in_fastapi(
|
|
209
|
+
return _wrap_in_fastapi(
|
|
210
|
+
func=func,
|
|
211
|
+
plugin_id=plugin_id,
|
|
212
|
+
precheck_func=precheck_func,
|
|
213
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
214
|
+
)
|
|
123
215
|
except Exception as e:
|
|
124
216
|
logger.error(f"failed to wrap function in FastAPI: {e}", exc_info=True)
|
|
125
217
|
raise EtlApiException(e) from e
|
|
@@ -129,13 +221,19 @@ def _wrap_in_fastapi(
|
|
|
129
221
|
func: Callable,
|
|
130
222
|
plugin_id: str,
|
|
131
223
|
precheck_func: Optional[Callable] = None,
|
|
224
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
132
225
|
) -> FastAPI:
|
|
133
226
|
if precheck_func is not None:
|
|
134
227
|
check_precheck_func(precheck_func=precheck_func)
|
|
135
228
|
|
|
136
229
|
logger.debug(f"set static id response to: {plugin_id}")
|
|
137
230
|
|
|
231
|
+
if "usage" not in inspect.signature(func).parameters:
|
|
232
|
+
logger.warning("usage data not an expected parameter, omitting")
|
|
233
|
+
|
|
138
234
|
fastapi_app = FastAPI()
|
|
235
|
+
# Installation contributes a public router dependency, so it must happen before /invoke.
|
|
236
|
+
install_invocation_envelope(fastapi_app)
|
|
139
237
|
|
|
140
238
|
response_type = get_output_sig(func)
|
|
141
239
|
filedata_meta_model = update_filedata_model(response_type)
|
|
@@ -146,6 +244,8 @@ def _wrap_in_fastapi(
|
|
|
146
244
|
file_data: Optional[FileDataType] = None
|
|
147
245
|
filedata_meta: Optional[filedata_meta_model] = None
|
|
148
246
|
status_code_text: Optional[str] = None
|
|
247
|
+
failure_category: Optional[str] = None
|
|
248
|
+
plugin_error: Optional[PluginErrorMetadata] = None
|
|
149
249
|
output: Optional[response_type] = None
|
|
150
250
|
message_channels: MessageChannels = Field(default_factory=MessageChannels)
|
|
151
251
|
|
|
@@ -161,20 +261,43 @@ def _wrap_in_fastapi(
|
|
|
161
261
|
filedata_meta = FileDataMeta()
|
|
162
262
|
message_channels = MessageChannels()
|
|
163
263
|
request_dict = kwargs if kwargs else {}
|
|
164
|
-
|
|
264
|
+
params = inspect.signature(func).parameters
|
|
265
|
+
if "usage" in params:
|
|
165
266
|
request_dict["usage"] = usage
|
|
166
|
-
|
|
167
|
-
logger.warning("usage data not an expected parameter, omitting")
|
|
168
|
-
if "message_channels" in inspect.signature(func).parameters:
|
|
267
|
+
if "message_channels" in params:
|
|
169
268
|
request_dict["message_channels"] = message_channels
|
|
170
|
-
if "filedata_meta" in
|
|
269
|
+
if "filedata_meta" in params:
|
|
171
270
|
request_dict["filedata_meta"] = filedata_meta
|
|
172
271
|
try:
|
|
173
272
|
if inspect.isasyncgenfunction(func):
|
|
273
|
+
bound_settings = current_invocation_settings()
|
|
274
|
+
bound_context = current_invocation_context()
|
|
275
|
+
|
|
174
276
|
# Stream response if function is an async generator
|
|
175
277
|
async def _stream_response():
|
|
176
|
-
|
|
177
|
-
|
|
278
|
+
# FastAPI 0.117 closes yield dependencies before iterating a
|
|
279
|
+
# StreamingResponse. Re-enter the captured binding inside the generator so the
|
|
280
|
+
# plugin sees the right request regardless of dependency-cleanup timing.
|
|
281
|
+
with invocation_envelope(bound_settings, bound_context):
|
|
282
|
+
try:
|
|
283
|
+
async for output in func(**(request_dict or {})):
|
|
284
|
+
yield (
|
|
285
|
+
InvokeResponse(
|
|
286
|
+
usage=usage,
|
|
287
|
+
message_channels=message_channels,
|
|
288
|
+
filedata_meta=filedata_meta_model.model_validate(
|
|
289
|
+
filedata_meta.model_dump()
|
|
290
|
+
),
|
|
291
|
+
status_code=status.HTTP_200_OK,
|
|
292
|
+
output=output,
|
|
293
|
+
file_data=request_dict.get("file_data", None),
|
|
294
|
+
).model_dump_json()
|
|
295
|
+
+ "\n"
|
|
296
|
+
)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(
|
|
299
|
+
f"Failure streaming response: {_safe_str(e)}", exc_info=True
|
|
300
|
+
)
|
|
178
301
|
yield (
|
|
179
302
|
InvokeResponse(
|
|
180
303
|
usage=usage,
|
|
@@ -182,27 +305,13 @@ def _wrap_in_fastapi(
|
|
|
182
305
|
filedata_meta=filedata_meta_model.model_validate(
|
|
183
306
|
filedata_meta.model_dump()
|
|
184
307
|
),
|
|
185
|
-
status_code=
|
|
186
|
-
|
|
187
|
-
|
|
308
|
+
status_code=status_code_of(e),
|
|
309
|
+
status_code_text=f"[{type(e).__name__}] {_safe_str(e)}",
|
|
310
|
+
failure_category=failure_category_of(e),
|
|
311
|
+
plugin_error=plugin_error_of(e),
|
|
188
312
|
).model_dump_json()
|
|
189
313
|
+ "\n"
|
|
190
314
|
)
|
|
191
|
-
except Exception as e:
|
|
192
|
-
logger.error(f"Failure streaming response: {e}", exc_info=True)
|
|
193
|
-
yield (
|
|
194
|
-
InvokeResponse(
|
|
195
|
-
usage=usage,
|
|
196
|
-
message_channels=message_channels,
|
|
197
|
-
filedata_meta=filedata_meta_model.model_validate(
|
|
198
|
-
filedata_meta.model_dump()
|
|
199
|
-
),
|
|
200
|
-
status_code=getattr(e, "status_code", None)
|
|
201
|
-
or status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
202
|
-
status_code_text=f"[{e.__class__.__name__}] {e}",
|
|
203
|
-
).model_dump_json()
|
|
204
|
-
+ "\n"
|
|
205
|
-
)
|
|
206
315
|
|
|
207
316
|
return StreamingResponse(_stream_response(), media_type="application/x-ndjson")
|
|
208
317
|
else:
|
|
@@ -217,40 +326,45 @@ def _wrap_in_fastapi(
|
|
|
217
326
|
)
|
|
218
327
|
except HTTPException as exc:
|
|
219
328
|
logger.error(
|
|
220
|
-
f"HTTPException: {exc.detail} (status_code={exc.status_code})",
|
|
329
|
+
f"HTTPException: {_safe_str(exc.detail)} (status_code={exc.status_code})",
|
|
330
|
+
exc_info=True,
|
|
221
331
|
)
|
|
222
332
|
return InvokeResponse(
|
|
223
333
|
usage=usage,
|
|
224
334
|
message_channels=message_channels,
|
|
225
335
|
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
|
|
226
336
|
status_code=exc.status_code,
|
|
227
|
-
status_code_text=
|
|
228
|
-
if isinstance(exc.detail,
|
|
229
|
-
else exc.detail,
|
|
337
|
+
status_code_text=exc.detail
|
|
338
|
+
if isinstance(exc.detail, str)
|
|
339
|
+
else json.dumps(exc.detail, default=_safe_str),
|
|
340
|
+
failure_category=failure_category_of(exc),
|
|
230
341
|
file_data=request_dict.get("file_data", None),
|
|
231
342
|
)
|
|
232
343
|
except UnstructuredIngestError as exc:
|
|
233
344
|
logger.error(
|
|
234
|
-
f"UnstructuredIngestError: {
|
|
345
|
+
f"UnstructuredIngestError: {_safe_str(exc)} "
|
|
346
|
+
f"(status_code={_error_attr(exc, 'status_code')})",
|
|
235
347
|
exc_info=True,
|
|
236
348
|
)
|
|
237
349
|
return InvokeResponse(
|
|
238
350
|
usage=usage,
|
|
239
351
|
message_channels=message_channels,
|
|
240
352
|
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
|
|
241
|
-
status_code=exc
|
|
242
|
-
status_code_text=
|
|
353
|
+
status_code=status_code_of(exc),
|
|
354
|
+
status_code_text=_safe_str(exc),
|
|
355
|
+
failure_category=failure_category_of(exc),
|
|
356
|
+
plugin_error=plugin_error_of(exc),
|
|
243
357
|
file_data=request_dict.get("file_data", None),
|
|
244
358
|
)
|
|
245
359
|
except Exception as invoke_error:
|
|
246
|
-
logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True)
|
|
360
|
+
logger.error(f"failed to invoke plugin: {_safe_str(invoke_error)}", exc_info=True)
|
|
247
361
|
return InvokeResponse(
|
|
248
362
|
usage=usage,
|
|
249
363
|
message_channels=message_channels,
|
|
250
364
|
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
|
|
251
|
-
status_code=
|
|
252
|
-
|
|
253
|
-
|
|
365
|
+
status_code=status_code_of(invoke_error),
|
|
366
|
+
status_code_text=f"[{type(invoke_error).__name__}] {_safe_str(invoke_error)}",
|
|
367
|
+
failure_category=failure_category_of(invoke_error),
|
|
254
368
|
file_data=request_dict.get("file_data", None),
|
|
255
369
|
)
|
|
256
370
|
|
|
@@ -287,9 +401,7 @@ def _wrap_in_fastapi(
|
|
|
287
401
|
|
|
288
402
|
@fastapi_app.post("/invoke", response_model=InvokeResponse)
|
|
289
403
|
async def run_job(request: Optional[input_schema_model] = None) -> ResponseType:
|
|
290
|
-
return await run_job_with_body(
|
|
291
|
-
request if request is not None else input_schema_model()
|
|
292
|
-
)
|
|
404
|
+
return await run_job_with_body(request if request is not None else input_schema_model())
|
|
293
405
|
|
|
294
406
|
elif input_schema_model.model_fields:
|
|
295
407
|
|
|
@@ -318,6 +430,8 @@ def _wrap_in_fastapi(
|
|
|
318
430
|
usage: list[UsageData]
|
|
319
431
|
status_code: int
|
|
320
432
|
status_code_text: Optional[str] = None
|
|
433
|
+
failure_category: Optional[str] = None
|
|
434
|
+
plugin_error: Optional[PluginErrorMetadata] = None
|
|
321
435
|
|
|
322
436
|
@fastapi_app.get("/schema")
|
|
323
437
|
async def get_schema() -> SchemaOutputResponse:
|
|
@@ -332,6 +446,8 @@ def _wrap_in_fastapi(
|
|
|
332
446
|
return InvokePrecheckResponse(
|
|
333
447
|
status_code=fn_response.status_code,
|
|
334
448
|
status_code_text=fn_response.status_code_text,
|
|
449
|
+
failure_category=fn_response.failure_category,
|
|
450
|
+
plugin_error=fn_response.plugin_error,
|
|
335
451
|
usage=fn_response.usage,
|
|
336
452
|
)
|
|
337
453
|
else:
|
|
@@ -347,6 +463,13 @@ def _wrap_in_fastapi(
|
|
|
347
463
|
except TypeError as e:
|
|
348
464
|
raise TypeError(f"failed to validate function schema: {e}") from e
|
|
349
465
|
|
|
466
|
+
# Registered last so add_metadata_route replaces any /metadata the plugin registered itself.
|
|
467
|
+
add_metadata_route(
|
|
468
|
+
fastapi_app,
|
|
469
|
+
identifier=plugin_id,
|
|
470
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
471
|
+
)
|
|
472
|
+
|
|
350
473
|
FastAPIInstrumentor.instrument_app(
|
|
351
474
|
fastapi_app, tracer_provider=get_trace_provider(), meter_provider=get_metric_provider()
|
|
352
475
|
)
|
|
@@ -361,6 +484,7 @@ def generate_fast_api(
|
|
|
361
484
|
id_method: Optional[str] = None,
|
|
362
485
|
precheck_str: Optional[str] = None,
|
|
363
486
|
precheck_method: Optional[str] = None,
|
|
487
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
364
488
|
) -> FastAPI:
|
|
365
489
|
instance = import_from_string(app)
|
|
366
490
|
func = get_func(instance, method_name)
|
|
@@ -379,4 +503,9 @@ def generate_fast_api(
|
|
|
379
503
|
elif precheck_method:
|
|
380
504
|
precheck_func = get_func(instance, precheck_method)
|
|
381
505
|
|
|
382
|
-
return wrap_in_fastapi(
|
|
506
|
+
return wrap_in_fastapi(
|
|
507
|
+
func=func,
|
|
508
|
+
plugin_id=plugin_id,
|
|
509
|
+
precheck_func=precheck_func,
|
|
510
|
+
invoke_with_sealed_dag_node_settings_v2=invoke_with_sealed_dag_node_settings_v2,
|
|
511
|
+
)
|
|
@@ -56,6 +56,7 @@ def get_command() -> click.Command:
|
|
|
56
56
|
plugin_id_method: Optional[str] = None,
|
|
57
57
|
precheck_app: Optional[str] = None,
|
|
58
58
|
precheck_app_method: Optional[str] = None,
|
|
59
|
+
sealed_dag_node_settings_v2: bool = False,
|
|
59
60
|
**kwargs,
|
|
60
61
|
):
|
|
61
62
|
# Make sure logging is configured before the call to run() so any setup has the same format
|
|
@@ -73,6 +74,7 @@ def get_command() -> click.Command:
|
|
|
73
74
|
id_method=plugin_id_method,
|
|
74
75
|
precheck_str=precheck_app,
|
|
75
76
|
precheck_method=precheck_app_method,
|
|
77
|
+
invoke_with_sealed_dag_node_settings_v2=sealed_dag_node_settings_v2,
|
|
76
78
|
)
|
|
77
79
|
# Explicitly map values that are manipulated in the original
|
|
78
80
|
# call to run(), preventing **kwargs reference
|
|
@@ -130,6 +132,14 @@ def get_command() -> click.Command:
|
|
|
130
132
|
"If precheck-app not provided, assumes method "
|
|
131
133
|
"lives on main class passes in.",
|
|
132
134
|
),
|
|
135
|
+
click.Option(
|
|
136
|
+
["--sealed-dag-node-settings-v2"],
|
|
137
|
+
is_flag=True,
|
|
138
|
+
default=False,
|
|
139
|
+
help="Advertise the invoke_with_sealed_dag_node_settings_v2 capability on "
|
|
140
|
+
"/metadata. Set only for a plugin that consumes per-invoke settings "
|
|
141
|
+
"through current_invocation_settings().",
|
|
142
|
+
),
|
|
133
143
|
]
|
|
134
144
|
)
|
|
135
145
|
return cmd
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Schema-generated local wire-contract bindings."""
|
unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/error_audience_v1.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
|
|
2
|
+
# ruff: noqa: E501
|
|
3
|
+
# Source: https://schemas.u10d.dev/errors/audience/v1.json
|
|
4
|
+
"""Error Audience — generated from the ratified JSON Schema.
|
|
5
|
+
https://schemas.u10d.dev/errors/audience/v1
|
|
6
|
+
|
|
7
|
+
Non-null actionability audience carried by plugin_error.audience; distinct from technical fault-
|
|
8
|
+
source classifications such as utic_invocation_settings.Blame
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorAudienceValue(str, Enum):
|
|
17
|
+
"""Who can act to resolve an error."""
|
|
18
|
+
USER = "user"
|
|
19
|
+
PLATFORM = "platform"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Non-null actionability audience carried by plugin_error.audience; distinct from technical
|
|
23
|
+
# fault-source classifications such as utic_invocation_settings.Blame
|
|
24
|
+
ErrorAudience = ErrorAudienceValue
|
|
25
|
+
|
|
26
|
+
# Generation provenance used by drift tests and reviewers.
|
|
27
|
+
SCHEMA_ID = 'https://schemas.u10d.dev/errors/audience/v1.json'
|
|
28
|
+
SCHEMA_SHA256 = '36d017a1b132d0c06ded5a60376c425e8d5a5e681f134cfdc4dcb1fa4c9498d8'
|
unstructured_platform_plugins-0.1.0/unstructured_platform_plugins/generated/invocation_context_v1.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Generated by scripts/generate_invocation_contracts.py; do not edit by hand.
|
|
2
|
+
# ruff: noqa: E501
|
|
3
|
+
# Source: https://schemas.u10d.dev/invocation-context/v1.json
|
|
4
|
+
"""Invocation Context — generated from the ratified JSON Schema.
|
|
5
|
+
https://schemas.u10d.dev/invocation-context/v1
|
|
6
|
+
|
|
7
|
+
Versioned request-scoped identity and correlation context carried in the reserved
|
|
8
|
+
invocation_context field of a plugin /invoke body
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvocationContext(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Versioned request-scoped identity and correlation context carried in the reserved
|
|
21
|
+
invocation_context field of a plugin /invoke body
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="allow")
|
|
25
|
+
|
|
26
|
+
schema_version: Literal["1"] = Field(
|
|
27
|
+
default="1",
|
|
28
|
+
description="Payload contract version. A missing value defaults to v1; consumers reject an unknown value rather than silently dropping attribution. Additive fields do not change this value.",
|
|
29
|
+
)
|
|
30
|
+
invocation_id: str | None = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Correlation identifier for this individual invocation.",
|
|
33
|
+
)
|
|
34
|
+
job_id: str | None = Field(default=None, description="Job that owns the invocation.")
|
|
35
|
+
workflow_id: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Workflow that owns the job, when known.",
|
|
38
|
+
)
|
|
39
|
+
attribution_id: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="Platform attribution identifier associated with the invocation.",
|
|
42
|
+
)
|
|
43
|
+
tenant_id: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Tenant identity to which the invocation is attributed.",
|
|
46
|
+
)
|
|
47
|
+
org_id: str | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="Organization identity to which the invocation is attributed.",
|
|
50
|
+
)
|
|
51
|
+
dag_node_id: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Identifier of the DAG node being invoked.",
|
|
54
|
+
)
|
|
55
|
+
dag_node_type: str | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Plugin type of the DAG node being invoked.",
|
|
58
|
+
)
|
|
59
|
+
dag_node_subtype: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="Plugin subtype of the DAG node being invoked.",
|
|
62
|
+
)
|
|
63
|
+
record_id: str | None = Field(
|
|
64
|
+
default=None,
|
|
65
|
+
description="Record being processed by a single-record invocation.",
|
|
66
|
+
)
|
|
67
|
+
attempt: int | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Attempt number associated with the claimed work.",
|
|
70
|
+
)
|
|
71
|
+
job_created_timestamp: str | None = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description="Job creation timestamp forwarded for lifecycle timing. It is context metadata, not a telemetry dimension.",
|
|
74
|
+
)
|
|
75
|
+
record_ids: list[str] | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Record identifiers in a batch invocation. When invocation_ids is also present, the two arrays are index-aligned.",
|
|
78
|
+
)
|
|
79
|
+
invocation_ids: list[str | None] | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Per-record invocation identifiers for a batch invocation. Entry i identifies record_ids[i]; null means that record carried no invocation identifier.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Generation provenance used by drift tests and reviewers.
|
|
85
|
+
SCHEMA_ID = 'https://schemas.u10d.dev/invocation-context/v1.json'
|
|
86
|
+
SCHEMA_SHA256 = 'ca64be22a104acc697b7aff0fb47633ad75fa30cd88a0c92259af732fa1023c0'
|
|
87
|
+
RESERVED_CONTEXT_KEY = 'invocation_context'
|
|
88
|
+
SUPPORTED_CONTEXT_VERSIONS = frozenset(['1'])
|
|
89
|
+
DIMENSION_FIELDS = ('invocation_id', 'tenant_id', 'org_id', 'job_id', 'workflow_id', 'attribution_id', 'dag_node_id', 'dag_node_type', 'dag_node_subtype', 'record_id', 'attempt')
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The ``invocation_context`` companion to the settings envelope.
|
|
2
|
+
|
|
3
|
+
Where ``invocation_settings`` carries *what* a plugin should be configured with, the context
|
|
4
|
+
carries *who* the invocation is for: the identity facets a shared-tenancy pod can no longer read
|
|
5
|
+
from its process environment. It travels in a second reserved, out-of-schema field of the
|
|
6
|
+
``/invoke`` body, extracted by the same route dependency that resolves the settings field.
|
|
7
|
+
|
|
8
|
+
The context is `/invoke` protocol identity, not settings security: it touches no crypto and no
|
|
9
|
+
secrets, and it evolves with the plugin protocol this package defines. The errors it raises come
|
|
10
|
+
from the shared ``InvocationSettingsError`` taxonomy so hosts classify context failures with the
|
|
11
|
+
same ``reason``/``blame`` machinery as settings failures.
|
|
12
|
+
|
|
13
|
+
The model below is the **consumer** view of that contract, deliberately lenient: unknown keys are
|
|
14
|
+
preserved for additive forward compatibility, and every identity field is optional so a
|
|
15
|
+
partially-populated context degrades to "less telemetry" rather than a failed invoke. The one thing
|
|
16
|
+
it is strict about is ``schema_version`` — that field makes incompatible producer and consumer
|
|
17
|
+
contracts detectable. The payload carries the version, so evolving the contract does not require
|
|
18
|
+
adding endpoints.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Mapping
|
|
24
|
+
|
|
25
|
+
import pydantic
|
|
26
|
+
from utic_invocation_settings import Blame, InvocationSettingsError, MalformedEnvelopeError
|
|
27
|
+
|
|
28
|
+
from unstructured_platform_plugins.generated.invocation_context_v1 import (
|
|
29
|
+
DIMENSION_FIELDS,
|
|
30
|
+
RESERVED_CONTEXT_KEY,
|
|
31
|
+
SUPPORTED_CONTEXT_VERSIONS,
|
|
32
|
+
)
|
|
33
|
+
from unstructured_platform_plugins.generated.invocation_context_v1 import (
|
|
34
|
+
InvocationContext as _GeneratedInvocationContext,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Sentinel distinguishing a truly-absent reserved key from one present with a ``None`` value.
|
|
38
|
+
_ABSENT = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnsupportedContextVersionError(InvocationSettingsError):
|
|
42
|
+
"""The ``invocation_context`` declares a ``schema_version`` this package does not understand.
|
|
43
|
+
|
|
44
|
+
This indicates deployment skew between platform components, not a fault in the request.
|
|
45
|
+
``CONTENT`` (a 5xx) rather than ``CALLER``: contexts are produced by the platform's own claim
|
|
46
|
+
pipeline, and a 422 would make an upstream blame classifier pin a version-skew failure on the
|
|
47
|
+
customer. Failing the request prevents an unreadable context from silently removing telemetry
|
|
48
|
+
dimensions.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
reason = "unsupported_context_version"
|
|
52
|
+
blame = Blame.CONTENT
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvocationContext(_GeneratedInvocationContext):
|
|
56
|
+
"""Request-scoped identity delivered alongside one claimed unit of work.
|
|
57
|
+
|
|
58
|
+
The field shape, version literal, extra-field policy, reserved key, and dimensions are generated
|
|
59
|
+
from the ratified schema. This adapter retains the cross-field invariant JSON Schema cannot
|
|
60
|
+
express.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@pydantic.model_validator(mode="after")
|
|
64
|
+
def _batch_lists_stay_index_aligned(self) -> "InvocationContext":
|
|
65
|
+
if (
|
|
66
|
+
self.record_ids is not None
|
|
67
|
+
and self.invocation_ids is not None
|
|
68
|
+
and len(self.record_ids) != len(self.invocation_ids)
|
|
69
|
+
):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"record_ids and invocation_ids must be the same length: "
|
|
72
|
+
"entry i of invocation_ids describes record i"
|
|
73
|
+
)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def extract_context(payload: Mapping[str, Any]) -> InvocationContext | None:
|
|
77
|
+
"""Return the :class:`InvocationContext` from ``payload[RESERVED_CONTEXT_KEY]``.
|
|
78
|
+
|
|
79
|
+
Returns ``None`` only when the producer omitted the reserved key. A present-but-invalid value
|
|
80
|
+
fails closed rather than degrading to "no context", because a context that silently vanishes
|
|
81
|
+
takes a pod's tenant attribution with it.
|
|
82
|
+
|
|
83
|
+
A recognizable context carrying an unknown ``schema_version`` raises
|
|
84
|
+
:class:`UnsupportedContextVersionError` so an incompatible contract cannot be mistaken for
|
|
85
|
+
absent context.
|
|
86
|
+
"""
|
|
87
|
+
raw = payload.get(RESERVED_CONTEXT_KEY, _ABSENT)
|
|
88
|
+
if raw is _ABSENT:
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(raw, InvocationContext):
|
|
91
|
+
return raw
|
|
92
|
+
try:
|
|
93
|
+
return InvocationContext.model_validate(raw)
|
|
94
|
+
except pydantic.ValidationError as exc:
|
|
95
|
+
errors = exc.errors()
|
|
96
|
+
# Only a well-typed version string this package does not know reads as deployment skew; a
|
|
97
|
+
# schema_version of the wrong type is the caller's malformed context like any other field.
|
|
98
|
+
reported = _reported_version(raw)
|
|
99
|
+
if isinstance(reported, str) and any(
|
|
100
|
+
error["loc"] == ("schema_version",) for error in errors
|
|
101
|
+
):
|
|
102
|
+
raise UnsupportedContextVersionError(
|
|
103
|
+
f"unsupported invocation_context schema_version: "
|
|
104
|
+
f"{_reported_version(raw)!r}; expected one of {sorted(SUPPORTED_CONTEXT_VERSIONS)}"
|
|
105
|
+
) from None
|
|
106
|
+
# `from None` so the pydantic error tree does not cross the domain-error boundary; a count
|
|
107
|
+
# plus the first message is enough signal. Mirrors the envelope extraction.
|
|
108
|
+
raise MalformedEnvelopeError(
|
|
109
|
+
f"invalid invocation_context: {exc.error_count()} validation error(s), "
|
|
110
|
+
f"first: {errors[0]['msg']}"
|
|
111
|
+
) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reported_version(raw: Any) -> Any:
|
|
115
|
+
"""The offending ``schema_version``, for the error message only. Never trusted."""
|
|
116
|
+
return raw.get("schema_version") if isinstance(raw, Mapping) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def dimensions(context: InvocationContext | None) -> dict[str, Any]:
|
|
120
|
+
"""The context's populated identity facets, ready to bind as telemetry dimensions."""
|
|
121
|
+
if context is None:
|
|
122
|
+
return {}
|
|
123
|
+
return {
|
|
124
|
+
field: value for field in DIMENSION_FIELDS if (value := getattr(context, field)) is not None
|
|
125
|
+
}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Transport for the reserved `/invoke` fields: request dependency, `/metadata`, optional body cap.
|
|
2
|
+
|
|
3
|
+
The *settings contract* — including the only accepted sealed `/invoke` shape (the v2 settings
|
|
4
|
+
document), field-level resolution, and what an absent field is allowed to mean — lives in
|
|
5
|
+
`utic_invocation_settings`, next to the crypto it governs; every decision about a settings payload
|
|
6
|
+
is delegated there. The *identity contract* —
|
|
7
|
+
the `invocation_context` model — is
|
|
8
|
+
`/invoke` protocol rather than settings security and lives in this package's
|
|
9
|
+
`invocation_context` module. This module is the delivery mechanism for both: getting the payloads
|
|
10
|
+
off the wire and the results to the handler, and spelling the shared `blame` taxonomy as HTTP
|
|
11
|
+
statuses.
|
|
12
|
+
|
|
13
|
+
The reserved fields are a first-class HTTP contract independent of the generated input schema.
|
|
14
|
+
They never appear in a plugin's declared signature, so `wrap_in_fastapi` keeps producing a handler
|
|
15
|
+
model built purely from the wrapped function, and a plugin reads the fields through
|
|
16
|
+
`current_invocation_settings()` / `current_invocation_context()` instead.
|
|
17
|
+
|
|
18
|
+
Well-formed extraction runs in a route dependency (`bind_invocation_envelope`), so the `/invoke`
|
|
19
|
+
body is buffered and parsed exactly once: Starlette caches both the bytes and the parsed JSON on
|
|
20
|
+
the `Request`, and the dependency reads that cached parse. Generated routes can reject malformed
|
|
21
|
+
JSON while validating their typed body before dependencies run, so the installer normalizes that
|
|
22
|
+
specific validation failure to the same transport error. Hosts that have established a safe
|
|
23
|
+
request ceiling may opt into `InvokeBodyLimitMiddleware`, which counts bytes as they stream through
|
|
24
|
+
without buffering. Wrapped plugins do not acquire a fleet-wide limit merely by upgrading this
|
|
25
|
+
package.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import hashlib
|
|
32
|
+
import inspect
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from collections import OrderedDict
|
|
38
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from contextvars import ContextVar
|
|
41
|
+
from typing import Any, Optional, TypeVar
|
|
42
|
+
|
|
43
|
+
from fastapi import Depends, FastAPI, Request
|
|
44
|
+
from fastapi.exception_handlers import request_validation_exception_handler
|
|
45
|
+
from fastapi.exceptions import RequestValidationError
|
|
46
|
+
from opentelemetry import trace
|
|
47
|
+
from starlette.requests import ClientDisconnect
|
|
48
|
+
from starlette.responses import JSONResponse
|
|
49
|
+
from starlette.routing import get_route_path
|
|
50
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
51
|
+
from utic_invocation_settings import (
|
|
52
|
+
INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY,
|
|
53
|
+
RESERVED_ENVELOPE_KEY,
|
|
54
|
+
Blame,
|
|
55
|
+
InvocationSettingsError,
|
|
56
|
+
MalformedEnvelopeError,
|
|
57
|
+
resolve_invocation_settings,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from unstructured_platform_plugins.invocation_context import (
|
|
61
|
+
RESERVED_CONTEXT_KEY,
|
|
62
|
+
InvocationContext,
|
|
63
|
+
dimensions,
|
|
64
|
+
extract_context,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def http_status_for(error: BaseException) -> int:
|
|
71
|
+
"""The HTTP status this transport answers for a failed resolution, from ``blame``.
|
|
72
|
+
|
|
73
|
+
One rule, the one the library README states normatively: ``Blame.CALLER`` -> 422, everything
|
|
74
|
+
else -> 500. The line it draws is whether a different request would work. Sealing drift, an
|
|
75
|
+
envelope for another recipient and a broken local mount are all 5xx, which keeps a controller's
|
|
76
|
+
blame classification off the customer, whose request was fine. Anything that is not a
|
|
77
|
+
classified error is a 500: an unclassified failure is not the caller's.
|
|
78
|
+
"""
|
|
79
|
+
return 422 if getattr(error, "blame", None) is Blame.CALLER else 500
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
T = TypeVar("T")
|
|
83
|
+
|
|
84
|
+
_METADATA_PATH = "/metadata"
|
|
85
|
+
_INVOKE_PATH = "/invoke"
|
|
86
|
+
|
|
87
|
+
# A convenient opt-in ceiling for hosts that have verified it against their request distribution.
|
|
88
|
+
# It is deliberately not the install default: batch invokes carry arrays of file_data payloads,
|
|
89
|
+
# and a wrapper upgrade must not reject previously valid fleet traffic without an explicit choice.
|
|
90
|
+
MAX_INVOKE_BODY_BYTES = 64 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
_INVOCATION: ContextVar[tuple[Optional[dict], Optional[InvocationContext]]] = ContextVar(
|
|
93
|
+
"invocation", default=(None, None)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_invocation_settings() -> Optional[dict]:
|
|
98
|
+
"""Reserved `invocation_settings` field bound for the current request, if any.
|
|
99
|
+
|
|
100
|
+
`None` means the field was genuinely absent — the only case in which a plugin may fall back to
|
|
101
|
+
its boot-time settings. A field that arrived and could not be opened never reaches a handler:
|
|
102
|
+
the binding dependency fails the request first.
|
|
103
|
+
"""
|
|
104
|
+
return _INVOCATION.get()[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def current_invocation_context() -> Optional[InvocationContext]:
|
|
108
|
+
"""Reserved `invocation_context` field bound for the current request, if any."""
|
|
109
|
+
return _INVOCATION.get()[1]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def invocation_envelope(
|
|
114
|
+
invocation_settings: Optional[dict], invocation_context: Optional[InvocationContext]
|
|
115
|
+
) -> Iterator[None]:
|
|
116
|
+
"""Bind the reserved /invoke fields for the current context."""
|
|
117
|
+
token = _INVOCATION.set((invocation_settings, invocation_context))
|
|
118
|
+
try:
|
|
119
|
+
yield
|
|
120
|
+
finally:
|
|
121
|
+
_INVOCATION.reset(token)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def add_metadata_route(
|
|
125
|
+
app: FastAPI,
|
|
126
|
+
identifier: Optional[str] = None,
|
|
127
|
+
invoke_with_sealed_dag_node_settings_v2: bool = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Register GET /metadata advertising the reserved /invoke fields this plugin accepts.
|
|
130
|
+
|
|
131
|
+
`/metadata` is the plugin API spec's own discovery surface (`PluginMetadataOutput`): capability
|
|
132
|
+
flags are strings in its `capabilities` list, which is where the controller looks before
|
|
133
|
+
forwarding the reserved fields — no controller-private probe route.
|
|
134
|
+
|
|
135
|
+
`invocation_settings` and `invocation_context` are transport capabilities: installing the
|
|
136
|
+
dependency makes the host receive, resolve, and bind those fields. The sealed-settings
|
|
137
|
+
capability is stronger: it tells the controller that the plugin handler consumes the resolved
|
|
138
|
+
field-level v2 settings in place of boot-time state. The controller may therefore send the
|
|
139
|
+
versioned v2 document, so this remains an explicit opt-in.
|
|
140
|
+
|
|
141
|
+
Last call wins: the payload lives on `app.state` and every call overwrites it, while the route
|
|
142
|
+
is registered once. A host wrapper may register at app construction and a plugin can still
|
|
143
|
+
re-register with its own identifier afterwards, with no route-order dependence.
|
|
144
|
+
"""
|
|
145
|
+
capabilities = [RESERVED_ENVELOPE_KEY, RESERVED_CONTEXT_KEY]
|
|
146
|
+
if invoke_with_sealed_dag_node_settings_v2:
|
|
147
|
+
capabilities.append(INVOKE_WITH_SEALED_DAG_NODE_SETTINGS_V2_CAPABILITY)
|
|
148
|
+
app.state.plugin_metadata_payload = {
|
|
149
|
+
"api_version": "3",
|
|
150
|
+
"identifier": identifier,
|
|
151
|
+
"capabilities": capabilities,
|
|
152
|
+
}
|
|
153
|
+
if getattr(app.state, "plugin_metadata_route_installed", False):
|
|
154
|
+
return
|
|
155
|
+
app.state.plugin_metadata_route_installed = True
|
|
156
|
+
|
|
157
|
+
# A /metadata route registered by the application itself would win by route order and pin its
|
|
158
|
+
# own stale payload; drop it so the last add_metadata_route call is the one that answers.
|
|
159
|
+
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != _METADATA_PATH]
|
|
160
|
+
|
|
161
|
+
@app.get(_METADATA_PATH)
|
|
162
|
+
async def plugin_metadata() -> dict:
|
|
163
|
+
return app.state.plugin_metadata_payload
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class UnusableInvocationEnvelope(Exception):
|
|
167
|
+
"""A reserved /invoke field arrived but cannot be used.
|
|
168
|
+
|
|
169
|
+
Raised by `bind_invocation_envelope` and answered by the handler
|
|
170
|
+
`install_invocation_envelope` registers, so the response shape — `detail` plus the library's
|
|
171
|
+
stable `reason` code as top-level siblings — stays what orchestrators parse, independent of
|
|
172
|
+
FastAPI's own error envelope.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, status_code: int, payload: dict):
|
|
176
|
+
super().__init__(payload.get("detail"))
|
|
177
|
+
self.status_code = status_code
|
|
178
|
+
self.payload = payload
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _unusable_envelope_response(
|
|
182
|
+
_request: Request, exc: UnusableInvocationEnvelope
|
|
183
|
+
) -> JSONResponse:
|
|
184
|
+
return JSONResponse(status_code=exc.status_code, content=exc.payload)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_malformed_json_validation(exc: RequestValidationError) -> bool:
|
|
188
|
+
"""Whether FastAPI rejected the request body before route dependencies could run."""
|
|
189
|
+
# A top-level JSONDecodeError stores the raw document and reports its integer parser offset as
|
|
190
|
+
# exactly ("body", offset). Pydantic's Json fields also emit `json_invalid`, but by then the
|
|
191
|
+
# outer request has been decoded into a mapping/list and the location continues through the
|
|
192
|
+
# model field. Those are ordinary schema-validation errors and must retain FastAPI's detail.
|
|
193
|
+
if not isinstance(exc.body, (str, bytes, bytearray)):
|
|
194
|
+
return False
|
|
195
|
+
return any(
|
|
196
|
+
error.get("type") == "json_invalid"
|
|
197
|
+
and len(location := tuple(error.get("loc", ()))) == 2
|
|
198
|
+
and location[0] == "body"
|
|
199
|
+
and isinstance(location[1], int)
|
|
200
|
+
for error in exc.errors()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def bind_invocation_envelope(request: Request) -> AsyncIterator[None]:
|
|
205
|
+
"""Resolve the reserved /invoke fields and bind them for the duration of the request.
|
|
206
|
+
|
|
207
|
+
Runs as a route dependency, after the framework has read the body: `request.json()` is
|
|
208
|
+
Starlette-cached, so the parse is shared with the framework's own body handling.
|
|
209
|
+
A reserved field that is present but unusable fails the request rather than being
|
|
210
|
+
treated as absent, because absence is the signal to fall back to the boot-time settings file:
|
|
211
|
+
degrading a malformed field to absence would quietly answer a request configured for one
|
|
212
|
+
tenant with whatever the pod happened to boot with. Under
|
|
213
|
+
`FF_INVOCATION_SETTINGS` there is no settings file to fall back to,
|
|
214
|
+
so absent or plaintext settings fail too — as does any /invoke whose body is not a JSON
|
|
215
|
+
object, since such a body cannot carry the envelope a native pod requires.
|
|
216
|
+
"""
|
|
217
|
+
# ASGI `path` includes any deployment root_path; get_route_path strips it, which is how the
|
|
218
|
+
# router itself matches, so binding fires exactly when the /invoke route does.
|
|
219
|
+
if request.method != "POST" or get_route_path(request.scope) != _INVOKE_PATH:
|
|
220
|
+
yield
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
parsed = await request.json()
|
|
225
|
+
except ValueError:
|
|
226
|
+
# request.json() reads through Starlette's cached body, so this does not consume or buffer
|
|
227
|
+
# the stream a second time. A truly empty body is absence and remains subject to the
|
|
228
|
+
# native-pod policy below. Any bytes that fail JSON parsing are a malformed request, not
|
|
229
|
+
# absence: collapsing them would turn a caller-fixable syntax error into a native pod's
|
|
230
|
+
# SealedDagNodeSettingsRequiredError (RECIPIENT -> 500).
|
|
231
|
+
if await request.body():
|
|
232
|
+
raise UnusableInvocationEnvelope(
|
|
233
|
+
422,
|
|
234
|
+
{
|
|
235
|
+
"detail": "Invalid JSON body",
|
|
236
|
+
"reason": MalformedEnvelopeError.reason,
|
|
237
|
+
},
|
|
238
|
+
) from None
|
|
239
|
+
parsed = None
|
|
240
|
+
|
|
241
|
+
raw_settings: Optional[Any] = None
|
|
242
|
+
if isinstance(parsed, dict):
|
|
243
|
+
raw_settings = parsed.get(RESERVED_ENVELOPE_KEY)
|
|
244
|
+
if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict):
|
|
245
|
+
raise UnusableInvocationEnvelope(
|
|
246
|
+
422,
|
|
247
|
+
{
|
|
248
|
+
"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}",
|
|
249
|
+
"reason": MalformedEnvelopeError.reason,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
# Off the event loop: resolution may perform blocking cryptography for independently
|
|
254
|
+
# sealed fields, and this dependency fronts every invoke on the pod.
|
|
255
|
+
invocation_settings = await asyncio.to_thread(resolve_invocation_settings, raw_settings)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# Class name only — never envelope contents, and never the exception's own message,
|
|
258
|
+
# which can embed request-controlled values.
|
|
259
|
+
logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__)
|
|
260
|
+
payload = {"detail": f"Unusable invocation settings: {type(exc).__name__}"}
|
|
261
|
+
reason = getattr(exc, "reason", None)
|
|
262
|
+
if isinstance(reason, str):
|
|
263
|
+
payload["reason"] = reason
|
|
264
|
+
raise UnusableInvocationEnvelope(http_status_for(exc), payload) from exc
|
|
265
|
+
|
|
266
|
+
invocation_context: Optional[InvocationContext] = None
|
|
267
|
+
if isinstance(parsed, dict):
|
|
268
|
+
try:
|
|
269
|
+
invocation_context = extract_context(parsed)
|
|
270
|
+
except InvocationSettingsError as exc:
|
|
271
|
+
# A context this plugin cannot read fails loudly here rather than running with
|
|
272
|
+
# silently absent identity. Status comes from the blame taxonomy: a malformed
|
|
273
|
+
# field is the caller's 422, but an unreadable schema_version is deployment skew
|
|
274
|
+
# between platform components and must not read as a caller fault. The log line is
|
|
275
|
+
# truncated because the message can embed request-controlled values.
|
|
276
|
+
logger.warning("rejecting invalid %s: %.200s", RESERVED_CONTEXT_KEY, exc)
|
|
277
|
+
status = http_status_for(exc)
|
|
278
|
+
detail = (
|
|
279
|
+
f"Invalid field: {RESERVED_CONTEXT_KEY}"
|
|
280
|
+
if status == 422
|
|
281
|
+
else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}"
|
|
282
|
+
)
|
|
283
|
+
raise UnusableInvocationEnvelope(
|
|
284
|
+
status, {"detail": detail, "reason": exc.reason}
|
|
285
|
+
) from exc
|
|
286
|
+
|
|
287
|
+
# Keep plugin-side request spans aligned with the controller's wide-event dimensions. The
|
|
288
|
+
# ratified DIMENSION_FIELDS list deliberately excludes batch correlation and unknown additive
|
|
289
|
+
# fields, so only the shared invocation identity is promoted to indexed telemetry.
|
|
290
|
+
invocation_dimensions = dimensions(invocation_context)
|
|
291
|
+
if invocation_dimensions:
|
|
292
|
+
span = trace.get_current_span()
|
|
293
|
+
for key, value in invocation_dimensions.items():
|
|
294
|
+
span.set_attribute(key, value)
|
|
295
|
+
|
|
296
|
+
with invocation_envelope(invocation_settings, invocation_context):
|
|
297
|
+
yield
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _send_json(send: Send, status_code: int, payload: dict) -> None:
|
|
301
|
+
body = json.dumps(payload).encode()
|
|
302
|
+
await send(
|
|
303
|
+
{
|
|
304
|
+
"type": "http.response.start",
|
|
305
|
+
"status": status_code,
|
|
306
|
+
"headers": [
|
|
307
|
+
(b"content-type", b"application/json"),
|
|
308
|
+
(b"content-length", str(len(body)).encode()),
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
await send({"type": "http.response.body", "body": body})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class InvokeBodyLimitMiddleware:
|
|
316
|
+
"""Reject a POST /invoke body over ``max_body_bytes`` with 413.
|
|
317
|
+
|
|
318
|
+
Counts bytes as the framework consumes them; nothing is buffered here. When the count crosses
|
|
319
|
+
the cap the downstream read is answered with ``http.disconnect``, which aborts the framework's
|
|
320
|
+
body read before another byte is held, and the 413 is sent once the application has unwound.
|
|
321
|
+
This has to sit below the framework because neither Starlette nor uvicorn bounds request-body
|
|
322
|
+
size.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(self, app: ASGIApp, max_body_bytes: int = MAX_INVOKE_BODY_BYTES):
|
|
326
|
+
self.app = app
|
|
327
|
+
self.max_body_bytes = max_body_bytes
|
|
328
|
+
|
|
329
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
330
|
+
if (
|
|
331
|
+
scope["type"] != "http"
|
|
332
|
+
or scope.get("method") != "POST"
|
|
333
|
+
or get_route_path(scope) != _INVOKE_PATH
|
|
334
|
+
):
|
|
335
|
+
await self.app(scope, receive, send)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
seen = 0
|
|
339
|
+
exceeded = False
|
|
340
|
+
response_started = False
|
|
341
|
+
|
|
342
|
+
async def counting_receive() -> dict:
|
|
343
|
+
nonlocal seen, exceeded
|
|
344
|
+
message = await receive()
|
|
345
|
+
if message["type"] == "http.request":
|
|
346
|
+
seen += len(message.get("body", b""))
|
|
347
|
+
if seen > self.max_body_bytes:
|
|
348
|
+
exceeded = True
|
|
349
|
+
return {"type": "http.disconnect"}
|
|
350
|
+
return message
|
|
351
|
+
|
|
352
|
+
async def guarded_send(message: dict) -> None:
|
|
353
|
+
nonlocal response_started
|
|
354
|
+
if exceeded and not response_started:
|
|
355
|
+
# A response computed after the body was cut is answering a truncated request;
|
|
356
|
+
# drop it so the 413 below is what the caller sees. A response that started
|
|
357
|
+
# before the cap tripped keeps streaming — its start is already on the wire.
|
|
358
|
+
return
|
|
359
|
+
if message["type"] == "http.response.start":
|
|
360
|
+
response_started = True
|
|
361
|
+
await send(message)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
await self.app(scope, counting_receive, guarded_send)
|
|
365
|
+
except ClientDisconnect:
|
|
366
|
+
if not exceeded:
|
|
367
|
+
raise
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
# The cut body stream can surface downstream as something other than
|
|
370
|
+
# ClientDisconnect; once the cap is the cause, the 413 below is the answer. Retain a
|
|
371
|
+
# type-only diagnostic for a coincident downstream bug without rendering its message,
|
|
372
|
+
# which may contain request data or credentials.
|
|
373
|
+
if not exceeded:
|
|
374
|
+
raise
|
|
375
|
+
logger.debug(
|
|
376
|
+
"downstream raised after /invoke body limit was exceeded: %s",
|
|
377
|
+
type(exc).__name__,
|
|
378
|
+
)
|
|
379
|
+
if exceeded and not response_started:
|
|
380
|
+
await _send_json(send, 413, {"detail": "Request body too large"})
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def install_invocation_envelope(app: FastAPI, max_body_bytes: int | None = None) -> None:
|
|
384
|
+
"""Install reserved-field binding before registering a FastAPI app's routes.
|
|
385
|
+
|
|
386
|
+
Adds `bind_invocation_envelope` through the router's public dependency list and registers the
|
|
387
|
+
failure response shape. FastAPI parses a generated route's typed body before dependencies run,
|
|
388
|
+
so malformed JSON validation is normalized to that same response shape at the app boundary;
|
|
389
|
+
every other validation error retains the handler the app already had. When ``max_body_bytes``
|
|
390
|
+
is supplied, also installs the body-size cap beneath the framework. The cap is opt-in because
|
|
391
|
+
a wrapper upgrade must not impose an unvalidated fleet-wide request limit. The dependency is a
|
|
392
|
+
path/method-aware no-op outside POST /invoke, so routes registered after this call can all
|
|
393
|
+
inherit it without private dependency-graph mutation. Calling after routes have already been
|
|
394
|
+
registered raises rather than silently leaving those routes uncovered.
|
|
395
|
+
|
|
396
|
+
Idempotent per app because both host-wrapper and plugin setup may call this function, while a
|
|
397
|
+
double installation would resolve settings twice per request. A repeated call asking for a
|
|
398
|
+
different ``max_body_bytes`` raises because the installed configuration cannot be changed and
|
|
399
|
+
silently keeping the first value would misrepresent the limit actually enforced.
|
|
400
|
+
"""
|
|
401
|
+
if getattr(app.state, "invocation_envelope_installed", False):
|
|
402
|
+
installed_max = app.state.invocation_envelope_max_body_bytes
|
|
403
|
+
if max_body_bytes != installed_max:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"install_invocation_envelope already installed with "
|
|
406
|
+
f"max_body_bytes={installed_max}; cannot reinstall with {max_body_bytes}"
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
if any(
|
|
410
|
+
getattr(route, "path", None) == _INVOKE_PATH
|
|
411
|
+
and "POST" in (getattr(route, "methods", None) or set())
|
|
412
|
+
for route in app.router.routes
|
|
413
|
+
):
|
|
414
|
+
raise RuntimeError(
|
|
415
|
+
"install_invocation_envelope must be called before the POST /invoke route is registered"
|
|
416
|
+
)
|
|
417
|
+
app.state.invocation_envelope_installed = True
|
|
418
|
+
app.state.invocation_envelope_max_body_bytes = max_body_bytes
|
|
419
|
+
app.router.dependencies.append(Depends(bind_invocation_envelope))
|
|
420
|
+
if max_body_bytes is not None:
|
|
421
|
+
app.add_middleware(InvokeBodyLimitMiddleware, max_body_bytes=max_body_bytes)
|
|
422
|
+
app.add_exception_handler(UnusableInvocationEnvelope, _unusable_envelope_response)
|
|
423
|
+
|
|
424
|
+
previous_validation_handler = app.exception_handlers.get(
|
|
425
|
+
RequestValidationError, request_validation_exception_handler
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def invocation_request_validation_response(request: Request, exc: RequestValidationError):
|
|
429
|
+
if (
|
|
430
|
+
request.method == "POST"
|
|
431
|
+
and get_route_path(request.scope) == _INVOKE_PATH
|
|
432
|
+
and _is_malformed_json_validation(exc)
|
|
433
|
+
):
|
|
434
|
+
return JSONResponse(
|
|
435
|
+
status_code=422,
|
|
436
|
+
content={
|
|
437
|
+
"detail": "Invalid JSON body",
|
|
438
|
+
"reason": MalformedEnvelopeError.reason,
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
response = previous_validation_handler(request, exc)
|
|
442
|
+
return await response if inspect.isawaitable(response) else response
|
|
443
|
+
|
|
444
|
+
app.add_exception_handler(RequestValidationError, invocation_request_validation_response)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str:
|
|
448
|
+
"""Digest of an ordinary resolved settings mapping, safe for secret-bearing values."""
|
|
449
|
+
return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class SettingsScopedCache:
|
|
453
|
+
"""Bind expensive derived state (clients, models, handlers) to the settings that built it.
|
|
454
|
+
|
|
455
|
+
A plugin consuming ``current_invocation_settings()`` derives a handler from each distinct
|
|
456
|
+
resolved mapping. Construction typically performs network work (model resolution, prechecks),
|
|
457
|
+
so results are memoized by ``settings_cache_key``. Raw field envelopes never reach this cache:
|
|
458
|
+
the public settings library resolves and caches them at the field boundary before this
|
|
459
|
+
transport binds the result. Both bounds matter under shared tenancy: size caps how many
|
|
460
|
+
distinct mappings stay live, and age evicts state after credential rotation. Eviction driven
|
|
461
|
+
only by the count of distinct mappings can take arbitrarily long on a quiet pod.
|
|
462
|
+
|
|
463
|
+
Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice;
|
|
464
|
+
the extra build is wasted work, never wrong state.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
def __init__(
|
|
468
|
+
self,
|
|
469
|
+
*,
|
|
470
|
+
ttl_seconds: float = 15 * 60,
|
|
471
|
+
maxsize: int = 32,
|
|
472
|
+
clock: Callable[[], float] = time.monotonic,
|
|
473
|
+
) -> None:
|
|
474
|
+
if ttl_seconds <= 0:
|
|
475
|
+
raise ValueError("ttl_seconds must be positive")
|
|
476
|
+
if maxsize < 1:
|
|
477
|
+
raise ValueError("maxsize must be at least 1")
|
|
478
|
+
self._ttl_seconds = float(ttl_seconds)
|
|
479
|
+
self._maxsize = maxsize
|
|
480
|
+
self._clock = clock
|
|
481
|
+
self._lock = threading.Lock()
|
|
482
|
+
self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
483
|
+
|
|
484
|
+
def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[], T]) -> T:
|
|
485
|
+
"""Return the cached value for these settings, building it on a miss."""
|
|
486
|
+
key = settings_cache_key(invocation_settings)
|
|
487
|
+
now = self._clock()
|
|
488
|
+
with self._lock:
|
|
489
|
+
entry = self._entries.get(key)
|
|
490
|
+
if entry is not None:
|
|
491
|
+
expires_at, value = entry
|
|
492
|
+
if now < expires_at:
|
|
493
|
+
self._entries.move_to_end(key)
|
|
494
|
+
return value
|
|
495
|
+
del self._entries[key]
|
|
496
|
+
value = build()
|
|
497
|
+
with self._lock:
|
|
498
|
+
# Every insert also sweeps entries whose TTL has lapsed, so a tenant that stops
|
|
499
|
+
# sending requests does not keep its credential-bearing handler live while the pod
|
|
500
|
+
# stays busy for others; per-key expiry alone only fires on that tenant's next hit.
|
|
501
|
+
# Re-read the clock: build() may take long enough for more entries to lapse.
|
|
502
|
+
now = self._clock()
|
|
503
|
+
for stale_key, (expires_at, _) in list(self._entries.items()):
|
|
504
|
+
if now >= expires_at:
|
|
505
|
+
del self._entries[stale_key]
|
|
506
|
+
self._entries[key] = (now + self._ttl_seconds, value)
|
|
507
|
+
self._entries.move_to_end(key)
|
|
508
|
+
while len(self._entries) > self._maxsize:
|
|
509
|
+
self._entries.popitem(last=False)
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
def handler_for(
|
|
513
|
+
self,
|
|
514
|
+
resolved_settings: Optional[Mapping[str, Any]],
|
|
515
|
+
*,
|
|
516
|
+
boot: Callable[[], Optional[T]],
|
|
517
|
+
build: Callable[[], T],
|
|
518
|
+
) -> T:
|
|
519
|
+
"""The handler for a request: cached per distinct resolved mapping, or the boot fallback.
|
|
520
|
+
|
|
521
|
+
Absent settings select the single handler configured by the boot-time settings file.
|
|
522
|
+
``boot`` returning ``None`` means the pod has no boot configuration and sealed per-invoke
|
|
523
|
+
settings are required, so the request fails rather than running an unconfigured handler.
|
|
524
|
+
"""
|
|
525
|
+
if resolved_settings is None:
|
|
526
|
+
handler = boot()
|
|
527
|
+
if handler is None:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
"no boot-time handler on this pod: sealed per-invoke settings are required"
|
|
530
|
+
)
|
|
531
|
+
return handler
|
|
532
|
+
return self.get_or_build(resolved_settings, build)
|
|
533
|
+
|
|
534
|
+
def clear(self) -> None:
|
|
535
|
+
with self._lock:
|
|
536
|
+
self._entries.clear()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.0.45" # pragma: no cover
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|