needlepath 0.1.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.
- needlepath/__init__.py +137 -0
- needlepath/client.py +947 -0
- needlepath/errors.py +123 -0
- needlepath/features.py +71 -0
- needlepath/models.py +476 -0
- needlepath/py.typed +0 -0
- needlepath/result.py +302 -0
- needlepath/testing.py +224 -0
- needlepath/transport.py +316 -0
- needlepath/version.py +10 -0
- needlepath-0.1.0.dist-info/METADATA +251 -0
- needlepath-0.1.0.dist-info/RECORD +14 -0
- needlepath-0.1.0.dist-info/WHEEL +4 -0
- needlepath-0.1.0.dist-info/licenses/LICENSE +202 -0
needlepath/client.py
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
"""The framework-free core client.
|
|
2
|
+
|
|
3
|
+
Two clients, one behaviour: :class:`NeedlepathClient` (sync) and
|
|
4
|
+
:class:`AsyncNeedlepathClient`. Everything that decides *what happens* lives in
|
|
5
|
+
module-level helpers shared by both, so the async client cannot drift from the
|
|
6
|
+
sync one — the failure mode that makes most dual-API SDKs quietly wrong.
|
|
7
|
+
|
|
8
|
+
The three binding rules of §8.2 are implemented here and nowhere else:
|
|
9
|
+
|
|
10
|
+
* **Fail open, always.** :meth:`NeedlepathClient.select` has no raising path.
|
|
11
|
+
Every error becomes a :class:`~needlepath.result.SelectionResult` with
|
|
12
|
+
``applied=False`` and the exception recorded on ``.error``.
|
|
13
|
+
* **Pin the operating point explicitly.** The constructor requires one.
|
|
14
|
+
* **Guard the 6 MB cap client-side.** The body is measured before it is sent.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import random
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import AbstractSet, Any, Dict, Optional, Sequence, Tuple
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
|
|
28
|
+
from . import features
|
|
29
|
+
from .errors import (
|
|
30
|
+
NeedlepathConfigError,
|
|
31
|
+
NeedlepathContractError,
|
|
32
|
+
NeedlepathError,
|
|
33
|
+
NeedlepathHTTPError,
|
|
34
|
+
NeedlepathPayloadTooLargeError,
|
|
35
|
+
NeedlepathTimeoutError,
|
|
36
|
+
NeedlepathTransportError,
|
|
37
|
+
)
|
|
38
|
+
from .models import (
|
|
39
|
+
REQUIRED_RESPONSE_FIELDS,
|
|
40
|
+
AdaptiveBudget,
|
|
41
|
+
BudgetSpec,
|
|
42
|
+
ContextRecord,
|
|
43
|
+
ContextRequest,
|
|
44
|
+
ContextResponse,
|
|
45
|
+
TaskSpec,
|
|
46
|
+
)
|
|
47
|
+
from .result import Reason, SelectionResult
|
|
48
|
+
from .transport import (
|
|
49
|
+
AsyncTransport,
|
|
50
|
+
HttpResult,
|
|
51
|
+
Transport,
|
|
52
|
+
UrllibTransport,
|
|
53
|
+
default_async_transport,
|
|
54
|
+
)
|
|
55
|
+
from .version import __version__
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
# Documented defaults
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
#: The documented default endpoint. A durable custom domain replaces this in E1's
|
|
62
|
+
#: follow-through (§5, S-17a); until it exists this is the address published runs
|
|
63
|
+
#: and design partners use, and it stays overridable by argument or by
|
|
64
|
+
#: ``NEEDLEPATH_BASE_URL`` so that swapping it is a config change, not a release.
|
|
65
|
+
DEFAULT_BASE_URL = "https://ayf02egtaf.execute-api.us-east-2.amazonaws.com/prod"
|
|
66
|
+
|
|
67
|
+
SELECT_PATH = "/v1/context/select"
|
|
68
|
+
HEALTH_PATH = "/v1/health"
|
|
69
|
+
OPERATING_POINTS_PATH = "/v1/operating-points"
|
|
70
|
+
|
|
71
|
+
#: API Gateway REST caps a request at 10 MB; Lambda caps a synchronous payload
|
|
72
|
+
#: at 6 MB. 6 MB is therefore the real contract limit for one select call, and
|
|
73
|
+
#: §8.2 requires the client to guard it rather than surface a platform error.
|
|
74
|
+
#:
|
|
75
|
+
#: Measured against the **JSON body**. The Lambda limit applies to the whole
|
|
76
|
+
#: invocation payload — the API Gateway event envelope (headers, request
|
|
77
|
+
#: context, base64 expansion when a body is not UTF-8-clean) sits on top of the
|
|
78
|
+
#: body — so a request that passes this guard at 5.99 MB can still be rejected
|
|
79
|
+
#: upstream. Lower ``max_request_bytes`` if you routinely run near the cap; the
|
|
80
|
+
#: honest guard is the one that says what it does not cover.
|
|
81
|
+
MAX_REQUEST_BYTES = 6 * 1024 * 1024
|
|
82
|
+
|
|
83
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
84
|
+
DEFAULT_MAX_RETRIES = 2
|
|
85
|
+
|
|
86
|
+
#: Retried. Every one of these is a transient condition where a second attempt
|
|
87
|
+
#: has a real chance of a different answer.
|
|
88
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
89
|
+
|
|
90
|
+
#: **Not** retried, and worth saying why: a 500 from this endpoint is
|
|
91
|
+
#: ``engine_error`` — a deterministic failure on this exact input. Retrying it
|
|
92
|
+
#: buys nothing, doubles the latency the caller waits before failing open, and
|
|
93
|
+
#: spends a second billable invocation on a request that will fail identically.
|
|
94
|
+
|
|
95
|
+
ENV_BASE_URL = "NEEDLEPATH_BASE_URL"
|
|
96
|
+
ENV_API_KEY = "NEEDLEPATH_API_KEY"
|
|
97
|
+
ENV_OPERATING_POINT = "NEEDLEPATH_OPERATING_POINT"
|
|
98
|
+
|
|
99
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
# Configuration resolution — shared, so both clients resolve identically
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_base_url(base_url: Optional[str]) -> str:
|
|
108
|
+
raw = base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL
|
|
109
|
+
raw = raw.strip().rstrip("/")
|
|
110
|
+
parts = urlsplit(raw)
|
|
111
|
+
if parts.scheme not in ("http", "https"):
|
|
112
|
+
raise NeedlepathConfigError(f"base_url must be http or https, got {parts.scheme!r}")
|
|
113
|
+
host = (parts.hostname or "").lower()
|
|
114
|
+
if parts.scheme == "http" and host not in _LOOPBACK_HOSTS:
|
|
115
|
+
# "TLS only" is an inherited binding constraint, not a preference
|
|
116
|
+
# (NEEDLEPATH_SAAS_DESIGN §0). Loopback is exempted because the
|
|
117
|
+
# localhost benchmark adapter is plain HTTP by design and is the seam's
|
|
118
|
+
# whole reason for existing.
|
|
119
|
+
raise NeedlepathConfigError(
|
|
120
|
+
f"base_url must use https for any non-loopback host (TLS only); got http://{host}"
|
|
121
|
+
)
|
|
122
|
+
if not host:
|
|
123
|
+
raise NeedlepathConfigError(f"base_url has no host: {raw!r}")
|
|
124
|
+
return raw
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_operating_point(operating_point: Optional[str]) -> str:
|
|
128
|
+
value = operating_point or os.environ.get(ENV_OPERATING_POINT)
|
|
129
|
+
if not value or not value.strip():
|
|
130
|
+
raise NeedlepathConfigError(
|
|
131
|
+
"operating_point is required. Pin an immutable label explicitly "
|
|
132
|
+
"(e.g. 'np-2026-07-r2') so behaviour never shifts under you when the "
|
|
133
|
+
"service default moves; see docs/needlepath/API_VERSIONING.md. "
|
|
134
|
+
f"Pass operating_point=... or set {ENV_OPERATING_POINT}."
|
|
135
|
+
)
|
|
136
|
+
return value.strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolve_api_key(api_key: Optional[str]) -> Optional[str]:
|
|
140
|
+
# An explicitly-passed empty string means "no auth" (the localhost adapter);
|
|
141
|
+
# None means "look in the environment".
|
|
142
|
+
if api_key is not None:
|
|
143
|
+
return api_key.strip() or None
|
|
144
|
+
env = os.environ.get(ENV_API_KEY)
|
|
145
|
+
return env.strip() if env and env.strip() else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def new_request_id() -> str:
|
|
149
|
+
"""A request id inside the published identifier charset.
|
|
150
|
+
|
|
151
|
+
``[A-Za-z0-9._:-]``, at most 128 characters (§1.3). Staying inside it means
|
|
152
|
+
the endpoint never has to sanitize ours, so ``format_metrics`` comes back
|
|
153
|
+
byte-identical to a conformant caller's.
|
|
154
|
+
"""
|
|
155
|
+
return "np-" + uuid.uuid4().hex
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
# Request assembly and response classification — shared by both clients
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_request(
|
|
164
|
+
*,
|
|
165
|
+
records: Sequence[ContextRecord],
|
|
166
|
+
task: TaskSpec,
|
|
167
|
+
max_context_tokens: int,
|
|
168
|
+
operating_point: str,
|
|
169
|
+
request_id: Optional[str] = None,
|
|
170
|
+
max_records: Optional[int] = None,
|
|
171
|
+
max_excerpt_tokens_per_record: Optional[int] = None,
|
|
172
|
+
mode: str = "fixed",
|
|
173
|
+
adaptive: Optional[AdaptiveBudget] = None,
|
|
174
|
+
require_evidence_coverage: Optional[bool] = None,
|
|
175
|
+
render: bool = True,
|
|
176
|
+
render_format: str = "plain",
|
|
177
|
+
return_per_record: bool = True,
|
|
178
|
+
) -> ContextRequest:
|
|
179
|
+
"""Assemble a :class:`ContextRequest` with the operating point already pinned."""
|
|
180
|
+
return ContextRequest(
|
|
181
|
+
request_id=request_id or new_request_id(),
|
|
182
|
+
records=list(records),
|
|
183
|
+
task=task,
|
|
184
|
+
budget=BudgetSpec(
|
|
185
|
+
max_context_tokens=max_context_tokens,
|
|
186
|
+
operating_point=operating_point,
|
|
187
|
+
max_records=max_records,
|
|
188
|
+
max_excerpt_tokens_per_record=max_excerpt_tokens_per_record,
|
|
189
|
+
mode=mode,
|
|
190
|
+
adaptive=adaptive,
|
|
191
|
+
require_evidence_coverage=require_evidence_coverage,
|
|
192
|
+
),
|
|
193
|
+
render=render,
|
|
194
|
+
render_format=render_format,
|
|
195
|
+
return_per_record=return_per_record,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def encode_body(request: ContextRequest, *, shadow: bool) -> bytes:
|
|
200
|
+
"""Serialize the request, applying the shadow feature flag.
|
|
201
|
+
|
|
202
|
+
Compact separators are used because every byte counts against the 6 MB
|
|
203
|
+
guard; the JSON is otherwise identical to the published client's.
|
|
204
|
+
"""
|
|
205
|
+
wire = request.to_wire()
|
|
206
|
+
if shadow and features.wire_shadow_mode_enabled():
|
|
207
|
+
wire["budget"]["mode"] = features.WIRE_SHADOW_MODE_VALUE
|
|
208
|
+
return json.dumps(wire, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_response_payload(payload: Any, request_id: str) -> ContextResponse:
|
|
212
|
+
"""Parse and contract-check a 2xx body.
|
|
213
|
+
|
|
214
|
+
Raises :class:`NeedlepathContractError` on anything a caller cannot trust to
|
|
215
|
+
describe *this* request. Unknown fields are kept, never rejected — the
|
|
216
|
+
contract is additive under ``/v1``.
|
|
217
|
+
"""
|
|
218
|
+
if not isinstance(payload, dict):
|
|
219
|
+
raise NeedlepathContractError(f"response is not a JSON object: {type(payload).__name__}")
|
|
220
|
+
missing = [f for f in REQUIRED_RESPONSE_FIELDS if f not in payload]
|
|
221
|
+
if missing:
|
|
222
|
+
raise NeedlepathContractError(f"response missing required fields: {missing}")
|
|
223
|
+
echoed = payload.get("request_id")
|
|
224
|
+
if echoed != request_id:
|
|
225
|
+
raise NeedlepathContractError(
|
|
226
|
+
f"response request_id {echoed!r} does not echo the request's {request_id!r}"
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
return ContextResponse.from_wire(payload)
|
|
230
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
231
|
+
raise NeedlepathContractError(f"malformed response body: {exc}") from exc
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def classify(
|
|
235
|
+
response: ContextResponse,
|
|
236
|
+
*,
|
|
237
|
+
render_requested: bool = True,
|
|
238
|
+
require_per_record: bool = True,
|
|
239
|
+
known_record_ids: Optional[AbstractSet[str]] = None,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Decide whether a contract-valid response is safe to *apply*.
|
|
242
|
+
|
|
243
|
+
This is the NEXPE-413 rule, generalized. An empty selection returned as a
|
|
244
|
+
success is a defect at the engine boundary; at a framework boundary it must
|
|
245
|
+
degrade to passthrough, never to an empty context. So must every other way
|
|
246
|
+
the service can say "I did not select anything useful".
|
|
247
|
+
|
|
248
|
+
Note what this function does **not** do: it never reads ``gate.reason``.
|
|
249
|
+
That value is an open enum with a planned third outcome, and a client that
|
|
250
|
+
branches on today's prefixes is a client that breaks additively. Every
|
|
251
|
+
decision here comes from a structural field.
|
|
252
|
+
"""
|
|
253
|
+
if response.selection_error:
|
|
254
|
+
return Reason.SELECTION_ERROR
|
|
255
|
+
if response.fallback_used:
|
|
256
|
+
# The engine stood down and is asking for the full context. Applying a
|
|
257
|
+
# fallback render would at best be a no-op and at worst a lossy one.
|
|
258
|
+
return Reason.ENGINE_FALLBACK
|
|
259
|
+
safety = response.safety
|
|
260
|
+
if safety is not None and (safety.fallback_required or not safety.selection_safe):
|
|
261
|
+
# The coverage verdict is a structural field, not the open enum: it says
|
|
262
|
+
# in as many words that this selection should not be used.
|
|
263
|
+
return Reason.SELECTION_UNSAFE
|
|
264
|
+
if response.records_selected <= 0:
|
|
265
|
+
return Reason.EMPTY_SELECTION
|
|
266
|
+
if render_requested and not response.rendered_context.strip():
|
|
267
|
+
return Reason.EMPTY_SELECTION
|
|
268
|
+
if require_per_record and not response.selected:
|
|
269
|
+
return Reason.NO_PER_RECORD_DETAIL
|
|
270
|
+
# A response whose selected ids are all unknown to us is the most destructive
|
|
271
|
+
# shape there is: an adapter that maps "not in the selection" to a
|
|
272
|
+
# placeholder would collapse EVERY record the caller sent. Refuse it here,
|
|
273
|
+
# once, rather than in each adapter.
|
|
274
|
+
if (
|
|
275
|
+
known_record_ids is not None
|
|
276
|
+
and response.selected
|
|
277
|
+
and not (response.selected_ids & set(known_record_ids))
|
|
278
|
+
):
|
|
279
|
+
return Reason.UNKNOWN_SELECTION
|
|
280
|
+
return Reason.OK
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _retry_after_seconds(headers: Dict[str, str]) -> Optional[float]:
|
|
284
|
+
"""Parse ``Retry-After`` when it is a delta-seconds value.
|
|
285
|
+
|
|
286
|
+
HTTP-date form is deliberately not honoured: it needs a clock-skew argument
|
|
287
|
+
to be correct, and this client's backoff is already bounded by the caller's
|
|
288
|
+
deadline, so ignoring it degrades to the default backoff rather than to
|
|
289
|
+
anything wrong.
|
|
290
|
+
"""
|
|
291
|
+
raw = headers.get("retry-after")
|
|
292
|
+
if not raw:
|
|
293
|
+
return None
|
|
294
|
+
try:
|
|
295
|
+
value = float(raw.strip())
|
|
296
|
+
except (TypeError, ValueError):
|
|
297
|
+
return None
|
|
298
|
+
return value if value >= 0 else None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class _Backoff:
|
|
302
|
+
"""Full-jitter exponential backoff, bounded by the caller's deadline."""
|
|
303
|
+
|
|
304
|
+
def __init__(self, base: float = 0.1, cap: float = 2.0, rng: Optional[random.Random] = None):
|
|
305
|
+
self._base = base
|
|
306
|
+
self._cap = cap
|
|
307
|
+
self._rng = rng or random.Random()
|
|
308
|
+
|
|
309
|
+
def delay(self, attempt: int, retry_after: Optional[float], remaining: float) -> float:
|
|
310
|
+
window = min(self._cap, self._base * (2**attempt))
|
|
311
|
+
wait = retry_after if retry_after is not None else self._rng.uniform(0.0, window)
|
|
312
|
+
# Leave room to actually make the next attempt.
|
|
313
|
+
return max(0.0, min(wait, max(0.0, remaining - 0.05)))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------------------- #
|
|
317
|
+
# Base
|
|
318
|
+
# --------------------------------------------------------------------------- #
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class _ClientBase:
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
*,
|
|
325
|
+
api_key: Optional[str] = None,
|
|
326
|
+
operating_point: Optional[str] = None,
|
|
327
|
+
base_url: Optional[str] = None,
|
|
328
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
329
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
330
|
+
max_request_bytes: int = MAX_REQUEST_BYTES,
|
|
331
|
+
shadow: bool = False,
|
|
332
|
+
user_agent_suffix: Optional[str] = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
self.base_url = _resolve_base_url(base_url)
|
|
335
|
+
self.operating_point = _resolve_operating_point(operating_point)
|
|
336
|
+
self._api_key = _resolve_api_key(api_key)
|
|
337
|
+
if timeout <= 0:
|
|
338
|
+
raise NeedlepathConfigError(f"timeout must be positive, got {timeout!r}")
|
|
339
|
+
if max_retries < 0:
|
|
340
|
+
raise NeedlepathConfigError(f"max_retries must be >= 0, got {max_retries!r}")
|
|
341
|
+
if max_request_bytes <= 0:
|
|
342
|
+
raise NeedlepathConfigError(
|
|
343
|
+
f"max_request_bytes must be positive, got {max_request_bytes!r}"
|
|
344
|
+
)
|
|
345
|
+
self.timeout = float(timeout)
|
|
346
|
+
self.max_retries = int(max_retries)
|
|
347
|
+
self.max_request_bytes = int(max_request_bytes)
|
|
348
|
+
self.shadow = bool(shadow)
|
|
349
|
+
self._user_agent = f"needlepath-python/{__version__}"
|
|
350
|
+
if user_agent_suffix:
|
|
351
|
+
self._user_agent += f" {user_agent_suffix}"
|
|
352
|
+
self._backoff = _Backoff()
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------- #
|
|
355
|
+
|
|
356
|
+
def __repr__(self) -> str:
|
|
357
|
+
# The API key is never rendered. Whether one is configured is useful;
|
|
358
|
+
# its value is not, and this object ends up in tracebacks and log lines.
|
|
359
|
+
return (
|
|
360
|
+
f"{type(self).__name__}(base_url={self.base_url!r}, "
|
|
361
|
+
f"operating_point={self.operating_point!r}, "
|
|
362
|
+
f"api_key={'set' if self._api_key else 'unset'}, "
|
|
363
|
+
f"shadow={self.shadow!r})"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _headers(self) -> Dict[str, str]:
|
|
367
|
+
headers = {
|
|
368
|
+
"content-type": "application/json",
|
|
369
|
+
"accept": "application/json",
|
|
370
|
+
"user-agent": self._user_agent,
|
|
371
|
+
}
|
|
372
|
+
if self._api_key:
|
|
373
|
+
headers["authorization"] = f"Bearer {self._api_key}"
|
|
374
|
+
return headers
|
|
375
|
+
|
|
376
|
+
def _url(self, path: str) -> str:
|
|
377
|
+
return f"{self.base_url}{path}"
|
|
378
|
+
|
|
379
|
+
def _prepare(
|
|
380
|
+
self, request: ContextRequest, shadow: bool
|
|
381
|
+
) -> Tuple[bytes, Optional[NeedlepathPayloadTooLargeError]]:
|
|
382
|
+
body = encode_body(request, shadow=shadow)
|
|
383
|
+
if len(body) > self.max_request_bytes:
|
|
384
|
+
return body, NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
|
|
385
|
+
return body, None
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _to_error(result: HttpResult) -> Optional[NeedlepathHTTPError]:
|
|
389
|
+
if 200 <= result.status < 300:
|
|
390
|
+
return None
|
|
391
|
+
return NeedlepathHTTPError(
|
|
392
|
+
result.status,
|
|
393
|
+
body=result.json(),
|
|
394
|
+
text=result.text(),
|
|
395
|
+
retry_after=_retry_after_seconds(result.headers),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# --------------------------------------------------------------------------- #
|
|
400
|
+
# Sync
|
|
401
|
+
# --------------------------------------------------------------------------- #
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class NeedlepathClient(_ClientBase):
|
|
405
|
+
"""Synchronous client for the Needlepath hosted context-selection service.
|
|
406
|
+
|
|
407
|
+
::
|
|
408
|
+
|
|
409
|
+
from needlepath import NeedlepathClient, ContextRecord, TaskSpec
|
|
410
|
+
|
|
411
|
+
client = NeedlepathClient(
|
|
412
|
+
api_key="np_live_...", # or NEEDLEPATH_API_KEY
|
|
413
|
+
operating_point="np-2026-07-r2", # required, and immutable
|
|
414
|
+
)
|
|
415
|
+
result = client.select(
|
|
416
|
+
records=[ContextRecord(text=..., kind="tool_result", id="r1")],
|
|
417
|
+
task=TaskSpec(prompt="what is the invoice total?"),
|
|
418
|
+
max_context_tokens=4000,
|
|
419
|
+
)
|
|
420
|
+
if result.applied:
|
|
421
|
+
use(result.rendered_context)
|
|
422
|
+
else:
|
|
423
|
+
use(my_original_context) # always safe, always available
|
|
424
|
+
|
|
425
|
+
:param api_key: ``np_live_…``. Falls back to ``NEEDLEPATH_API_KEY``. Pass
|
|
426
|
+
``""`` to send no ``Authorization`` header (the localhost adapter).
|
|
427
|
+
:param operating_point: **Required.** An immutable label such as
|
|
428
|
+
``np-2026-07-r2``. Falls back to ``NEEDLEPATH_OPERATING_POINT``.
|
|
429
|
+
:param base_url: Defaults to :data:`DEFAULT_BASE_URL` or ``NEEDLEPATH_BASE_URL``.
|
|
430
|
+
:param timeout: Total wall-clock deadline for one :meth:`select`, retries
|
|
431
|
+
and backoff included. Not a per-attempt timeout.
|
|
432
|
+
:param max_retries: Extra attempts after the first, for transient statuses
|
|
433
|
+
and transport failures only.
|
|
434
|
+
:param shadow: Default shadow mode for this client. See
|
|
435
|
+
:mod:`needlepath.features`.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def __init__(self, *, transport: Optional[Transport] = None, **kwargs: Any) -> None:
|
|
439
|
+
super().__init__(**kwargs)
|
|
440
|
+
self._transport: Transport = transport or UrllibTransport()
|
|
441
|
+
self._owns_transport = transport is None
|
|
442
|
+
|
|
443
|
+
# ---------------------------------------------------------------- #
|
|
444
|
+
# The fail-open surface
|
|
445
|
+
# ---------------------------------------------------------------- #
|
|
446
|
+
|
|
447
|
+
def select(
|
|
448
|
+
self,
|
|
449
|
+
*,
|
|
450
|
+
records: Sequence[ContextRecord],
|
|
451
|
+
task: TaskSpec,
|
|
452
|
+
max_context_tokens: int,
|
|
453
|
+
request_id: Optional[str] = None,
|
|
454
|
+
shadow: Optional[bool] = None,
|
|
455
|
+
require_per_record: bool = True,
|
|
456
|
+
**budget_kwargs: Any,
|
|
457
|
+
) -> SelectionResult:
|
|
458
|
+
"""Select context. **Never raises.**
|
|
459
|
+
|
|
460
|
+
Any keyword accepted by :func:`build_request` other than ``operating_point``
|
|
461
|
+
may be passed through ``budget_kwargs`` (``max_records``, ``mode``,
|
|
462
|
+
``adaptive``, ``render_format``, …).
|
|
463
|
+
"""
|
|
464
|
+
if _is_empty(records):
|
|
465
|
+
return SelectionResult.passthrough_result(
|
|
466
|
+
Reason.NO_RECORDS,
|
|
467
|
+
request_id=request_id or "",
|
|
468
|
+
operating_point=self.operating_point,
|
|
469
|
+
shadow=self.shadow if shadow is None else bool(shadow),
|
|
470
|
+
)
|
|
471
|
+
try:
|
|
472
|
+
request = build_request(
|
|
473
|
+
records=records,
|
|
474
|
+
task=task,
|
|
475
|
+
max_context_tokens=max_context_tokens,
|
|
476
|
+
operating_point=self.operating_point,
|
|
477
|
+
request_id=request_id,
|
|
478
|
+
**budget_kwargs,
|
|
479
|
+
)
|
|
480
|
+
except Exception as exc: # noqa: BLE001 - assembling must not be a crash path
|
|
481
|
+
return SelectionResult.passthrough_result(
|
|
482
|
+
Reason.UNEXPECTED_ERROR,
|
|
483
|
+
request_id=request_id or "",
|
|
484
|
+
operating_point=self.operating_point,
|
|
485
|
+
error=exc,
|
|
486
|
+
)
|
|
487
|
+
return self.select_request(request, shadow=shadow, require_per_record=require_per_record)
|
|
488
|
+
|
|
489
|
+
def select_request(
|
|
490
|
+
self,
|
|
491
|
+
request: ContextRequest,
|
|
492
|
+
*,
|
|
493
|
+
shadow: Optional[bool] = None,
|
|
494
|
+
require_per_record: bool = True,
|
|
495
|
+
timeout: Optional[float] = None,
|
|
496
|
+
) -> SelectionResult:
|
|
497
|
+
"""Select context from an assembled request. **Never raises.**"""
|
|
498
|
+
is_shadow = self.shadow if shadow is None else bool(shadow)
|
|
499
|
+
started = time.perf_counter()
|
|
500
|
+
try:
|
|
501
|
+
response, attempts, size = self._call(request, is_shadow, timeout)
|
|
502
|
+
# `_finish` is INSIDE the boundary on purpose. It reads fields off a
|
|
503
|
+
# body that came from a socket; a malformed one (`rendered_context`
|
|
504
|
+
# as a number, say) would otherwise raise here, outside every
|
|
505
|
+
# except-clause, and reach the caller as a crash.
|
|
506
|
+
return _finish(
|
|
507
|
+
response,
|
|
508
|
+
request=request,
|
|
509
|
+
operating_point=self.operating_point,
|
|
510
|
+
shadow=is_shadow,
|
|
511
|
+
require_per_record=require_per_record,
|
|
512
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
513
|
+
attempts=attempts,
|
|
514
|
+
request_bytes=size,
|
|
515
|
+
)
|
|
516
|
+
except NeedlepathError as exc:
|
|
517
|
+
return SelectionResult.passthrough_result(
|
|
518
|
+
_reason_for(exc),
|
|
519
|
+
request_id=request.request_id,
|
|
520
|
+
operating_point=self.operating_point,
|
|
521
|
+
shadow=is_shadow,
|
|
522
|
+
error=exc,
|
|
523
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
524
|
+
attempts=getattr(exc, "_np_attempts", 0),
|
|
525
|
+
request_bytes=getattr(exc, "_np_bytes", 0),
|
|
526
|
+
)
|
|
527
|
+
except Exception as exc: # noqa: BLE001 - a defect here must still fail open
|
|
528
|
+
return SelectionResult.passthrough_result(
|
|
529
|
+
Reason.UNEXPECTED_ERROR,
|
|
530
|
+
request_id=request.request_id,
|
|
531
|
+
operating_point=self.operating_point,
|
|
532
|
+
shadow=is_shadow,
|
|
533
|
+
error=exc,
|
|
534
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def select_or_raise(
|
|
538
|
+
self,
|
|
539
|
+
request: ContextRequest,
|
|
540
|
+
*,
|
|
541
|
+
shadow: Optional[bool] = None,
|
|
542
|
+
timeout: Optional[float] = None,
|
|
543
|
+
) -> ContextResponse:
|
|
544
|
+
"""Select context, raising on any error path.
|
|
545
|
+
|
|
546
|
+
**Not for framework adapters.** §8.2's fail-open rule is binding at a
|
|
547
|
+
framework boundary, and this method exists for the two places it does
|
|
548
|
+
not apply: diagnostics, and callers who are themselves a benchmark and
|
|
549
|
+
must not silently substitute a passthrough for a measurement.
|
|
550
|
+
"""
|
|
551
|
+
response, _attempts, _size = self._call(
|
|
552
|
+
request, self.shadow if shadow is None else bool(shadow), timeout
|
|
553
|
+
)
|
|
554
|
+
return response
|
|
555
|
+
|
|
556
|
+
# ---------------------------------------------------------------- #
|
|
557
|
+
|
|
558
|
+
def _call(
|
|
559
|
+
self, request: ContextRequest, shadow: bool, timeout: Optional[float]
|
|
560
|
+
) -> Tuple[ContextResponse, int, int]:
|
|
561
|
+
body, oversize = self._prepare(request, shadow)
|
|
562
|
+
if oversize is not None:
|
|
563
|
+
oversize._np_bytes = len(body) # type: ignore[attr-defined]
|
|
564
|
+
raise oversize
|
|
565
|
+
deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
|
|
566
|
+
url = self._url(SELECT_PATH)
|
|
567
|
+
headers = self._headers()
|
|
568
|
+
last: Optional[NeedlepathError] = None
|
|
569
|
+
attempts = 0
|
|
570
|
+
for attempt in range(self.max_retries + 1):
|
|
571
|
+
remaining = deadline - time.monotonic()
|
|
572
|
+
if remaining <= 0:
|
|
573
|
+
break
|
|
574
|
+
attempts += 1
|
|
575
|
+
try:
|
|
576
|
+
http = self._transport.request(
|
|
577
|
+
"POST", url, headers=headers, body=body, timeout=remaining
|
|
578
|
+
)
|
|
579
|
+
err = self._to_error(http)
|
|
580
|
+
if err is None:
|
|
581
|
+
if time.monotonic() > deadline:
|
|
582
|
+
# Defence in depth behind the transport's own read
|
|
583
|
+
# deadline: a response that arrives after the caller's
|
|
584
|
+
# budget is not a response the caller asked for.
|
|
585
|
+
raise NeedlepathTimeoutError(
|
|
586
|
+
"a response arrived after the deadline had elapsed"
|
|
587
|
+
)
|
|
588
|
+
response = validate_response_payload(http.json(), request.request_id)
|
|
589
|
+
return response, attempts, len(body)
|
|
590
|
+
last = err
|
|
591
|
+
if http.status not in RETRY_STATUSES:
|
|
592
|
+
break
|
|
593
|
+
retry_after = err.retry_after
|
|
594
|
+
except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
|
|
595
|
+
last = exc
|
|
596
|
+
retry_after = None
|
|
597
|
+
except NeedlepathContractError as exc:
|
|
598
|
+
# A conformant server does not do this; a second attempt at the
|
|
599
|
+
# same build will not do it differently.
|
|
600
|
+
last = exc
|
|
601
|
+
break
|
|
602
|
+
if attempt >= self.max_retries:
|
|
603
|
+
break
|
|
604
|
+
delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
|
|
605
|
+
if delay <= 0 and deadline - time.monotonic() <= 0:
|
|
606
|
+
break
|
|
607
|
+
if delay:
|
|
608
|
+
time.sleep(delay)
|
|
609
|
+
if last is None: # pragma: no cover - only when the deadline was already past
|
|
610
|
+
last = NeedlepathTimeoutError("deadline elapsed before any attempt was made")
|
|
611
|
+
last._np_attempts = attempts # type: ignore[attr-defined]
|
|
612
|
+
last._np_bytes = len(body) # type: ignore[attr-defined]
|
|
613
|
+
raise last
|
|
614
|
+
|
|
615
|
+
# ---------------------------------------------------------------- #
|
|
616
|
+
# Diagnostics. These raise: they are not on any agent's hot path.
|
|
617
|
+
# ---------------------------------------------------------------- #
|
|
618
|
+
|
|
619
|
+
def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
620
|
+
"""``GET /v1/health``. Raises on failure."""
|
|
621
|
+
return self._get(HEALTH_PATH, timeout)
|
|
622
|
+
|
|
623
|
+
def operating_points(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
624
|
+
"""``GET /v1/operating-points``. Raises on failure.
|
|
625
|
+
|
|
626
|
+
Useful once, at wire-up, to assert that the label you pinned is one the
|
|
627
|
+
endpoint actually serves. Do not call it per request.
|
|
628
|
+
"""
|
|
629
|
+
return self._get(OPERATING_POINTS_PATH, timeout)
|
|
630
|
+
|
|
631
|
+
def _get(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
|
|
632
|
+
http = self._transport.request(
|
|
633
|
+
"GET",
|
|
634
|
+
self._url(path),
|
|
635
|
+
headers=self._headers(),
|
|
636
|
+
body=None,
|
|
637
|
+
timeout=float(timeout if timeout is not None else self.timeout),
|
|
638
|
+
)
|
|
639
|
+
err = self._to_error(http)
|
|
640
|
+
if err is not None:
|
|
641
|
+
raise err
|
|
642
|
+
payload = http.json()
|
|
643
|
+
if not isinstance(payload, dict):
|
|
644
|
+
raise NeedlepathContractError(f"{path} did not return a JSON object")
|
|
645
|
+
return payload
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------- #
|
|
648
|
+
|
|
649
|
+
def close(self) -> None:
|
|
650
|
+
if self._owns_transport:
|
|
651
|
+
self._transport.close()
|
|
652
|
+
|
|
653
|
+
def __enter__(self) -> NeedlepathClient:
|
|
654
|
+
return self
|
|
655
|
+
|
|
656
|
+
def __exit__(self, *exc: Any) -> None:
|
|
657
|
+
self.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# --------------------------------------------------------------------------- #
|
|
661
|
+
# Async
|
|
662
|
+
# --------------------------------------------------------------------------- #
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class AsyncNeedlepathClient(_ClientBase):
|
|
666
|
+
"""Asynchronous twin of :class:`NeedlepathClient`.
|
|
667
|
+
|
|
668
|
+
Identical behaviour, identical results — the decision logic is the same
|
|
669
|
+
module-level functions. Uses ``httpx.AsyncClient`` when httpx is importable
|
|
670
|
+
and a worker thread otherwise, so it never blocks the event loop either way.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
def __init__(self, *, transport: Optional[AsyncTransport] = None, **kwargs: Any) -> None:
|
|
674
|
+
super().__init__(**kwargs)
|
|
675
|
+
self._transport: AsyncTransport = transport or default_async_transport()
|
|
676
|
+
self._owns_transport = transport is None
|
|
677
|
+
|
|
678
|
+
async def select(
|
|
679
|
+
self,
|
|
680
|
+
*,
|
|
681
|
+
records: Sequence[ContextRecord],
|
|
682
|
+
task: TaskSpec,
|
|
683
|
+
max_context_tokens: int,
|
|
684
|
+
request_id: Optional[str] = None,
|
|
685
|
+
shadow: Optional[bool] = None,
|
|
686
|
+
require_per_record: bool = True,
|
|
687
|
+
**budget_kwargs: Any,
|
|
688
|
+
) -> SelectionResult:
|
|
689
|
+
"""Select context. **Never raises.**"""
|
|
690
|
+
if _is_empty(records):
|
|
691
|
+
return SelectionResult.passthrough_result(
|
|
692
|
+
Reason.NO_RECORDS,
|
|
693
|
+
request_id=request_id or "",
|
|
694
|
+
operating_point=self.operating_point,
|
|
695
|
+
shadow=self.shadow if shadow is None else bool(shadow),
|
|
696
|
+
)
|
|
697
|
+
try:
|
|
698
|
+
request = build_request(
|
|
699
|
+
records=records,
|
|
700
|
+
task=task,
|
|
701
|
+
max_context_tokens=max_context_tokens,
|
|
702
|
+
operating_point=self.operating_point,
|
|
703
|
+
request_id=request_id,
|
|
704
|
+
**budget_kwargs,
|
|
705
|
+
)
|
|
706
|
+
except Exception as exc: # noqa: BLE001
|
|
707
|
+
return SelectionResult.passthrough_result(
|
|
708
|
+
Reason.UNEXPECTED_ERROR,
|
|
709
|
+
request_id=request_id or "",
|
|
710
|
+
operating_point=self.operating_point,
|
|
711
|
+
error=exc,
|
|
712
|
+
)
|
|
713
|
+
return await self.select_request(
|
|
714
|
+
request, shadow=shadow, require_per_record=require_per_record
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
async def select_request(
|
|
718
|
+
self,
|
|
719
|
+
request: ContextRequest,
|
|
720
|
+
*,
|
|
721
|
+
shadow: Optional[bool] = None,
|
|
722
|
+
require_per_record: bool = True,
|
|
723
|
+
timeout: Optional[float] = None,
|
|
724
|
+
) -> SelectionResult:
|
|
725
|
+
"""Select context from an assembled request. **Never raises.**"""
|
|
726
|
+
is_shadow = self.shadow if shadow is None else bool(shadow)
|
|
727
|
+
started = time.perf_counter()
|
|
728
|
+
try:
|
|
729
|
+
response, attempts, size = await self._call(request, is_shadow, timeout)
|
|
730
|
+
# Inside the boundary, for the same reason as the sync twin.
|
|
731
|
+
return _finish(
|
|
732
|
+
response,
|
|
733
|
+
request=request,
|
|
734
|
+
operating_point=self.operating_point,
|
|
735
|
+
shadow=is_shadow,
|
|
736
|
+
require_per_record=require_per_record,
|
|
737
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
738
|
+
attempts=attempts,
|
|
739
|
+
request_bytes=size,
|
|
740
|
+
)
|
|
741
|
+
except NeedlepathError as exc:
|
|
742
|
+
return SelectionResult.passthrough_result(
|
|
743
|
+
_reason_for(exc),
|
|
744
|
+
request_id=request.request_id,
|
|
745
|
+
operating_point=self.operating_point,
|
|
746
|
+
shadow=is_shadow,
|
|
747
|
+
error=exc,
|
|
748
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
749
|
+
attempts=getattr(exc, "_np_attempts", 0),
|
|
750
|
+
request_bytes=getattr(exc, "_np_bytes", 0),
|
|
751
|
+
)
|
|
752
|
+
except Exception as exc: # noqa: BLE001
|
|
753
|
+
return SelectionResult.passthrough_result(
|
|
754
|
+
Reason.UNEXPECTED_ERROR,
|
|
755
|
+
request_id=request.request_id,
|
|
756
|
+
operating_point=self.operating_point,
|
|
757
|
+
shadow=is_shadow,
|
|
758
|
+
error=exc,
|
|
759
|
+
client_latency_ms=(time.perf_counter() - started) * 1000.0,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
async def select_or_raise(
|
|
763
|
+
self,
|
|
764
|
+
request: ContextRequest,
|
|
765
|
+
*,
|
|
766
|
+
shadow: Optional[bool] = None,
|
|
767
|
+
timeout: Optional[float] = None,
|
|
768
|
+
) -> ContextResponse:
|
|
769
|
+
"""Select context, raising on any error path. Not for adapters."""
|
|
770
|
+
response, _attempts, _size = await self._call(
|
|
771
|
+
request, self.shadow if shadow is None else bool(shadow), timeout
|
|
772
|
+
)
|
|
773
|
+
return response
|
|
774
|
+
|
|
775
|
+
async def _call(
|
|
776
|
+
self, request: ContextRequest, shadow: bool, timeout: Optional[float]
|
|
777
|
+
) -> Tuple[ContextResponse, int, int]:
|
|
778
|
+
body, oversize = self._prepare(request, shadow)
|
|
779
|
+
if oversize is not None:
|
|
780
|
+
oversize._np_bytes = len(body) # type: ignore[attr-defined]
|
|
781
|
+
raise oversize
|
|
782
|
+
deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
|
|
783
|
+
url = self._url(SELECT_PATH)
|
|
784
|
+
headers = self._headers()
|
|
785
|
+
last: Optional[NeedlepathError] = None
|
|
786
|
+
attempts = 0
|
|
787
|
+
for attempt in range(self.max_retries + 1):
|
|
788
|
+
remaining = deadline - time.monotonic()
|
|
789
|
+
if remaining <= 0:
|
|
790
|
+
break
|
|
791
|
+
attempts += 1
|
|
792
|
+
try:
|
|
793
|
+
http = await self._transport.request(
|
|
794
|
+
"POST", url, headers=headers, body=body, timeout=remaining
|
|
795
|
+
)
|
|
796
|
+
err = self._to_error(http)
|
|
797
|
+
if err is None:
|
|
798
|
+
if time.monotonic() > deadline:
|
|
799
|
+
# Defence in depth behind the transport's own read
|
|
800
|
+
# deadline: a response that arrives after the caller's
|
|
801
|
+
# budget is not a response the caller asked for.
|
|
802
|
+
raise NeedlepathTimeoutError(
|
|
803
|
+
"a response arrived after the deadline had elapsed"
|
|
804
|
+
)
|
|
805
|
+
response = validate_response_payload(http.json(), request.request_id)
|
|
806
|
+
return response, attempts, len(body)
|
|
807
|
+
last = err
|
|
808
|
+
if http.status not in RETRY_STATUSES:
|
|
809
|
+
break
|
|
810
|
+
retry_after = err.retry_after
|
|
811
|
+
except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
|
|
812
|
+
last = exc
|
|
813
|
+
retry_after = None
|
|
814
|
+
except NeedlepathContractError as exc:
|
|
815
|
+
last = exc
|
|
816
|
+
break
|
|
817
|
+
if attempt >= self.max_retries:
|
|
818
|
+
break
|
|
819
|
+
delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
|
|
820
|
+
if delay <= 0 and deadline - time.monotonic() <= 0:
|
|
821
|
+
break
|
|
822
|
+
if delay:
|
|
823
|
+
await asyncio.sleep(delay)
|
|
824
|
+
if last is None: # pragma: no cover
|
|
825
|
+
last = NeedlepathTimeoutError("deadline elapsed before any attempt was made")
|
|
826
|
+
last._np_attempts = attempts # type: ignore[attr-defined]
|
|
827
|
+
last._np_bytes = len(body) # type: ignore[attr-defined]
|
|
828
|
+
raise last
|
|
829
|
+
|
|
830
|
+
async def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
831
|
+
return await self._aget(HEALTH_PATH, timeout)
|
|
832
|
+
|
|
833
|
+
async def operating_points(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
834
|
+
return await self._aget(OPERATING_POINTS_PATH, timeout)
|
|
835
|
+
|
|
836
|
+
async def _aget(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
|
|
837
|
+
http = await self._transport.request(
|
|
838
|
+
"GET",
|
|
839
|
+
self._url(path),
|
|
840
|
+
headers=self._headers(),
|
|
841
|
+
body=None,
|
|
842
|
+
timeout=float(timeout if timeout is not None else self.timeout),
|
|
843
|
+
)
|
|
844
|
+
err = self._to_error(http)
|
|
845
|
+
if err is not None:
|
|
846
|
+
raise err
|
|
847
|
+
payload = http.json()
|
|
848
|
+
if not isinstance(payload, dict):
|
|
849
|
+
raise NeedlepathContractError(f"{path} did not return a JSON object")
|
|
850
|
+
return payload
|
|
851
|
+
|
|
852
|
+
async def aclose(self) -> None:
|
|
853
|
+
if self._owns_transport:
|
|
854
|
+
await self._transport.aclose()
|
|
855
|
+
|
|
856
|
+
async def __aenter__(self) -> AsyncNeedlepathClient:
|
|
857
|
+
return self
|
|
858
|
+
|
|
859
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
860
|
+
await self.aclose()
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# --------------------------------------------------------------------------- #
|
|
864
|
+
# Shared outcome assembly
|
|
865
|
+
# --------------------------------------------------------------------------- #
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _is_empty(records: Any) -> bool:
|
|
869
|
+
"""Whether there is nothing to select over, for any sequence-ish argument.
|
|
870
|
+
|
|
871
|
+
`not records` is the obvious spelling and it raises for anything with an
|
|
872
|
+
ambiguous truth value (a numpy array, a pandas Series) — outside the
|
|
873
|
+
fail-open boundary, because it runs before the request is even built.
|
|
874
|
+
"""
|
|
875
|
+
try:
|
|
876
|
+
return len(records) == 0
|
|
877
|
+
except Exception: # noqa: BLE001
|
|
878
|
+
return False
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _reason_for(exc: NeedlepathError) -> str:
|
|
882
|
+
if isinstance(exc, NeedlepathPayloadTooLargeError):
|
|
883
|
+
return Reason.PAYLOAD_TOO_LARGE
|
|
884
|
+
if isinstance(exc, NeedlepathTimeoutError):
|
|
885
|
+
return Reason.TIMEOUT
|
|
886
|
+
if isinstance(exc, NeedlepathTransportError):
|
|
887
|
+
return Reason.TRANSPORT_ERROR
|
|
888
|
+
if isinstance(exc, NeedlepathHTTPError):
|
|
889
|
+
return Reason.HTTP_ERROR
|
|
890
|
+
if isinstance(exc, NeedlepathContractError):
|
|
891
|
+
return Reason.CONTRACT_ERROR
|
|
892
|
+
return Reason.UNEXPECTED_ERROR
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _finish(
|
|
896
|
+
response: ContextResponse,
|
|
897
|
+
*,
|
|
898
|
+
request: ContextRequest,
|
|
899
|
+
operating_point: str,
|
|
900
|
+
shadow: bool,
|
|
901
|
+
require_per_record: bool,
|
|
902
|
+
client_latency_ms: float,
|
|
903
|
+
attempts: int,
|
|
904
|
+
request_bytes: int,
|
|
905
|
+
) -> SelectionResult:
|
|
906
|
+
reason = classify(
|
|
907
|
+
response,
|
|
908
|
+
render_requested=request.render,
|
|
909
|
+
require_per_record=require_per_record and request.return_per_record,
|
|
910
|
+
known_record_ids={r.id for r in request.records if r.id},
|
|
911
|
+
)
|
|
912
|
+
# Shadow wins over OK and *loses* to every decline: a shadow run must report
|
|
913
|
+
# the same reason a live run would have, or the shadow report predicts an
|
|
914
|
+
# invoice for selections that would never have been applied.
|
|
915
|
+
if reason == Reason.OK and shadow:
|
|
916
|
+
reason = Reason.SHADOW
|
|
917
|
+
return SelectionResult(
|
|
918
|
+
applied=(reason == Reason.OK),
|
|
919
|
+
reason=reason,
|
|
920
|
+
request_id=request.request_id,
|
|
921
|
+
operating_point=operating_point,
|
|
922
|
+
shadow=shadow,
|
|
923
|
+
response=response,
|
|
924
|
+
error=None,
|
|
925
|
+
client_latency_ms=client_latency_ms,
|
|
926
|
+
attempts=attempts,
|
|
927
|
+
request_bytes=request_bytes,
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
__all__ = [
|
|
932
|
+
"DEFAULT_BASE_URL",
|
|
933
|
+
"SELECT_PATH",
|
|
934
|
+
"HEALTH_PATH",
|
|
935
|
+
"OPERATING_POINTS_PATH",
|
|
936
|
+
"MAX_REQUEST_BYTES",
|
|
937
|
+
"DEFAULT_TIMEOUT_S",
|
|
938
|
+
"DEFAULT_MAX_RETRIES",
|
|
939
|
+
"RETRY_STATUSES",
|
|
940
|
+
"NeedlepathClient",
|
|
941
|
+
"AsyncNeedlepathClient",
|
|
942
|
+
"build_request",
|
|
943
|
+
"encode_body",
|
|
944
|
+
"classify",
|
|
945
|
+
"validate_response_payload",
|
|
946
|
+
"new_request_id",
|
|
947
|
+
]
|