structured-data-models-client 1.0.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.
- sdm_client/__init__.py +70 -0
- sdm_client/_version.py +5 -0
- sdm_client/adapters/__init__.py +9 -0
- sdm_client/adapters/relational.py +870 -0
- sdm_client/adapters/tabular.py +580 -0
- sdm_client/base.py +300 -0
- sdm_client/client.py +1416 -0
- sdm_client/core/__init__.py +5 -0
- sdm_client/core/databricks_serving.py +611 -0
- sdm_client/core/databricks_user_agent.py +76 -0
- sdm_client/core/dates.py +278 -0
- sdm_client/core/transport.py +898 -0
- sdm_client/core/warnings.py +51 -0
- sdm_client/decompose.py +421 -0
- sdm_client/errors.py +177 -0
- sdm_client/materializers/__init__.py +10 -0
- sdm_client/materializers/relational.py +1786 -0
- sdm_client/materializers/sources.py +331 -0
- sdm_client/materializers/tabular.py +339 -0
- sdm_client/models.py +251 -0
- sdm_client/py.typed +0 -0
- sdm_client/query.py +647 -0
- sdm_client/request.py +1127 -0
- sdm_client/result.py +328 -0
- sdm_client/schema.py +3187 -0
- sdm_client/wire/__init__.py +46 -0
- sdm_client/wire/answers.py +80 -0
- sdm_client/wire/dtypes.py +701 -0
- sdm_client/wire/keys.py +363 -0
- sdm_client/wire/tables.py +50 -0
- structured_data_models_client-1.0.0.dist-info/METADATA +99 -0
- structured_data_models_client-1.0.0.dist-info/RECORD +36 -0
- structured_data_models_client-1.0.0.dist-info/WHEEL +5 -0
- structured_data_models_client-1.0.0.dist-info/licenses/LICENSE +204 -0
- structured_data_models_client-1.0.0.dist-info/licenses/NOTICE +10 -0
- structured_data_models_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
from urllib.parse import quote, urlparse
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
import urllib3.exceptions
|
|
16
|
+
from requests.adapters import HTTPAdapter
|
|
17
|
+
from urllib3.util.retry import Retry
|
|
18
|
+
|
|
19
|
+
from sdm_client.errors import (
|
|
20
|
+
NimRequestError,
|
|
21
|
+
StructuredDataError,
|
|
22
|
+
format_invalid_params,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_PREDICTIONS_PATH = '/v1/predictions'
|
|
26
|
+
_SESSIONS_PATH = '/v1/sessions'
|
|
27
|
+
_HEALTH_READY_PATH = '/v1/health/ready'
|
|
28
|
+
# Supplied by the NIM runtime rather than the Universal TFM contract, so an
|
|
29
|
+
# endpoint is not obliged to serve it.
|
|
30
|
+
_MODELS_PATH = '/v1/models'
|
|
31
|
+
_DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
32
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
33
|
+
_DEFAULT_BACKOFF_FACTOR = 0.5
|
|
34
|
+
#: The transient ones. A 500 is not among them: the payload and the model are
|
|
35
|
+
#: the same on every attempt, so a server fault reproduces, and the caller
|
|
36
|
+
#: mistakes this endpoint reports as a 500 are reclassified from the body as
|
|
37
|
+
#: the caller's after three more uploads of the whole gathered context.
|
|
38
|
+
_RETRY_STATUS = (429, 502, 503, 504)
|
|
39
|
+
_RETRY_AFTER_MAX_SECONDS = 60
|
|
40
|
+
_MAX_BODY_SNIPPET = 512
|
|
41
|
+
_LOCAL_HOSTS = frozenset({'localhost', '127.0.0.1', '::1'})
|
|
42
|
+
# Well above any real prediction response (a full-precision float64 column
|
|
43
|
+
# for a million rows is ~25 MB of JSON), but far below what a compressed
|
|
44
|
+
# hostile body can inflate to.
|
|
45
|
+
_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
46
|
+
_RESPONSE_CHUNK_BYTES = 1024 * 1024
|
|
47
|
+
# Deleting a session is a bookkeeping call the server answers immediately, and
|
|
48
|
+
# it runs on cleanup paths where the caller is no longer waiting on a result.
|
|
49
|
+
# The full request timeout would let an unreachable endpoint stall a teardown.
|
|
50
|
+
_DELETE_TIMEOUT_SECONDS = 10.0
|
|
51
|
+
_USERINFO_RE = re.compile(r'(?<=//)[^/@\s]+@')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def redact_url(url: str | None) -> str | None:
|
|
55
|
+
r"""Strip any userinfo from ``url`` so it is safe to log or display.
|
|
56
|
+
|
|
57
|
+
A URL carrying a password in its userinfo is a legal way to point this
|
|
58
|
+
client at a deployment, and the credential is then in the URL itself
|
|
59
|
+
rather than in ``api_key``. Anywhere a URL reaches an error message or
|
|
60
|
+
a repr it goes through here first, so the credential does not travel
|
|
61
|
+
with the diagnosis into a log aggregator or a bug report.
|
|
62
|
+
|
|
63
|
+
Mirrors ``sdm_relational_engine.client.client.redact_url``, which guards
|
|
64
|
+
the same credential on the Kumo Relational path. The two cannot share one
|
|
65
|
+
implementation:
|
|
66
|
+
this package must not import the optional driver, and the package both
|
|
67
|
+
depend on is a SQL-connector package with no URL handling. Keep them in
|
|
68
|
+
step.
|
|
69
|
+
"""
|
|
70
|
+
if not url:
|
|
71
|
+
return url
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
if not parsed.username and not parsed.password:
|
|
74
|
+
return url
|
|
75
|
+
host = parsed.hostname or ''
|
|
76
|
+
if ':' in host:
|
|
77
|
+
host = f'[{host}]'
|
|
78
|
+
if parsed.port:
|
|
79
|
+
host = f'{host}:{parsed.port}'
|
|
80
|
+
return parsed._replace(netloc=host).geturl()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scrub_userinfo(text: str) -> str:
|
|
84
|
+
r"""Remove any ``user:secret@`` credential from free text.
|
|
85
|
+
|
|
86
|
+
The counterpart to :func:`redact_url` for strings that merely *contain* a
|
|
87
|
+
URL rather than being one, chiefly the ``requests`` exception messages
|
|
88
|
+
quoted into transport errors, which echo back whatever URL was requested.
|
|
89
|
+
"""
|
|
90
|
+
return _USERINFO_RE.sub('', text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate_tls_verification(url: str, verify_ssl: bool) -> None:
|
|
94
|
+
"""Allow disabled TLS verification only for local development endpoints."""
|
|
95
|
+
if verify_ssl:
|
|
96
|
+
return
|
|
97
|
+
host = (urlparse(url).hostname or '').lower()
|
|
98
|
+
if host in _LOCAL_HOSTS:
|
|
99
|
+
return
|
|
100
|
+
raise StructuredDataError(
|
|
101
|
+
f'TLS certificate verification may be disabled only for a loopback '
|
|
102
|
+
f'endpoint, got {redact_url(url)!r}. Set verify_ssl=True and trust the '
|
|
103
|
+
'endpoint certificate instead.',
|
|
104
|
+
code='INVALID_CONFIGURATION',
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_url(url: str, api_key: str | None) -> None:
|
|
109
|
+
if not isinstance(url, str):
|
|
110
|
+
raise StructuredDataError(
|
|
111
|
+
f'url must be a string, got {type(url).__name__}',
|
|
112
|
+
code='INVALID_CONFIGURATION',
|
|
113
|
+
)
|
|
114
|
+
parsed = urlparse(url)
|
|
115
|
+
shown = redact_url(url)
|
|
116
|
+
if parsed.scheme not in ('http', 'https'):
|
|
117
|
+
raise StructuredDataError(
|
|
118
|
+
f'url must start with http:// or https://, got {shown!r}',
|
|
119
|
+
code='INVALID_CONFIGURATION',
|
|
120
|
+
)
|
|
121
|
+
if not parsed.hostname:
|
|
122
|
+
raise StructuredDataError(
|
|
123
|
+
f'url is missing a host, got {shown!r}',
|
|
124
|
+
code='INVALID_CONFIGURATION',
|
|
125
|
+
)
|
|
126
|
+
# A base URL is a scheme, a host and at most a path prefix. A query or
|
|
127
|
+
# fragment is not a meaningful part of one, and every route is appended to
|
|
128
|
+
# this string. Thus, `http://host?api_key=x` becomes
|
|
129
|
+
# `http://host/?api_key=x/v1/predictions`, which is both a broken URL and a
|
|
130
|
+
# secret in a plaintext request line that redaction never sees.
|
|
131
|
+
if parsed.query or parsed.fragment:
|
|
132
|
+
raise StructuredDataError(
|
|
133
|
+
'url must be a base endpoint without a query string or fragment; '
|
|
134
|
+
'pass a credential as api_key rather than in the URL',
|
|
135
|
+
code='INVALID_CONFIGURATION',
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
host = parsed.hostname.lower()
|
|
139
|
+
# Userinfo in the URL is a credential too, and ``requests`` turns it into
|
|
140
|
+
# an ``Authorization: Basic`` header. Checking only ``api_key`` let
|
|
141
|
+
# such a URL send that in the clear.
|
|
142
|
+
carries_credential = bool(api_key or parsed.username or parsed.password)
|
|
143
|
+
if (
|
|
144
|
+
carries_credential
|
|
145
|
+
and parsed.scheme == 'http'
|
|
146
|
+
and host not in _LOCAL_HOSTS
|
|
147
|
+
):
|
|
148
|
+
what = 'an API key' if api_key else 'the credential in this URL'
|
|
149
|
+
raise StructuredDataError(
|
|
150
|
+
f'refusing to send {what} over plaintext HTTP; use an https:// '
|
|
151
|
+
f'URL (or a localhost endpoint), got {shown!r}',
|
|
152
|
+
code='INVALID_CONFIGURATION',
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Session(requests.Session):
|
|
157
|
+
r"""A ``Session`` that enforces redirect security policy.
|
|
158
|
+
|
|
159
|
+
``requests`` strips only the standard ``Authorization`` header when a
|
|
160
|
+
redirect changes origin; a custom header set on the session is re-sent
|
|
161
|
+
verbatim, so without this the configured endpoint could hand the API key
|
|
162
|
+
to any host it names, defeating ``_validate_url``.
|
|
163
|
+
|
|
164
|
+
Redirects are still followed, and this covers the credential only. A
|
|
165
|
+
``307``/``308`` therefore re-sends the request *body* to the redirect
|
|
166
|
+
target,
|
|
167
|
+
including the caller's whole labelled context table for Kumo Tabular and
|
|
168
|
+
across an ``https`` to ``http`` downgrade, which ``_validate_url`` never
|
|
169
|
+
sees because it runs at construction against the configured URL. That is
|
|
170
|
+
an accepted trade when certificate verification is enabled. When it is
|
|
171
|
+
disabled for a loopback development endpoint, redirect targets must remain
|
|
172
|
+
loopback too, so the request-level ``verify=False`` cannot reach a remote
|
|
173
|
+
host.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, *, verify_ssl: bool) -> None:
|
|
177
|
+
super().__init__()
|
|
178
|
+
self._verify_ssl = verify_ssl
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _release_unread(resp: object) -> None:
|
|
182
|
+
r"""Give up a redirect hop's socket without reading its body.
|
|
183
|
+
|
|
184
|
+
Close rather than drain: the connection is surrendered instead of being
|
|
185
|
+
read to the end so it can be reused. Marking the body consumed is what
|
|
186
|
+
stops ``requests`` reading it afterwards. ``resp.content`` returns the
|
|
187
|
+
empty bytes set here instead of pulling from the socket.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(resp, requests.Response):
|
|
190
|
+
return
|
|
191
|
+
raw = getattr(resp, 'raw', None)
|
|
192
|
+
if raw is not None:
|
|
193
|
+
raw.close()
|
|
194
|
+
resp._content = b''
|
|
195
|
+
resp._content_consumed = True # type: ignore[attr-defined]
|
|
196
|
+
|
|
197
|
+
def resolve_redirects( # type: ignore[override]
|
|
198
|
+
self,
|
|
199
|
+
resp: requests.Response,
|
|
200
|
+
req: requests.PreparedRequest,
|
|
201
|
+
**kwargs: Any,
|
|
202
|
+
) -> Any:
|
|
203
|
+
r"""Follow redirects without reading the bodies they carry.
|
|
204
|
+
|
|
205
|
+
``requests`` consumes each hop's body (``resp.content``) to release the
|
|
206
|
+
socket, with no cap. This layer never needs those bytes because its
|
|
207
|
+
only
|
|
208
|
+
request reports a status. They are attacker-controlled on
|
|
209
|
+
a misconfigured endpoint.
|
|
210
|
+
|
|
211
|
+
Every hop is released, not just the first. ``requests`` resolves the
|
|
212
|
+
rest inside the generator below, reading each one as it loops, so each
|
|
213
|
+
is released as it is yielded, before control returns to that loop.
|
|
214
|
+
"""
|
|
215
|
+
# ``Session.send`` calls this for every response, redirect or not, so
|
|
216
|
+
# the check matters: releasing unconditionally would close the body of
|
|
217
|
+
# a successful response before the caller ever saw it.
|
|
218
|
+
if self.get_redirect_target(resp) is not None:
|
|
219
|
+
self._release_unread(resp)
|
|
220
|
+
for hop in super().resolve_redirects(resp, req, **kwargs):
|
|
221
|
+
if isinstance(hop, requests.Response) and (
|
|
222
|
+
self.get_redirect_target(hop) is not None
|
|
223
|
+
):
|
|
224
|
+
self._release_unread(hop)
|
|
225
|
+
yield hop
|
|
226
|
+
|
|
227
|
+
def rebuild_auth(
|
|
228
|
+
self,
|
|
229
|
+
prepared_request: requests.PreparedRequest,
|
|
230
|
+
response: requests.Response,
|
|
231
|
+
) -> None:
|
|
232
|
+
super().rebuild_auth(prepared_request, response)
|
|
233
|
+
previous_url = response.request.url
|
|
234
|
+
if (
|
|
235
|
+
previous_url
|
|
236
|
+
and prepared_request.url
|
|
237
|
+
and self.should_strip_auth(previous_url, prepared_request.url)
|
|
238
|
+
):
|
|
239
|
+
prepared_request.headers.pop('X-API-Key', None)
|
|
240
|
+
if prepared_request.url:
|
|
241
|
+
_validate_tls_verification(prepared_request.url, self._verify_ssl)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _validate_limits(timeout: Any, max_retries: Any) -> None:
|
|
245
|
+
r"""Reject unusable transport limits at construction.
|
|
246
|
+
|
|
247
|
+
Without this the offending value first surfaces as a raw ``ValueError``
|
|
248
|
+
from urllib3, on the first request rather than at the call that set it.
|
|
249
|
+
|
|
250
|
+
``inf`` and ``nan`` are rejected explicitly: both compare ``False`` against
|
|
251
|
+
``<= 0`` and so pass an ordinary positivity test, then fail on the first
|
|
252
|
+
request with ``OverflowError``/``ValueError`` from the socket layer.
|
|
253
|
+
``inf``
|
|
254
|
+
is the natural way to reach for "no timeout" here, since this constructor
|
|
255
|
+
does not accept ``None``.
|
|
256
|
+
"""
|
|
257
|
+
if (
|
|
258
|
+
isinstance(timeout, bool)
|
|
259
|
+
or not isinstance(timeout, (int, float))
|
|
260
|
+
or not math.isfinite(timeout)
|
|
261
|
+
or timeout <= 0
|
|
262
|
+
):
|
|
263
|
+
raise StructuredDataError(
|
|
264
|
+
f'timeout must be a positive, finite number of seconds, got '
|
|
265
|
+
f'{timeout!r}',
|
|
266
|
+
code='INVALID_CONFIGURATION',
|
|
267
|
+
)
|
|
268
|
+
if (
|
|
269
|
+
isinstance(max_retries, bool)
|
|
270
|
+
or not isinstance(max_retries, int)
|
|
271
|
+
or max_retries < 0
|
|
272
|
+
):
|
|
273
|
+
raise StructuredDataError(
|
|
274
|
+
f'max_retries must be a non-negative integer, got {max_retries!r}',
|
|
275
|
+
code='INVALID_CONFIGURATION',
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _build_retry(
|
|
280
|
+
max_retries: int,
|
|
281
|
+
backoff_factor: float,
|
|
282
|
+
*,
|
|
283
|
+
retry_post: bool = True,
|
|
284
|
+
) -> Retry:
|
|
285
|
+
r"""The retry policy this transport's requests run under.
|
|
286
|
+
|
|
287
|
+
``retry_after_max`` caps how long a server-chosen ``Retry-After`` header
|
|
288
|
+
may park the caller; it needs urllib3 >= 2.3, and older versions fall back
|
|
289
|
+
to urllib3's own (6 hour) cap.
|
|
290
|
+
|
|
291
|
+
``retry_post=False`` drops ``POST`` from the retryable methods, which
|
|
292
|
+
switches off the read and status retries for a route whose side effect the
|
|
293
|
+
client cannot reconcile. Connection failures stay retryable either way:
|
|
294
|
+
urllib3 gates read and status retries on the method but not connect ones,
|
|
295
|
+
and a request that never reached the server cannot have had an effect.
|
|
296
|
+
|
|
297
|
+
``DELETE`` is always retryable. Releasing a session is idempotent by
|
|
298
|
+
contract. The NIM answers 204 whether or not the session is still there,
|
|
299
|
+
and the status it returns when it cannot release one right now is a
|
|
300
|
+
`429` whose message asks the caller to retry. Leaving it out meant the
|
|
301
|
+
client raised the one error it documents as worth retrying and then did
|
|
302
|
+
not retry it.
|
|
303
|
+
"""
|
|
304
|
+
methods = {'GET', 'DELETE'}
|
|
305
|
+
if retry_post:
|
|
306
|
+
methods.add('POST')
|
|
307
|
+
options: dict[str, Any] = {
|
|
308
|
+
'total': max_retries,
|
|
309
|
+
'connect': max_retries,
|
|
310
|
+
'read': max_retries,
|
|
311
|
+
'status': max_retries,
|
|
312
|
+
'backoff_factor': backoff_factor,
|
|
313
|
+
'status_forcelist': _RETRY_STATUS,
|
|
314
|
+
'allowed_methods': frozenset(methods),
|
|
315
|
+
'respect_retry_after_header': True,
|
|
316
|
+
'raise_on_status': False,
|
|
317
|
+
}
|
|
318
|
+
try:
|
|
319
|
+
return Retry(retry_after_max=_RETRY_AFTER_MAX_SECONDS, **options)
|
|
320
|
+
except TypeError:
|
|
321
|
+
return Retry(**options)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _timed_out(error: BaseException | None) -> bool:
|
|
325
|
+
r"""Whether a request failure is a timeout, however it reached us.
|
|
326
|
+
|
|
327
|
+
urllib3 wraps a read timeout in ``MaxRetryError``, which ``requests``
|
|
328
|
+
surfaces as ``ConnectionError`` rather than ``Timeout``.
|
|
329
|
+
"""
|
|
330
|
+
seen: set[int] = set()
|
|
331
|
+
while error is not None and id(error) not in seen:
|
|
332
|
+
seen.add(id(error))
|
|
333
|
+
if isinstance(
|
|
334
|
+
error, (requests.Timeout, urllib3.exceptions.TimeoutError)
|
|
335
|
+
):
|
|
336
|
+
return True
|
|
337
|
+
error = error.__cause__ or error.__context__
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _failed_to_reach(
|
|
342
|
+
error: Exception, url: str, timeout: float | None
|
|
343
|
+
) -> StructuredDataError:
|
|
344
|
+
r"""One account of a failed request, for every route that can fail.
|
|
345
|
+
|
|
346
|
+
A timeout names the knob and the value rather than urllib3's "Max retries
|
|
347
|
+
exceeded", which says the opposite of the truth when none were asked for.
|
|
348
|
+
"""
|
|
349
|
+
if _timed_out(error) and timeout is not None:
|
|
350
|
+
return StructuredDataError(
|
|
351
|
+
f'Request to {redact_url(url)} timed out after {timeout:g} '
|
|
352
|
+
f'seconds, which is the timeout this client was given',
|
|
353
|
+
code='TRANSPORT_ERROR',
|
|
354
|
+
details={'timeout': timeout, 'url': redact_url(url)},
|
|
355
|
+
)
|
|
356
|
+
return StructuredDataError(
|
|
357
|
+
f'Request to {redact_url(url)} failed: {scrub_userinfo(str(error))}',
|
|
358
|
+
code='TRANSPORT_ERROR',
|
|
359
|
+
details={'url': redact_url(url)},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _decoded_object(content: bytes, url: str) -> dict[str, Any]:
|
|
364
|
+
r"""``content`` as the JSON object it is supposed to be.
|
|
365
|
+
|
|
366
|
+
In one place because it was in two, and one checked only that it was JSON:
|
|
367
|
+
a 200 carrying an array escaped `models()` as a bare ``AttributeError``.
|
|
368
|
+
A deeply nested body raises `RecursionError`, a `RuntimeError`, which would
|
|
369
|
+
otherwise escape the contract these errors state.
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
body = json.loads(content or b'{}')
|
|
373
|
+
except (ValueError, RecursionError) as error:
|
|
374
|
+
raise StructuredDataError(
|
|
375
|
+
f'{redact_url(url)} did not return JSON',
|
|
376
|
+
code='TRANSPORT_ERROR',
|
|
377
|
+
details={'url': redact_url(url)},
|
|
378
|
+
) from error
|
|
379
|
+
if not isinstance(body, dict):
|
|
380
|
+
raise StructuredDataError(
|
|
381
|
+
f'Expected a JSON object from {redact_url(url)}, got '
|
|
382
|
+
f'{type(body).__name__}',
|
|
383
|
+
code='TRANSPORT_ERROR',
|
|
384
|
+
details={'url': redact_url(url)},
|
|
385
|
+
)
|
|
386
|
+
return body
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _snippet(text: str) -> str:
|
|
390
|
+
r"""A one-line, length-capped view of a response body, so a multi-megabyte
|
|
391
|
+
HTML error page cannot become a multi-megabyte exception message.
|
|
392
|
+
"""
|
|
393
|
+
text = ' '.join(text.split())
|
|
394
|
+
if len(text) <= _MAX_BODY_SNIPPET:
|
|
395
|
+
return text
|
|
396
|
+
return f'{text[:_MAX_BODY_SNIPPET]}... [truncated, {len(text)} chars total]'
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _read_capped(response: requests.Response, url: str) -> bytes:
|
|
400
|
+
r"""Reads a streamed response body, refusing anything past the cap.
|
|
401
|
+
|
|
402
|
+
``requests`` inflates ``Content-Encoding: gzip`` with no ratio limit, so
|
|
403
|
+
an unbounded read lets a small compressed body expand into hundreds of
|
|
404
|
+
megabytes of client memory before anything is parsed.
|
|
405
|
+
"""
|
|
406
|
+
body = bytearray()
|
|
407
|
+
for chunk in response.iter_content(_RESPONSE_CHUNK_BYTES):
|
|
408
|
+
if len(body) + len(chunk) > _MAX_RESPONSE_BYTES:
|
|
409
|
+
raise StructuredDataError(
|
|
410
|
+
f'Response body from {redact_url(url)} exceeds the '
|
|
411
|
+
f'{_MAX_RESPONSE_BYTES} byte limit',
|
|
412
|
+
code='TRANSPORT_ERROR',
|
|
413
|
+
)
|
|
414
|
+
body.extend(chunk)
|
|
415
|
+
return bytes(body)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _path_segment(value: str) -> str:
|
|
419
|
+
r"""Escapes a server-chosen id before it is spliced into a URL path, so a
|
|
420
|
+
hostile or malformed ``session_id`` cannot redirect the call to another
|
|
421
|
+
route.
|
|
422
|
+
|
|
423
|
+
``quote`` leaves a dot alone, because it is unreserved. A resolver then
|
|
424
|
+
removes the only two segments that mean something to it, ``.`` and ``..``.
|
|
425
|
+
Thus, ``..`` turned a scoring call against a pinned
|
|
426
|
+
context into a one-shot prediction elsewhere. Those name no session and
|
|
427
|
+
are refused; ``...`` is an ordinary segment and is escaped like any
|
|
428
|
+
other. An empty id is refused for its own reason: it contributes no
|
|
429
|
+
segment at all, so the path closes up around it.
|
|
430
|
+
"""
|
|
431
|
+
if value in {'', '.', '..'}:
|
|
432
|
+
raise StructuredDataError(
|
|
433
|
+
f'{value!r} is not a session id: it does not survive as a path '
|
|
434
|
+
f'segment when the URL is resolved, so the call would be made '
|
|
435
|
+
f'against a different route than the one it names',
|
|
436
|
+
code='INVALID_RESPONSE',
|
|
437
|
+
)
|
|
438
|
+
return quote(value, safe='')
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _build_session(
|
|
442
|
+
url: str,
|
|
443
|
+
api_key: str | None,
|
|
444
|
+
verify_ssl: bool,
|
|
445
|
+
max_retries: int,
|
|
446
|
+
backoff_factor: float,
|
|
447
|
+
) -> requests.Session:
|
|
448
|
+
r"""The pooled session, with ``POST /v1/sessions`` held out of the retries.
|
|
449
|
+
|
|
450
|
+
Creating a session fits and pins the context in the worker's memory before
|
|
451
|
+
the response is written, so a re-sent ``POST`` leaves one orphaned pinned
|
|
452
|
+
context per attempt: the client keeps only the last ``session_id`` and can
|
|
453
|
+
never ``DELETE`` the earlier ones. That is a side effect the client cannot
|
|
454
|
+
reconcile, unlike ``/v1/predictions``, which the same policy may safely
|
|
455
|
+
replay.
|
|
456
|
+
|
|
457
|
+
``requests`` resolves an adapter by longest matching URL prefix, so the
|
|
458
|
+
stricter policy is mounted on the exact create-session URL and the ordinary
|
|
459
|
+
one re-mounted on the routes below it: scoring against a pinned context and
|
|
460
|
+
releasing one are both replayable and keep the full policy.
|
|
461
|
+
"""
|
|
462
|
+
session = _Session(verify_ssl=verify_ssl)
|
|
463
|
+
if api_key:
|
|
464
|
+
session.headers['X-API-Key'] = api_key
|
|
465
|
+
adapter = HTTPAdapter(max_retries=_build_retry(max_retries, backoff_factor))
|
|
466
|
+
session.mount('http://', adapter)
|
|
467
|
+
session.mount('https://', adapter)
|
|
468
|
+
session.mount(
|
|
469
|
+
url + _SESSIONS_PATH,
|
|
470
|
+
HTTPAdapter(
|
|
471
|
+
max_retries=_build_retry(
|
|
472
|
+
max_retries, backoff_factor, retry_post=False
|
|
473
|
+
)
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
session.mount(url + _SESSIONS_PATH + '/', adapter)
|
|
477
|
+
return session
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class WireTransport(Protocol):
|
|
481
|
+
r"""What a client needs from whatever carries its requests.
|
|
482
|
+
|
|
483
|
+
A NIM reached over HTTP is one; a Databricks Model Serving endpoint is
|
|
484
|
+
another. The client asks only this of either.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
#: Whether the endpoint can pin a context. A client refuses ``fit()`` up
|
|
488
|
+
#: front when it cannot, rather than preparing a session to be told so.
|
|
489
|
+
serves_sessions: bool
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def url(self) -> str: ...
|
|
493
|
+
|
|
494
|
+
def close(self) -> None: ...
|
|
495
|
+
|
|
496
|
+
def health_ready(self) -> bool: ...
|
|
497
|
+
|
|
498
|
+
def list_models(self) -> list[str]: ...
|
|
499
|
+
|
|
500
|
+
def predict(self, payload: dict[str, Any]) -> dict[str, Any]: ...
|
|
501
|
+
|
|
502
|
+
def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: ...
|
|
503
|
+
|
|
504
|
+
def session_predict(
|
|
505
|
+
self, session_id: str, payload: dict[str, Any]
|
|
506
|
+
) -> dict[str, Any]: ...
|
|
507
|
+
|
|
508
|
+
def delete_session(self, session_id: str) -> None: ...
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class Transport:
|
|
512
|
+
r"""The HTTP wire layer to one Universal TFM NIM.
|
|
513
|
+
|
|
514
|
+
Owns a pooled ``requests.Session`` with retry/backoff on the transient
|
|
515
|
+
failures :data:`_RETRY_STATUS` names. Not part of the public API surface;
|
|
516
|
+
a ``SDMClient`` holds one of these.
|
|
517
|
+
|
|
518
|
+
``timeout`` bounds each individual attempt, not the call as a whole: a
|
|
519
|
+
request that exhausts ``max_retries`` can take up to
|
|
520
|
+
``(max_retries + 1) * timeout`` plus backoff.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
serves_sessions = True
|
|
524
|
+
|
|
525
|
+
def __init__(
|
|
526
|
+
self,
|
|
527
|
+
url: str,
|
|
528
|
+
api_key: str | None = None,
|
|
529
|
+
*,
|
|
530
|
+
verify_ssl: bool = True,
|
|
531
|
+
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
532
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
533
|
+
backoff_factor: float = _DEFAULT_BACKOFF_FACTOR,
|
|
534
|
+
) -> None:
|
|
535
|
+
_validate_url(url, api_key)
|
|
536
|
+
_validate_limits(timeout, max_retries)
|
|
537
|
+
_validate_tls_verification(url, verify_ssl)
|
|
538
|
+
self._url = url.rstrip('/')
|
|
539
|
+
self._api_key = api_key
|
|
540
|
+
self._verify_ssl = verify_ssl
|
|
541
|
+
self._timeout = timeout
|
|
542
|
+
self._closed = False
|
|
543
|
+
self._max_retries = max_retries
|
|
544
|
+
self._session = _build_session(
|
|
545
|
+
self._url, api_key, verify_ssl, max_retries, backoff_factor
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
@property
|
|
549
|
+
def url(self) -> str:
|
|
550
|
+
return self._url
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def api_key(self) -> str | None:
|
|
554
|
+
return self._api_key
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def verify_ssl(self) -> bool:
|
|
558
|
+
return self._verify_ssl
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def timeout(self) -> float:
|
|
562
|
+
return self._timeout
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def max_retries(self) -> int:
|
|
566
|
+
return self._max_retries
|
|
567
|
+
|
|
568
|
+
def close(self) -> None:
|
|
569
|
+
self._closed = True
|
|
570
|
+
self._session.close()
|
|
571
|
+
|
|
572
|
+
def _require_open(self) -> None:
|
|
573
|
+
if self._closed:
|
|
574
|
+
raise StructuredDataError(
|
|
575
|
+
'this client is closed; construct a new SDMClient',
|
|
576
|
+
code='INVALID_CONFIGURATION',
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def health_ready(
|
|
580
|
+
self,
|
|
581
|
+
*,
|
|
582
|
+
timeout: float | None = None,
|
|
583
|
+
retry: bool = True,
|
|
584
|
+
raise_on_not_ready: bool = False,
|
|
585
|
+
) -> bool:
|
|
586
|
+
self._require_open()
|
|
587
|
+
request_timeout = self._timeout if timeout is None else timeout
|
|
588
|
+
_validate_limits(request_timeout, 0)
|
|
589
|
+
url = self._url + _HEALTH_READY_PATH
|
|
590
|
+
session = (
|
|
591
|
+
self._session
|
|
592
|
+
if retry
|
|
593
|
+
else _build_session(
|
|
594
|
+
self._url,
|
|
595
|
+
self._api_key,
|
|
596
|
+
self._verify_ssl,
|
|
597
|
+
max_retries=0,
|
|
598
|
+
backoff_factor=_DEFAULT_BACKOFF_FACTOR,
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
try:
|
|
602
|
+
# Streamed and closed unread: only the status matters, and the
|
|
603
|
+
# body is attacker-controlled on a misconfigured endpoint.
|
|
604
|
+
with session.get(
|
|
605
|
+
url,
|
|
606
|
+
timeout=request_timeout,
|
|
607
|
+
verify=self._verify_ssl,
|
|
608
|
+
stream=True,
|
|
609
|
+
) as response:
|
|
610
|
+
if response.status_code != 200 and raise_on_not_ready:
|
|
611
|
+
raise StructuredDataError(
|
|
612
|
+
f'Readiness check at {redact_url(url)} returned '
|
|
613
|
+
f'HTTP {response.status_code}',
|
|
614
|
+
code='TRANSPORT_ERROR',
|
|
615
|
+
details={
|
|
616
|
+
'status_code': response.status_code,
|
|
617
|
+
'url': redact_url(url),
|
|
618
|
+
},
|
|
619
|
+
)
|
|
620
|
+
return response.status_code == 200
|
|
621
|
+
except requests.RequestException as error:
|
|
622
|
+
if _timed_out(error):
|
|
623
|
+
raise StructuredDataError(
|
|
624
|
+
f'Readiness check at {redact_url(url)} timed out after '
|
|
625
|
+
f'{request_timeout:g} seconds',
|
|
626
|
+
code='TRANSPORT_ERROR',
|
|
627
|
+
details={
|
|
628
|
+
'timeout': request_timeout,
|
|
629
|
+
'url': redact_url(url),
|
|
630
|
+
},
|
|
631
|
+
) from error
|
|
632
|
+
raise _failed_to_reach(error, url, None) from error
|
|
633
|
+
finally:
|
|
634
|
+
if session is not self._session:
|
|
635
|
+
session.close()
|
|
636
|
+
|
|
637
|
+
def list_models(self, *, timeout: float | None = None) -> list[str]:
|
|
638
|
+
r"""The model ids the endpoint advertises.
|
|
639
|
+
|
|
640
|
+
``/v1/models`` is supplied by the NIM runtime and is **not** one of the
|
|
641
|
+
contract's four paths, so an endpoint may not serve it at all. That is
|
|
642
|
+
reported as a transport error naming the path rather than being
|
|
643
|
+
answered from this client's own list of models, which is what the
|
|
644
|
+
previous generation did. That meant it claimed a model the
|
|
645
|
+
endpoint did not serve.
|
|
646
|
+
"""
|
|
647
|
+
self._require_open()
|
|
648
|
+
request_timeout = self._timeout if timeout is None else timeout
|
|
649
|
+
_validate_limits(request_timeout, 0)
|
|
650
|
+
url = self._url + _MODELS_PATH
|
|
651
|
+
try:
|
|
652
|
+
with self._session.get(
|
|
653
|
+
url,
|
|
654
|
+
timeout=request_timeout,
|
|
655
|
+
verify=self._verify_ssl,
|
|
656
|
+
stream=True,
|
|
657
|
+
) as response:
|
|
658
|
+
content = _read_capped(response, url)
|
|
659
|
+
if response.status_code != 200:
|
|
660
|
+
raise _to_nim_error(response.status_code, content)
|
|
661
|
+
except requests.RequestException as error:
|
|
662
|
+
raise _failed_to_reach(error, url, request_timeout) from error
|
|
663
|
+
|
|
664
|
+
entries = _decoded_object(content, url).get('data')
|
|
665
|
+
if entries is None:
|
|
666
|
+
entries = []
|
|
667
|
+
if not isinstance(entries, list):
|
|
668
|
+
raise StructuredDataError(
|
|
669
|
+
f'Expected a list of models under "data" from '
|
|
670
|
+
f'{redact_url(url)}, got {type(entries).__name__}',
|
|
671
|
+
code='TRANSPORT_ERROR',
|
|
672
|
+
details={'url': redact_url(url)},
|
|
673
|
+
)
|
|
674
|
+
return [
|
|
675
|
+
str(entry['id'])
|
|
676
|
+
for entry in entries
|
|
677
|
+
if isinstance(entry, dict) and entry.get('id')
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
def predict(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
681
|
+
return self._post(_PREDICTIONS_PATH, payload)
|
|
682
|
+
|
|
683
|
+
def create_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
684
|
+
r"""Pins a context server-side; ``payload`` carries the context-only
|
|
685
|
+
sections of a prediction request.
|
|
686
|
+
"""
|
|
687
|
+
return self._post(_SESSIONS_PATH, payload)
|
|
688
|
+
|
|
689
|
+
def session_predict(
|
|
690
|
+
self,
|
|
691
|
+
session_id: str,
|
|
692
|
+
payload: dict[str, Any],
|
|
693
|
+
) -> dict[str, Any]:
|
|
694
|
+
r"""Scores rows against a pinned context; ``payload`` carries only the
|
|
695
|
+
per-call sections.
|
|
696
|
+
"""
|
|
697
|
+
return self._post(
|
|
698
|
+
f'{_SESSIONS_PATH}/{_path_segment(session_id)}/predictions', payload
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def delete_session(self, session_id: str) -> None:
|
|
702
|
+
r"""Releases a pinned context. Idempotent server-side: the NIM answers
|
|
703
|
+
204 whether or not the session is still there.
|
|
704
|
+
"""
|
|
705
|
+
self._require_open()
|
|
706
|
+
url = f'{self._url}{_SESSIONS_PATH}/{_path_segment(session_id)}'
|
|
707
|
+
request_timeout = min(self._timeout, _DELETE_TIMEOUT_SECONDS)
|
|
708
|
+
try:
|
|
709
|
+
with self._session.delete(
|
|
710
|
+
url,
|
|
711
|
+
timeout=request_timeout,
|
|
712
|
+
verify=self._verify_ssl,
|
|
713
|
+
stream=True,
|
|
714
|
+
) as response:
|
|
715
|
+
status_code = response.status_code
|
|
716
|
+
content = _read_capped(response, url)
|
|
717
|
+
except requests.RequestException as error:
|
|
718
|
+
raise _failed_to_reach(error, url, request_timeout) from error
|
|
719
|
+
if status_code >= 400:
|
|
720
|
+
raise _to_nim_error(status_code, content)
|
|
721
|
+
|
|
722
|
+
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
723
|
+
self._require_open()
|
|
724
|
+
url = self._url + path
|
|
725
|
+
# Encode before sending, so a value with no JSON form is named here
|
|
726
|
+
# rather than raising a TypeError from inside `requests` that mentions
|
|
727
|
+
# neither this client nor the offending key.
|
|
728
|
+
_check_encodable(payload)
|
|
729
|
+
try:
|
|
730
|
+
body = json.dumps(payload, allow_nan=False)
|
|
731
|
+
except RecursionError as error: # pragma: no cover - see the check
|
|
732
|
+
raise StructuredDataError(
|
|
733
|
+
'this request is nested too deeply to encode as JSON',
|
|
734
|
+
code='INVALID_REQUEST',
|
|
735
|
+
) from error
|
|
736
|
+
except (TypeError, ValueError) as error:
|
|
737
|
+
raise StructuredDataError(
|
|
738
|
+
f'this request carries a value that cannot be sent as JSON: '
|
|
739
|
+
f'{error}',
|
|
740
|
+
code='INVALID_REQUEST',
|
|
741
|
+
) from error
|
|
742
|
+
try:
|
|
743
|
+
with self._session.post(
|
|
744
|
+
url,
|
|
745
|
+
# Encoded here rather than handed over as `str`: requests
|
|
746
|
+
# gives a str body to httplib, which encodes latin-1, so this
|
|
747
|
+
# is only safe today because `json.dumps` defaults to
|
|
748
|
+
# `ensure_ascii=True`. Not a default worth depending on.
|
|
749
|
+
data=body.encode('utf-8'),
|
|
750
|
+
headers={'Content-Type': 'application/json'},
|
|
751
|
+
timeout=self._timeout,
|
|
752
|
+
verify=self._verify_ssl,
|
|
753
|
+
stream=True,
|
|
754
|
+
) as response:
|
|
755
|
+
status_code = response.status_code
|
|
756
|
+
content = _read_capped(response, url)
|
|
757
|
+
except requests.RequestException as error:
|
|
758
|
+
raise _failed_to_reach(error, url, self._timeout) from error
|
|
759
|
+
if status_code >= 400:
|
|
760
|
+
raise _to_nim_error(status_code, content)
|
|
761
|
+
return _decoded_object(content, url)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
#: Deeper than any real request and far below the interpreter's own limit,
|
|
765
|
+
#: so the walk below reports rather than exhausting the stack.
|
|
766
|
+
_MAX_PAYLOAD_DEPTH = 200
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _check_encodable(payload: Any) -> None:
|
|
770
|
+
r"""Refuse a payload that nests too deeply or refers back to itself.
|
|
771
|
+
|
|
772
|
+
Not left to ``json.dumps`` raising ``RecursionError``. Whether it does is
|
|
773
|
+
an implementation detail of the encoder: CPython 3.12 raises on a 30,000
|
|
774
|
+
level mapping and 3.14 encodes the same value without complaint, so a
|
|
775
|
+
guard written around the exception protects one interpreter and not the
|
|
776
|
+
next. This package supports both.
|
|
777
|
+
|
|
778
|
+
Iterative, because a recursive check would hit the same limit it exists to
|
|
779
|
+
report.
|
|
780
|
+
"""
|
|
781
|
+
stack: list[tuple[Any, int, tuple[int, ...]]] = [(payload, 0, ())]
|
|
782
|
+
while stack:
|
|
783
|
+
value, depth, parents = stack.pop()
|
|
784
|
+
if not isinstance(value, (Mapping, list, tuple)):
|
|
785
|
+
continue
|
|
786
|
+
if id(value) in parents:
|
|
787
|
+
raise StructuredDataError(
|
|
788
|
+
'this request contains a value that refers back to itself, '
|
|
789
|
+
'so it cannot be sent as JSON',
|
|
790
|
+
code='INVALID_REQUEST',
|
|
791
|
+
)
|
|
792
|
+
if depth > _MAX_PAYLOAD_DEPTH:
|
|
793
|
+
raise StructuredDataError(
|
|
794
|
+
f'this request nests more than {_MAX_PAYLOAD_DEPTH} levels '
|
|
795
|
+
f'deep, so it cannot be sent as JSON',
|
|
796
|
+
code='INVALID_REQUEST',
|
|
797
|
+
)
|
|
798
|
+
nested = (*parents, id(value))
|
|
799
|
+
items = value.values() if isinstance(value, Mapping) else value
|
|
800
|
+
for item in items:
|
|
801
|
+
stack.append((item, depth + 1, nested))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
_THE_CALLERS_TO_FIX = (
|
|
805
|
+
('failed to collect any context examples', 'EMPTY_CONTEXT'),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
_LEAKED_EXCEPTION = re.compile(
|
|
809
|
+
r'\b(?:failed unexpectedly with\s+)?'
|
|
810
|
+
r'[A-Za-z_][A-Za-z0-9_]*(?:Error|Exception)\s*:\s*'
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _diagnosis_in(reported: Any) -> tuple[str, str] | None:
|
|
815
|
+
r"""The wording and stable code of a failure the caller can act on.
|
|
816
|
+
|
|
817
|
+
``_THE_CALLERS_TO_FIX`` holds the failures the endpoint reports as its own
|
|
818
|
+
that describe the request instead. Each is the caller's to fix, and an
|
|
819
|
+
orchestrator told a server faulted will retry one that can only fail the
|
|
820
|
+
same way.
|
|
821
|
+
|
|
822
|
+
They are matched on the endpoint's wording, which is the only signal the
|
|
823
|
+
response gives: the status says server, the code says internal, and only
|
|
824
|
+
the sentence says otherwise. A build that rewords one reverts to reporting
|
|
825
|
+
it the way the endpoint did, rather than misreporting anything.
|
|
826
|
+
|
|
827
|
+
Only a string is read. A body whose ``detail`` is an object is not the
|
|
828
|
+
shape the contract describes, and rendering one to text could match this
|
|
829
|
+
on a debug field the endpoint never meant as its diagnosis.
|
|
830
|
+
"""
|
|
831
|
+
if not isinstance(reported, str):
|
|
832
|
+
return None
|
|
833
|
+
said = reported.casefold()
|
|
834
|
+
for wording, code in _THE_CALLERS_TO_FIX:
|
|
835
|
+
if wording in said:
|
|
836
|
+
return wording, code
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _the_diagnosis_alone(message: str, wording: str) -> str:
|
|
841
|
+
r"""The part of ``message`` that says what the caller got wrong.
|
|
842
|
+
|
|
843
|
+
What comes before it is the endpoint describing its own failure: which
|
|
844
|
+
prediction, at which address, and the Python type that reached its
|
|
845
|
+
response, matched by ``_LEAKED_EXCEPTION``. None of that is the caller's
|
|
846
|
+
to act on, and a type from inside the server reads as a crash, which is
|
|
847
|
+
the impression being corrected.
|
|
848
|
+
"""
|
|
849
|
+
found = message.casefold().find(wording)
|
|
850
|
+
kept = message[found:].strip() if found >= 0 else message
|
|
851
|
+
return _LEAKED_EXCEPTION.sub('', kept).strip() or message
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _to_nim_error(status_code: int, content: bytes) -> NimRequestError:
|
|
855
|
+
r"""The error an endpoint's failed response means.
|
|
856
|
+
|
|
857
|
+
A failure the caller can act on is reported as theirs whatever status it
|
|
858
|
+
arrived under, with what the endpoint called it kept in ``details``: that
|
|
859
|
+
is worth knowing even where it was wrong.
|
|
860
|
+
"""
|
|
861
|
+
try:
|
|
862
|
+
body = json.loads(content)
|
|
863
|
+
except (ValueError, RecursionError):
|
|
864
|
+
body = {}
|
|
865
|
+
if not isinstance(body, dict):
|
|
866
|
+
body = {}
|
|
867
|
+
code = body.get('code')
|
|
868
|
+
reported = (
|
|
869
|
+
body.get('detail')
|
|
870
|
+
or body.get('title')
|
|
871
|
+
or content.decode('utf-8', 'replace')
|
|
872
|
+
)
|
|
873
|
+
message = (
|
|
874
|
+
_snippet(str(reported))
|
|
875
|
+
if reported
|
|
876
|
+
else (f'NIM request failed with status {status_code}')
|
|
877
|
+
)
|
|
878
|
+
# A validation failure's top-level detail is often only "Request validation
|
|
879
|
+
# failed."; the per-field diagnosis is in invalid_params, so render it into
|
|
880
|
+
# the message rather than leaving it for the caller to dig out of details.
|
|
881
|
+
message += format_invalid_params(body.get('invalid_params'))
|
|
882
|
+
details = {
|
|
883
|
+
key: value
|
|
884
|
+
for key, value in body.items()
|
|
885
|
+
if key not in ('code', 'detail', 'title', 'type', 'status', 'instance')
|
|
886
|
+
}
|
|
887
|
+
found = _diagnosis_in(reported) if status_code >= 500 else None
|
|
888
|
+
if found is not None:
|
|
889
|
+
wording, diagnosis = found
|
|
890
|
+
details = {**details, 'endpoint_code': code, 'diagnosis': diagnosis}
|
|
891
|
+
code = 'INVALID_REQUEST'
|
|
892
|
+
message = _the_diagnosis_alone(message, wording)
|
|
893
|
+
return NimRequestError(
|
|
894
|
+
status_code,
|
|
895
|
+
code=code,
|
|
896
|
+
message=message,
|
|
897
|
+
details=details,
|
|
898
|
+
)
|