bynn-decisionnode 0.3.1__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.
- bynn_decisionnode-0.3.1.dist-info/METADATA +939 -0
- bynn_decisionnode-0.3.1.dist-info/RECORD +19 -0
- bynn_decisionnode-0.3.1.dist-info/WHEEL +4 -0
- bynn_decisionnode-0.3.1.dist-info/licenses/LICENSE +21 -0
- decisionnode/__init__.py +195 -0
- decisionnode/_base.py +548 -0
- decisionnode/_batches.py +333 -0
- decisionnode/_boosters.py +553 -0
- decisionnode/_client.py +2269 -0
- decisionnode/_errors.py +218 -0
- decisionnode/_images.py +283 -0
- decisionnode/_questions.py +262 -0
- decisionnode/_sessions.py +711 -0
- decisionnode/_types.py +764 -0
- decisionnode/_uploads.py +265 -0
- decisionnode/_validate.py +934 -0
- decisionnode/_version.py +5 -0
- decisionnode/_videos.py +197 -0
- decisionnode/py.typed +0 -0
decisionnode/_base.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""What the sync and async clients share: settings, request bodies, retries and errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import email.utils
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Union
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._errors import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
BadRequestError,
|
|
22
|
+
ContextLimitError,
|
|
23
|
+
DecisionNodeError,
|
|
24
|
+
InsufficientCreditError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
OverloadedError,
|
|
27
|
+
PermissionDeniedError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
RefusalError,
|
|
30
|
+
RequestTooLargeError,
|
|
31
|
+
ServerError,
|
|
32
|
+
SessionLimitError,
|
|
33
|
+
ValidationError,
|
|
34
|
+
)
|
|
35
|
+
from ._images import DEFAULT_INLINE_THRESHOLD, Image
|
|
36
|
+
from ._questions import Question, choice, score, truth
|
|
37
|
+
from ._types import parse_safety
|
|
38
|
+
from ._version import __version__
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("decisionnode")
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.decisionnode.com"
|
|
43
|
+
DEFAULT_MODEL = "decisionnode-moe"
|
|
44
|
+
DEFAULT_TIMEOUT = 10.0
|
|
45
|
+
# One attempt of a decision whose boosters name ocr, when the caller set no timeout: the
|
|
46
|
+
# booster reads the text before the model answers.
|
|
47
|
+
OCR_TIMEOUT = 30.0
|
|
48
|
+
# One storage PUT attempt of an image, apart from the API attempt's timeout: up to 10 MB
|
|
49
|
+
# on a slower uplink than the API's answers need.
|
|
50
|
+
DEFAULT_UPLOAD_TIMEOUT = 60.0
|
|
51
|
+
DEFAULT_MAX_RETRIES = 2
|
|
52
|
+
DEFAULT_CONCURRENCY = 8
|
|
53
|
+
USER_AGENT = "decisionnode-python/%s" % __version__
|
|
54
|
+
|
|
55
|
+
RETRY_STATUSES = frozenset({408, 429} | set(range(500, 600)))
|
|
56
|
+
INITIAL_BACKOFF = 0.5
|
|
57
|
+
MAX_BACKOFF = 8.0
|
|
58
|
+
# A wait the server asks for is honoured up to this many seconds. The API asks for at
|
|
59
|
+
# most 30; the bound keeps a misbehaving proxy from stalling a caller for hours.
|
|
60
|
+
MAX_RETRY_AFTER = 60.0
|
|
61
|
+
|
|
62
|
+
DECIDE_PATH = "/v1/decide"
|
|
63
|
+
MODELS_PATH = "/v1/models"
|
|
64
|
+
UPLOADS_PATH = "/v1/uploads"
|
|
65
|
+
SESSIONS_PATH = "/v1/sessions"
|
|
66
|
+
BATCHES_PATH = "/v1/batches"
|
|
67
|
+
COLLECTIONS_PATH = "/v1/collections"
|
|
68
|
+
|
|
69
|
+
# Retry policies. DEFAULT: 408, 429, 5xx, timeouts and dropped connections. MUTATION
|
|
70
|
+
# (batch create, add and finalize): only 429 and 529, which the API answers before
|
|
71
|
+
# applying anything; never after a timeout, a lost answer or another 5xx, since a repeat
|
|
72
|
+
# could create a second draft. SESSION_OPEN: as DEFAULT, except that a 429
|
|
73
|
+
# session_limit_exceeded is never retried. OCR (a decision whose boosters name ocr): as
|
|
74
|
+
# DEFAULT, except that a timeout is never retried, since the server finishes that attempt
|
|
75
|
+
# and may bill it. IDEMPOTENT (collections create and enroll, which carry their own
|
|
76
|
+
# Idempotency-Key, the same on every attempt): as DEFAULT, and also a 409
|
|
77
|
+
# idempotency_in_progress, after its Retry-After; a 409 idempotency_key_reused is raised.
|
|
78
|
+
DEFAULT = "default"
|
|
79
|
+
MUTATION = "mutation"
|
|
80
|
+
SESSION_OPEN = "session_open"
|
|
81
|
+
OCR = "ocr"
|
|
82
|
+
IDEMPOTENT = "idempotent"
|
|
83
|
+
IDEMPOTENCY_IN_PROGRESS = "idempotency_in_progress"
|
|
84
|
+
|
|
85
|
+
# A 400 for an upload the client placed in the request: it uploads again and sends the
|
|
86
|
+
# request once more.
|
|
87
|
+
STALE_UPLOAD = frozenset({"upload_expired", "upload_consumed", "upload_not_found", "upload_incomplete"})
|
|
88
|
+
|
|
89
|
+
ImageInput = Union[Image, Mapping[str, Any]]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Unset:
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return "<unset>"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
UNSET: Any = _Unset()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_base_url(url: str) -> str:
|
|
104
|
+
"""The base URL without a trailing slash, or ``ValueError`` when it is not usable.
|
|
105
|
+
|
|
106
|
+
``https`` only, so the key never travels unencrypted. Plain ``http`` is accepted for
|
|
107
|
+
``localhost``, ``127.0.0.1`` and ``::1`` alone, for a server on the same machine.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
parts = urlsplit(url)
|
|
111
|
+
host = parts.hostname
|
|
112
|
+
parts.port # noqa: B018 - raises ValueError on a port that is not a number
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
raise ValueError("base_url %r is not a valid URL: %s" % (url, exc)) from None
|
|
115
|
+
scheme = parts.scheme.lower()
|
|
116
|
+
if not host:
|
|
117
|
+
raise ValueError("base_url %r has no host" % url)
|
|
118
|
+
if scheme == "https":
|
|
119
|
+
pass
|
|
120
|
+
elif scheme == "http":
|
|
121
|
+
if host.lower() not in LOOPBACK_HOSTS:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"base_url %r uses plain http; use https (http is accepted only for localhost, 127.0.0.1 and ::1)" % url
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError("base_url %r must start with https://" % url)
|
|
127
|
+
if parts.query or parts.fragment:
|
|
128
|
+
raise ValueError("base_url %r must not have a query or a fragment" % url)
|
|
129
|
+
return url.rstrip("/")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check_timeout(timeout: Any, default: float = DEFAULT_TIMEOUT, name: str = "timeout") -> float:
|
|
133
|
+
if timeout is None:
|
|
134
|
+
return default
|
|
135
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
136
|
+
raise ValueError("%s must be a number of seconds above 0, got %r" % (name, timeout))
|
|
137
|
+
value = float(timeout)
|
|
138
|
+
if not math.isfinite(value) or value <= 0:
|
|
139
|
+
raise ValueError("%s must be a number of seconds above 0, got %r" % (name, timeout))
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_max_retries(max_retries: Any) -> int:
|
|
144
|
+
if max_retries is None:
|
|
145
|
+
return DEFAULT_MAX_RETRIES
|
|
146
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
147
|
+
raise ValueError("max_retries must be a whole number, 0 or more, got %r" % (max_retries,))
|
|
148
|
+
return int(max_retries)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_inline_threshold(value: Any) -> int:
|
|
152
|
+
if value is None:
|
|
153
|
+
return DEFAULT_INLINE_THRESHOLD
|
|
154
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
155
|
+
raise ValueError("inline_threshold must be a whole number of bytes, 0 or more, got %r" % (value,))
|
|
156
|
+
return int(value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Settings:
|
|
160
|
+
"""The resolved client settings: an explicit argument, then the environment, then the default.
|
|
161
|
+
|
|
162
|
+
An empty string counts as not given, so ``api_key=""`` falls back to the environment.
|
|
163
|
+
Every setting is checked here, so a bad one fails before any request is sent.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
api_key: Optional[str],
|
|
169
|
+
base_url: Optional[str],
|
|
170
|
+
model: Optional[str],
|
|
171
|
+
timeout: Optional[float],
|
|
172
|
+
max_retries: Optional[int],
|
|
173
|
+
headers: Optional[Mapping[str, str]],
|
|
174
|
+
inline_threshold: Optional[int] = None,
|
|
175
|
+
upload_timeout: Optional[float] = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.api_key = api_key or os.environ.get("DECISIONNODE_API_KEY") or None
|
|
178
|
+
url = base_url or os.environ.get("DECISIONNODE_BASE_URL") or DEFAULT_BASE_URL
|
|
179
|
+
self.base_url = check_base_url(str(url))
|
|
180
|
+
self.model = model or os.environ.get("DECISIONNODE_MODEL") or DEFAULT_MODEL
|
|
181
|
+
# The limit for one whole attempt, from sending the request to the last byte of
|
|
182
|
+
# the response body. Each network step is also bounded by it. Whether the caller
|
|
183
|
+
# set it: a decision naming ocr waits OCR_TIMEOUT unless a timeout was set.
|
|
184
|
+
self.timeout = check_timeout(timeout)
|
|
185
|
+
self.timeout_given = timeout is not None
|
|
186
|
+
self.max_retries = check_max_retries(max_retries)
|
|
187
|
+
self.custom_headers: Dict[str, str] = dict(headers or {})
|
|
188
|
+
self.inline_threshold = check_inline_threshold(inline_threshold)
|
|
189
|
+
self.upload_timeout = check_timeout(upload_timeout, DEFAULT_UPLOAD_TIMEOUT, "upload_timeout")
|
|
190
|
+
|
|
191
|
+
def request_headers(self, has_body: bool, content_type: str = "application/json") -> Dict[str, str]:
|
|
192
|
+
headers = {
|
|
193
|
+
"Authorization": "Bearer %s" % self.require_key(),
|
|
194
|
+
"User-Agent": USER_AGENT,
|
|
195
|
+
"Accept": "application/json",
|
|
196
|
+
}
|
|
197
|
+
if has_body:
|
|
198
|
+
headers["Content-Type"] = content_type
|
|
199
|
+
headers.update(self.custom_headers)
|
|
200
|
+
return headers
|
|
201
|
+
|
|
202
|
+
def require_key(self) -> str:
|
|
203
|
+
if not self.api_key:
|
|
204
|
+
raise AuthenticationError("no API key: pass api_key to the client or set DECISIONNODE_API_KEY")
|
|
205
|
+
return self.api_key
|
|
206
|
+
|
|
207
|
+
def stream_headers(self) -> Dict[str, str]:
|
|
208
|
+
"""The headers of a session's WebSocket handshake: the key, never in the URL."""
|
|
209
|
+
headers = {"Authorization": "Bearer %s" % self.require_key()}
|
|
210
|
+
headers.update(self.custom_headers)
|
|
211
|
+
return headers
|
|
212
|
+
|
|
213
|
+
def url(self, path: str) -> str:
|
|
214
|
+
return self.base_url + path
|
|
215
|
+
|
|
216
|
+
def __repr__(self) -> str:
|
|
217
|
+
key = "set" if self.api_key else "missing"
|
|
218
|
+
return "base_url=%r, model=%r, api_key=%s" % (self.base_url, self.model, key)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def no_cookies() -> CookieJar:
|
|
222
|
+
"""A cookie jar that keeps nothing, so no request carries state from an earlier response."""
|
|
223
|
+
return CookieJar(policy=DefaultCookiePolicy(allowed_domains=[]))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------------------- request bodies
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def encode(body: Any) -> bytes:
|
|
230
|
+
try:
|
|
231
|
+
return json.dumps(body, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
|
232
|
+
except (TypeError, ValueError) as exc:
|
|
233
|
+
raise ValueError("the request cannot be sent as JSON: %s" % exc) from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def check_question_map(questions: Any) -> Dict[str, Any]:
|
|
237
|
+
"""The question map with string ids, or ``ValueError`` for an argument that is not one."""
|
|
238
|
+
if questions is None or not isinstance(questions, Mapping):
|
|
239
|
+
raise ValueError("questions must be a mapping of question id to question")
|
|
240
|
+
if len(questions) == 0:
|
|
241
|
+
raise ValueError("questions must hold at least one question")
|
|
242
|
+
# Each question is checked as the API checks it (a question that is not an object is
|
|
243
|
+
# its 422), so it passes through as given here.
|
|
244
|
+
return {str(qid): q for qid, q in questions.items()}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def image_list(images: Any) -> List[Any]:
|
|
248
|
+
"""The images argument as a list, or ``ValueError``."""
|
|
249
|
+
if not images:
|
|
250
|
+
return []
|
|
251
|
+
if isinstance(images, (Image, Mapping)):
|
|
252
|
+
return [images]
|
|
253
|
+
if isinstance(images, (str, bytes)):
|
|
254
|
+
raise ValueError("images must be a list of Image objects or wire-form mappings")
|
|
255
|
+
out = list(images)
|
|
256
|
+
for img in out:
|
|
257
|
+
if not isinstance(img, (Image, Mapping)):
|
|
258
|
+
raise ValueError("images must be Image objects or wire-form mappings, got %r" % (img,))
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def decide_body(
|
|
263
|
+
state: Any,
|
|
264
|
+
questions: Mapping[str, Any],
|
|
265
|
+
wires: Sequence[Mapping[str, Any]],
|
|
266
|
+
model: Optional[str],
|
|
267
|
+
default_model: str,
|
|
268
|
+
videos: Sequence[Mapping[str, Any]] = (),
|
|
269
|
+
boosters: Any = None,
|
|
270
|
+
fallback: Optional[bool] = None,
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
"""The decide body. ``boosters`` is the field as sent (``booster_wire``) and
|
|
273
|
+
``fallback`` the option as given; ``None`` leaves either key out, so a request
|
|
274
|
+
without them is unchanged."""
|
|
275
|
+
body: Dict[str, Any] = {
|
|
276
|
+
"model": model or default_model,
|
|
277
|
+
"state": "" if state is None or state is UNSET else state,
|
|
278
|
+
"questions": dict(questions),
|
|
279
|
+
}
|
|
280
|
+
if wires:
|
|
281
|
+
body["images"] = list(wires)
|
|
282
|
+
if videos:
|
|
283
|
+
body["videos"] = list(videos)
|
|
284
|
+
if boosters is not None:
|
|
285
|
+
body["boosters"] = boosters
|
|
286
|
+
if fallback is not None:
|
|
287
|
+
body["fallback"] = fallback
|
|
288
|
+
return body
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
DECIDE_KEYS = frozenset({"state", "questions", "images", "videos", "model", "boosters", "fallback"})
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_fallback(fallback: Any) -> Optional[bool]:
|
|
295
|
+
"""The ``fallback`` option: ``True``, ``False`` or ``None`` (left out)."""
|
|
296
|
+
if fallback is not None and not isinstance(fallback, bool):
|
|
297
|
+
raise ValueError("fallback must be True or False, got %r" % (fallback,))
|
|
298
|
+
return fallback
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def should_retry(policy: str, status: int, error: DecisionNodeError) -> bool:
|
|
302
|
+
if policy == MUTATION:
|
|
303
|
+
return status in (429, 529)
|
|
304
|
+
if policy == SESSION_OPEN and isinstance(error, SessionLimitError):
|
|
305
|
+
return False
|
|
306
|
+
if policy == IDEMPOTENT and status == 409:
|
|
307
|
+
return error.error_type == IDEMPOTENCY_IN_PROGRESS
|
|
308
|
+
return status in RETRY_STATUSES
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def parse_time(text: Any) -> Optional[float]:
|
|
312
|
+
"""An ISO 8601 UTC time such as ``2026-10-06T09:35:00Z`` as a Unix time, or ``None``."""
|
|
313
|
+
if not isinstance(text, str) or not text:
|
|
314
|
+
return None
|
|
315
|
+
value = text.strip()
|
|
316
|
+
if value.endswith(("Z", "z")):
|
|
317
|
+
value = value[:-1] + "+00:00"
|
|
318
|
+
try:
|
|
319
|
+
when = datetime.datetime.fromisoformat(value)
|
|
320
|
+
except ValueError:
|
|
321
|
+
return None
|
|
322
|
+
if when.tzinfo is None:
|
|
323
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
324
|
+
return when.timestamp()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def check_many(requests: Iterable[Mapping[str, Any]], concurrency: int) -> List[Mapping[str, Any]]:
|
|
328
|
+
if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency < 1:
|
|
329
|
+
raise ValueError("concurrency must be a whole number, 1 or more, got %r" % (concurrency,))
|
|
330
|
+
items = list(requests)
|
|
331
|
+
for i, req in enumerate(items):
|
|
332
|
+
if not isinstance(req, Mapping):
|
|
333
|
+
raise ValueError("decide_many: request %d is not a mapping of decide() arguments" % i)
|
|
334
|
+
unknown = set(req) - DECIDE_KEYS
|
|
335
|
+
if unknown:
|
|
336
|
+
raise ValueError("decide_many: request %d has unknown keys %s" % (i, ", ".join(sorted(map(str, unknown)))))
|
|
337
|
+
return items
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def gate_state(action: Any, context: Any) -> Dict[str, Any]:
|
|
341
|
+
state: Dict[str, Any] = {"action": action}
|
|
342
|
+
if context is not None:
|
|
343
|
+
state["context"] = context
|
|
344
|
+
return state
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def gate_questions() -> Dict[str, Question]:
|
|
348
|
+
# The same wording in every official SDK, so a gate asks the same thing in any language.
|
|
349
|
+
return {
|
|
350
|
+
"risk": score(
|
|
351
|
+
"How much harm could this action cause if it is wrong?",
|
|
352
|
+
["none", "low", "moderate", "high", "severe"],
|
|
353
|
+
),
|
|
354
|
+
"touches_production": truth(
|
|
355
|
+
"Does this action change a production system, production data or anything customers use?"
|
|
356
|
+
),
|
|
357
|
+
"verdict": choice(
|
|
358
|
+
"Should this action run as it is, wait for a person to confirm it, or be blocked?",
|
|
359
|
+
{
|
|
360
|
+
"allow": "safe to run without asking",
|
|
361
|
+
"confirm": "a person should confirm it first",
|
|
362
|
+
"block": "should not run",
|
|
363
|
+
},
|
|
364
|
+
),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------- retries
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def retry_after_seconds(headers: Mapping[str, str]) -> Optional[float]:
|
|
372
|
+
"""The wait the server asked for, in seconds, capped at 60.
|
|
373
|
+
|
|
374
|
+
``retry-after-ms`` first, then ``Retry-After`` as seconds or as an HTTP date.
|
|
375
|
+
"""
|
|
376
|
+
wait = requested_wait(headers)
|
|
377
|
+
return None if wait is None else min(wait, MAX_RETRY_AFTER)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def requested_wait(headers: Mapping[str, str]) -> Optional[float]:
|
|
381
|
+
ms = headers.get("retry-after-ms")
|
|
382
|
+
if ms is not None:
|
|
383
|
+
try:
|
|
384
|
+
value = float(ms) / 1000.0
|
|
385
|
+
if math.isfinite(value) and value >= 0:
|
|
386
|
+
return value
|
|
387
|
+
except ValueError:
|
|
388
|
+
pass
|
|
389
|
+
text = headers.get("retry-after")
|
|
390
|
+
if text is None:
|
|
391
|
+
return None
|
|
392
|
+
try:
|
|
393
|
+
value = float(text)
|
|
394
|
+
except ValueError:
|
|
395
|
+
try:
|
|
396
|
+
when = email.utils.parsedate_to_datetime(text)
|
|
397
|
+
except (TypeError, ValueError, IndexError):
|
|
398
|
+
return None
|
|
399
|
+
if when is None:
|
|
400
|
+
return None
|
|
401
|
+
if when.tzinfo is None:
|
|
402
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
403
|
+
return max(0.0, when.timestamp() - time.time())
|
|
404
|
+
if math.isfinite(value) and value >= 0:
|
|
405
|
+
return value
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def backoff(attempt: int) -> float:
|
|
410
|
+
"""Exponential backoff from 0.5 s with jitter, capped at 8 s. ``attempt`` counts from 0."""
|
|
411
|
+
base = min(MAX_BACKOFF, INITIAL_BACKOFF * float(2**attempt))
|
|
412
|
+
return base * (0.75 + random.random() * 0.25)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def retry_wait(attempt: int, headers: Optional[Mapping[str, str]]) -> float:
|
|
416
|
+
wait = retry_after_seconds(headers) if headers is not None else None
|
|
417
|
+
return backoff(attempt) if wait is None else wait
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def lower_headers(headers: httpx.Headers) -> Dict[str, str]:
|
|
421
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --------------------------------------------------------------------------- errors
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# The status text of an error whose body is not JSON: the same in every DecisionNode
|
|
428
|
+
# SDK, never the HTTP library's own wording.
|
|
429
|
+
_STATUS_TEXT = {
|
|
430
|
+
400: "Bad Request",
|
|
431
|
+
401: "Unauthorized",
|
|
432
|
+
402: "Payment Required",
|
|
433
|
+
403: "Forbidden",
|
|
434
|
+
404: "Not Found",
|
|
435
|
+
405: "Method Not Allowed",
|
|
436
|
+
408: "Request Timeout",
|
|
437
|
+
409: "Conflict",
|
|
438
|
+
410: "Gone",
|
|
439
|
+
413: "Content Too Large",
|
|
440
|
+
422: "Unprocessable Content",
|
|
441
|
+
429: "Too Many Requests",
|
|
442
|
+
500: "Internal Server Error",
|
|
443
|
+
502: "Bad Gateway",
|
|
444
|
+
503: "Service Unavailable",
|
|
445
|
+
504: "Gateway Timeout",
|
|
446
|
+
529: "Overloaded",
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def status_message(status: int, request_id: Optional[str]) -> str:
|
|
451
|
+
"""The client's own message for an error whose body is not JSON (an edge's or a
|
|
452
|
+
proxy's page, or no body): ``HTTP <status> <text>``, then the request id when sent."""
|
|
453
|
+
out = "HTTP %d" % status
|
|
454
|
+
if status in _STATUS_TEXT:
|
|
455
|
+
out += " " + _STATUS_TEXT[status]
|
|
456
|
+
if request_id is not None:
|
|
457
|
+
out += " (request id %s)" % request_id
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def make_error(status: int, headers: Mapping[str, str], text: str) -> DecisionNodeError:
|
|
462
|
+
body: Any = None
|
|
463
|
+
message: Optional[str] = None
|
|
464
|
+
try:
|
|
465
|
+
body = json.loads(text)
|
|
466
|
+
except ValueError:
|
|
467
|
+
# Not the JSON envelope (an HTML or text page, or an empty body): the class
|
|
468
|
+
# follows the status alone, and the page is never read into any field.
|
|
469
|
+
message = status_message(status, headers.get("x-request-id"))
|
|
470
|
+
detail = body.get("detail") if isinstance(body, Mapping) else None
|
|
471
|
+
error_type: Optional[str] = None
|
|
472
|
+
category: Optional[str] = None
|
|
473
|
+
entries: Optional[List[Any]] = None
|
|
474
|
+
if isinstance(detail, Mapping):
|
|
475
|
+
error_type = detail.get("error_type")
|
|
476
|
+
message = detail.get("message")
|
|
477
|
+
category = detail.get("category")
|
|
478
|
+
elif isinstance(detail, str):
|
|
479
|
+
message = detail
|
|
480
|
+
elif isinstance(detail, list):
|
|
481
|
+
entries = detail
|
|
482
|
+
kwargs: Dict[str, Any] = dict(
|
|
483
|
+
status=status,
|
|
484
|
+
error_type=error_type,
|
|
485
|
+
request_id=headers.get("x-request-id"),
|
|
486
|
+
reason=headers.get("x-decisionnode-error"),
|
|
487
|
+
body=body,
|
|
488
|
+
headers=headers,
|
|
489
|
+
)
|
|
490
|
+
if status == 400:
|
|
491
|
+
if error_type == "max_tokens_exceeded":
|
|
492
|
+
return ContextLimitError(message, **kwargs)
|
|
493
|
+
return BadRequestError(message, **kwargs)
|
|
494
|
+
if status == 401:
|
|
495
|
+
return AuthenticationError(message, **kwargs)
|
|
496
|
+
if status == 402:
|
|
497
|
+
return InsufficientCreditError(message, **kwargs)
|
|
498
|
+
if status == 403:
|
|
499
|
+
if error_type == "refusal_error":
|
|
500
|
+
return RefusalError(
|
|
501
|
+
message, category=category, safety=parse_safety(headers.get("x-decisionnode-safety")), **kwargs
|
|
502
|
+
)
|
|
503
|
+
return PermissionDeniedError(message, **kwargs)
|
|
504
|
+
if status == 404:
|
|
505
|
+
return NotFoundError(message, **kwargs)
|
|
506
|
+
if status == 413:
|
|
507
|
+
return RequestTooLargeError(message, **kwargs)
|
|
508
|
+
if status == 422:
|
|
509
|
+
return ValidationError(message, entries=entries, **kwargs)
|
|
510
|
+
if status == 429:
|
|
511
|
+
if error_type == "session_limit_exceeded":
|
|
512
|
+
return SessionLimitError(message, retry_after=retry_after_seconds(headers), **kwargs)
|
|
513
|
+
return RateLimitError(message, retry_after=retry_after_seconds(headers), **kwargs)
|
|
514
|
+
if status == 529:
|
|
515
|
+
return OverloadedError(message, retry_after=retry_after_seconds(headers), **kwargs)
|
|
516
|
+
if 500 <= status <= 599:
|
|
517
|
+
return ServerError(message, **kwargs)
|
|
518
|
+
# Redirects (never followed) and every status without a class of its own.
|
|
519
|
+
return DecisionNodeError(message, **kwargs)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def parse_json(status: int, headers: Mapping[str, str], text: str) -> Any:
|
|
523
|
+
"""The body of a 2xx response. A body that is not JSON is a :class:`ServerError`."""
|
|
524
|
+
try:
|
|
525
|
+
return json.loads(text)
|
|
526
|
+
except ValueError as exc:
|
|
527
|
+
raise ServerError(
|
|
528
|
+
"the response body is not JSON",
|
|
529
|
+
status=status,
|
|
530
|
+
request_id=headers.get("x-request-id"),
|
|
531
|
+
body=text,
|
|
532
|
+
headers=headers,
|
|
533
|
+
) from exc
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def log_response(
|
|
537
|
+
method: str, path: str, status: Union[int, str], request_id: Optional[str], started: float, attempt: int
|
|
538
|
+
) -> None:
|
|
539
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
540
|
+
logger.debug(
|
|
541
|
+
"%s %s -> %s request_id=%s %.0fms attempt=%d",
|
|
542
|
+
method,
|
|
543
|
+
path,
|
|
544
|
+
status,
|
|
545
|
+
request_id or "-",
|
|
546
|
+
(time.monotonic() - started) * 1000,
|
|
547
|
+
attempt + 1,
|
|
548
|
+
)
|