arkivector 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.
- arkivector/__init__.py +101 -0
- arkivector/_http.py +420 -0
- arkivector/client.py +1135 -0
- arkivector/errors.py +173 -0
- arkivector/filter.py +161 -0
- arkivector/langchain.py +269 -0
- arkivector/llama_index.py +301 -0
- arkivector/models.py +1079 -0
- arkivector/py.typed +0 -0
- arkivector/types.py +523 -0
- arkivector/vectorbytes.py +353 -0
- arkivector-1.0.0.dist-info/METADATA +29 -0
- arkivector-1.0.0.dist-info/RECORD +14 -0
- arkivector-1.0.0.dist-info/WHEEL +4 -0
arkivector/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Arkivector Python SDK."""
|
|
2
|
+
|
|
3
|
+
from arkivector.client import Client, build_entity_attributes
|
|
4
|
+
from arkivector.errors import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
NotImplementedByDaemon,
|
|
10
|
+
TransportError,
|
|
11
|
+
UnavailableError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
VectorBytesError,
|
|
14
|
+
error_from_problem,
|
|
15
|
+
)
|
|
16
|
+
from arkivector.filter import F
|
|
17
|
+
from arkivector.models import (
|
|
18
|
+
BatchResult,
|
|
19
|
+
Collection,
|
|
20
|
+
Column,
|
|
21
|
+
ColumnDef,
|
|
22
|
+
Consistency,
|
|
23
|
+
CountResult,
|
|
24
|
+
DirectAttribute,
|
|
25
|
+
Entity,
|
|
26
|
+
EntityInput,
|
|
27
|
+
IndexDef,
|
|
28
|
+
PreparedTransaction,
|
|
29
|
+
PrepareResult,
|
|
30
|
+
Readiness,
|
|
31
|
+
ScrollPage,
|
|
32
|
+
SearchHit,
|
|
33
|
+
SearchResult,
|
|
34
|
+
SyncStatus,
|
|
35
|
+
T,
|
|
36
|
+
Ttl,
|
|
37
|
+
TxResult,
|
|
38
|
+
TypedValue,
|
|
39
|
+
UpsertItemResult,
|
|
40
|
+
UpsertResult,
|
|
41
|
+
VersionInfo,
|
|
42
|
+
)
|
|
43
|
+
from arkivector.types import (
|
|
44
|
+
AnyVector,
|
|
45
|
+
Bit,
|
|
46
|
+
HalfVector,
|
|
47
|
+
SparseVector,
|
|
48
|
+
Vector,
|
|
49
|
+
)
|
|
50
|
+
from arkivector.vectorbytes import vectorbytes_decode_any
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
SUPPORTED_API_MAJOR = 1
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"__version__",
|
|
57
|
+
"SUPPORTED_API_MAJOR",
|
|
58
|
+
"AnyVector",
|
|
59
|
+
"ApiError",
|
|
60
|
+
"AuthError",
|
|
61
|
+
"BatchResult",
|
|
62
|
+
"Bit",
|
|
63
|
+
"Client",
|
|
64
|
+
"Collection",
|
|
65
|
+
"Column",
|
|
66
|
+
"ColumnDef",
|
|
67
|
+
"ConflictError",
|
|
68
|
+
"Consistency",
|
|
69
|
+
"CountResult",
|
|
70
|
+
"DirectAttribute",
|
|
71
|
+
"Entity",
|
|
72
|
+
"EntityInput",
|
|
73
|
+
"F",
|
|
74
|
+
"HalfVector",
|
|
75
|
+
"IndexDef",
|
|
76
|
+
"NotFoundError",
|
|
77
|
+
"NotImplementedByDaemon",
|
|
78
|
+
"PrepareResult",
|
|
79
|
+
"PreparedTransaction",
|
|
80
|
+
"Readiness",
|
|
81
|
+
"ScrollPage",
|
|
82
|
+
"SearchHit",
|
|
83
|
+
"SearchResult",
|
|
84
|
+
"SparseVector",
|
|
85
|
+
"SyncStatus",
|
|
86
|
+
"T",
|
|
87
|
+
"TransportError",
|
|
88
|
+
"Ttl",
|
|
89
|
+
"TxResult",
|
|
90
|
+
"TypedValue",
|
|
91
|
+
"UnavailableError",
|
|
92
|
+
"UpsertItemResult",
|
|
93
|
+
"UpsertResult",
|
|
94
|
+
"ValidationError",
|
|
95
|
+
"Vector",
|
|
96
|
+
"VectorBytesError",
|
|
97
|
+
"VersionInfo",
|
|
98
|
+
"build_entity_attributes",
|
|
99
|
+
"error_from_problem",
|
|
100
|
+
"vectorbytes_decode_any",
|
|
101
|
+
]
|
arkivector/_http.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Thread-safe HTTP transport with retry engine and problem error mapping."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from arkivector.errors import ApiError, TransportError, error_from_problem
|
|
15
|
+
|
|
16
|
+
SDK_VERSION = "0.1.0"
|
|
17
|
+
USER_AGENT = f"arkivector-python/{SDK_VERSION}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class HttpResponse:
|
|
22
|
+
"""HTTP response container carrying status, headers, and body."""
|
|
23
|
+
|
|
24
|
+
status: int
|
|
25
|
+
headers: Dict[str, str]
|
|
26
|
+
body: bytes
|
|
27
|
+
request_id: str
|
|
28
|
+
|
|
29
|
+
def json(self) -> Any:
|
|
30
|
+
"""Parse response body as JSON."""
|
|
31
|
+
if not self.body:
|
|
32
|
+
return None
|
|
33
|
+
return json.loads(self.body.decode("utf-8"))
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def as_of_block(self) -> Optional[int]:
|
|
37
|
+
"""Block number reported by the Arkivector daemon."""
|
|
38
|
+
val = self.headers.get("arkivector-as-of-block")
|
|
39
|
+
return int(val) if val is not None else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConnectionPool:
|
|
43
|
+
"""Thread-safe HTTP/HTTPS connection pool."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, idle_timeout: float = 4.0) -> None:
|
|
46
|
+
self._idle_timeout = idle_timeout
|
|
47
|
+
self._pool: Dict[
|
|
48
|
+
Tuple[str, str, int],
|
|
49
|
+
List[
|
|
50
|
+
Tuple[
|
|
51
|
+
Union[http.client.HTTPConnection, http.client.HTTPSConnection],
|
|
52
|
+
float,
|
|
53
|
+
]
|
|
54
|
+
],
|
|
55
|
+
] = {}
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def acquire(
|
|
59
|
+
self,
|
|
60
|
+
scheme: str,
|
|
61
|
+
host: str,
|
|
62
|
+
port: int,
|
|
63
|
+
timeout: float,
|
|
64
|
+
) -> Tuple[Union[http.client.HTTPConnection, http.client.HTTPSConnection], bool]:
|
|
65
|
+
"""Acquire a connection from the pool or create a new one. Returns (conn, is_reused)."""
|
|
66
|
+
key = (scheme, host, port)
|
|
67
|
+
now = time.monotonic()
|
|
68
|
+
with self._lock:
|
|
69
|
+
conns = self._pool.get(key)
|
|
70
|
+
while conns:
|
|
71
|
+
conn, last_used = conns.pop()
|
|
72
|
+
if now - last_used < self._idle_timeout:
|
|
73
|
+
return conn, True
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
conn.close()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
if scheme == "https":
|
|
81
|
+
return http.client.HTTPSConnection(host, port, timeout=timeout), False
|
|
82
|
+
return http.client.HTTPConnection(host, port, timeout=timeout), False
|
|
83
|
+
|
|
84
|
+
def release(
|
|
85
|
+
self,
|
|
86
|
+
scheme: str,
|
|
87
|
+
host: str,
|
|
88
|
+
port: int,
|
|
89
|
+
conn: Union[http.client.HTTPConnection, http.client.HTTPSConnection],
|
|
90
|
+
should_keep_alive: bool,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Return a connection to the pool or close it."""
|
|
93
|
+
if not should_keep_alive:
|
|
94
|
+
try:
|
|
95
|
+
conn.close()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
key = (scheme, host, port)
|
|
101
|
+
with self._lock:
|
|
102
|
+
conns = self._pool.setdefault(key, [])
|
|
103
|
+
conns.append((conn, time.monotonic()))
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close all pooled connections."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
for conns in self._pool.values():
|
|
109
|
+
for conn, _ in conns:
|
|
110
|
+
try:
|
|
111
|
+
conn.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
self._pool.clear()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class HttpTransport:
|
|
118
|
+
"""Thread-safe HTTP transport with retry engine."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
base_url: str = "http://127.0.0.1:8080",
|
|
123
|
+
api_key: Optional[str] = None,
|
|
124
|
+
timeout: float = 10.0,
|
|
125
|
+
max_retries: int = 3,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.base_url = base_url.rstrip("/")
|
|
128
|
+
self.api_key = api_key
|
|
129
|
+
self.timeout = timeout
|
|
130
|
+
self.max_retries = max_retries
|
|
131
|
+
|
|
132
|
+
parsed = urllib.parse.urlsplit(self.base_url)
|
|
133
|
+
self.scheme = parsed.scheme.lower()
|
|
134
|
+
if self.scheme not in ("http", "https"):
|
|
135
|
+
raise ValueError(f"Invalid URL scheme: {self.scheme!r}")
|
|
136
|
+
self.host = parsed.hostname or "127.0.0.1"
|
|
137
|
+
self.port = parsed.port or (443 if self.scheme == "https" else 80)
|
|
138
|
+
self.base_path = parsed.path.rstrip("/")
|
|
139
|
+
self._pool = ConnectionPool()
|
|
140
|
+
|
|
141
|
+
def _send_once(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
headers: Dict[str, str],
|
|
146
|
+
body_bytes: Optional[bytes],
|
|
147
|
+
timeout: float,
|
|
148
|
+
) -> HttpResponse:
|
|
149
|
+
conn, is_reused = self._pool.acquire(self.scheme, self.host, self.port, timeout=timeout)
|
|
150
|
+
try:
|
|
151
|
+
conn.request(method, path, body=body_bytes, headers=headers)
|
|
152
|
+
resp = conn.getresponse()
|
|
153
|
+
status = resp.status
|
|
154
|
+
resp_headers = {k.lower(): v for k, v in resp.getheaders()}
|
|
155
|
+
resp_body = resp.read()
|
|
156
|
+
|
|
157
|
+
will_close = (
|
|
158
|
+
getattr(resp, "will_close", False)
|
|
159
|
+
or resp_headers.get("connection", "").lower() == "close"
|
|
160
|
+
)
|
|
161
|
+
self._pool.release(
|
|
162
|
+
self.scheme, self.host, self.port, conn, should_keep_alive=not will_close
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
req_id = resp_headers.get("x-request-id") or headers.get("X-Request-Id", "")
|
|
166
|
+
return HttpResponse(
|
|
167
|
+
status=status,
|
|
168
|
+
headers=resp_headers,
|
|
169
|
+
body=resp_body,
|
|
170
|
+
request_id=req_id,
|
|
171
|
+
)
|
|
172
|
+
except (http.client.RemoteDisconnected, BrokenPipeError, ConnectionResetError) as e:
|
|
173
|
+
try:
|
|
174
|
+
conn.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
if is_reused:
|
|
178
|
+
conn_fresh, _ = self._pool.acquire(
|
|
179
|
+
self.scheme, self.host, self.port, timeout=timeout
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
conn_fresh.request(method, path, body=body_bytes, headers=headers)
|
|
183
|
+
resp = conn_fresh.getresponse()
|
|
184
|
+
status = resp.status
|
|
185
|
+
resp_headers = {k.lower(): v for k, v in resp.getheaders()}
|
|
186
|
+
resp_body = resp.read()
|
|
187
|
+
will_close = (
|
|
188
|
+
getattr(resp, "will_close", False)
|
|
189
|
+
or resp_headers.get("connection", "").lower() == "close"
|
|
190
|
+
)
|
|
191
|
+
self._pool.release(
|
|
192
|
+
self.scheme,
|
|
193
|
+
self.host,
|
|
194
|
+
self.port,
|
|
195
|
+
conn_fresh,
|
|
196
|
+
should_keep_alive=not will_close,
|
|
197
|
+
)
|
|
198
|
+
req_id = resp_headers.get("x-request-id") or headers.get("X-Request-Id", "")
|
|
199
|
+
return HttpResponse(
|
|
200
|
+
status=status,
|
|
201
|
+
headers=resp_headers,
|
|
202
|
+
body=resp_body,
|
|
203
|
+
request_id=req_id,
|
|
204
|
+
)
|
|
205
|
+
except Exception as inner_e:
|
|
206
|
+
try:
|
|
207
|
+
conn_fresh.close()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
raise inner_e
|
|
211
|
+
raise e
|
|
212
|
+
except Exception:
|
|
213
|
+
try:
|
|
214
|
+
conn.close()
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
def can_retry(
|
|
220
|
+
self,
|
|
221
|
+
method: str,
|
|
222
|
+
path: str,
|
|
223
|
+
is_idempotent: bool = False,
|
|
224
|
+
idempotency_key: Optional[str] = None,
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Determine if a request is eligible for automated retries per conventions.md §8."""
|
|
227
|
+
is_idempotent_path = any(
|
|
228
|
+
path.endswith(p) or f"{p}?" in path
|
|
229
|
+
for p in (
|
|
230
|
+
"/search",
|
|
231
|
+
"/search:batch",
|
|
232
|
+
"/scroll",
|
|
233
|
+
"/count",
|
|
234
|
+
"/entities:prepare",
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return bool(
|
|
238
|
+
is_idempotent
|
|
239
|
+
or is_idempotent_path
|
|
240
|
+
or (method.upper() == "GET")
|
|
241
|
+
or (idempotency_key is not None)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def request(
|
|
245
|
+
self,
|
|
246
|
+
method: str,
|
|
247
|
+
path: str,
|
|
248
|
+
*,
|
|
249
|
+
body: Optional[Any] = None,
|
|
250
|
+
query: Optional[Dict[str, Any]] = None,
|
|
251
|
+
headers: Optional[Dict[str, str]] = None,
|
|
252
|
+
request_id: Optional[str] = None,
|
|
253
|
+
idempotency_key: Optional[str] = None,
|
|
254
|
+
is_idempotent: bool = False,
|
|
255
|
+
timeout: Optional[float] = None,
|
|
256
|
+
auth: bool = True,
|
|
257
|
+
) -> HttpResponse:
|
|
258
|
+
"""Execute an HTTP request with headers, idempotency, and retries."""
|
|
259
|
+
clean_path = path if path.startswith("/") else f"/{path}"
|
|
260
|
+
full_path = f"{self.base_path}{clean_path}"
|
|
261
|
+
if query:
|
|
262
|
+
q_params = [(k, str(v)) for k, v in query.items() if v is not None]
|
|
263
|
+
if q_params:
|
|
264
|
+
full_path = f"{full_path}?{urllib.parse.urlencode(q_params)}"
|
|
265
|
+
|
|
266
|
+
if body is not None:
|
|
267
|
+
if isinstance(body, (bytes, bytearray)):
|
|
268
|
+
body_bytes = bytes(body)
|
|
269
|
+
elif isinstance(body, str):
|
|
270
|
+
body_bytes = body.encode("utf-8")
|
|
271
|
+
else:
|
|
272
|
+
body_bytes = json.dumps(body).encode("utf-8")
|
|
273
|
+
else:
|
|
274
|
+
body_bytes = None
|
|
275
|
+
|
|
276
|
+
req_headers: Dict[str, str] = {
|
|
277
|
+
"User-Agent": USER_AGENT,
|
|
278
|
+
"Accept": "application/json, application/problem+json",
|
|
279
|
+
}
|
|
280
|
+
req_id = request_id or uuid.uuid4().hex
|
|
281
|
+
req_headers["X-Request-Id"] = req_id
|
|
282
|
+
|
|
283
|
+
if auth and self.api_key:
|
|
284
|
+
req_headers["Authorization"] = f"Bearer {self.api_key}"
|
|
285
|
+
|
|
286
|
+
if body_bytes is not None:
|
|
287
|
+
req_headers["Content-Type"] = "application/json"
|
|
288
|
+
req_headers["Content-Length"] = str(len(body_bytes))
|
|
289
|
+
|
|
290
|
+
if idempotency_key is not None:
|
|
291
|
+
req_headers["Idempotency-Key"] = str(idempotency_key)
|
|
292
|
+
|
|
293
|
+
if headers:
|
|
294
|
+
req_headers.update(headers)
|
|
295
|
+
|
|
296
|
+
is_idempotent_path = any(
|
|
297
|
+
path.endswith(p) or f"{p}?" in path
|
|
298
|
+
for p in (
|
|
299
|
+
"/search",
|
|
300
|
+
"/search:batch",
|
|
301
|
+
"/scroll",
|
|
302
|
+
"/count",
|
|
303
|
+
"/entities:prepare",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
can_retry = (
|
|
307
|
+
is_idempotent
|
|
308
|
+
or is_idempotent_path
|
|
309
|
+
or (method.upper() == "GET")
|
|
310
|
+
or (idempotency_key is not None)
|
|
311
|
+
)
|
|
312
|
+
effective_timeout = timeout if timeout is not None else self.timeout
|
|
313
|
+
attempts_limit = self.max_retries if can_retry else 0
|
|
314
|
+
|
|
315
|
+
last_retry_after: Optional[float] = None
|
|
316
|
+
|
|
317
|
+
for attempt in range(attempts_limit + 1):
|
|
318
|
+
if attempt > 0:
|
|
319
|
+
if last_retry_after is not None:
|
|
320
|
+
delay = last_retry_after
|
|
321
|
+
else:
|
|
322
|
+
n = attempt - 1
|
|
323
|
+
base_delay = 0.2 * (2.0**n)
|
|
324
|
+
jitter = random.uniform(0.8, 1.2)
|
|
325
|
+
delay = base_delay * jitter
|
|
326
|
+
time.sleep(delay)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
resp = self._send_once(
|
|
330
|
+
method=method.upper(),
|
|
331
|
+
path=full_path,
|
|
332
|
+
headers=req_headers,
|
|
333
|
+
body_bytes=body_bytes,
|
|
334
|
+
timeout=effective_timeout,
|
|
335
|
+
)
|
|
336
|
+
except (http.client.HTTPException, socket.error, OSError, TimeoutError) as e:
|
|
337
|
+
trans_err = TransportError(str(e), request_id=req_id, cause=e)
|
|
338
|
+
if attempt < attempts_limit:
|
|
339
|
+
last_retry_after = None
|
|
340
|
+
continue
|
|
341
|
+
raise trans_err from e
|
|
342
|
+
|
|
343
|
+
status = resp.status
|
|
344
|
+
if 200 <= status < 300:
|
|
345
|
+
return resp
|
|
346
|
+
|
|
347
|
+
if clean_path == "/readyz" and status == 503:
|
|
348
|
+
problem_data = self._parse_json(resp.body)
|
|
349
|
+
if isinstance(problem_data, dict) and "ready" in problem_data:
|
|
350
|
+
return resp
|
|
351
|
+
|
|
352
|
+
problem_dict = self._parse_json(resp.body)
|
|
353
|
+
err = self._map_error(
|
|
354
|
+
status=status,
|
|
355
|
+
problem=problem_dict,
|
|
356
|
+
request_id=resp.request_id,
|
|
357
|
+
resp_body=resp.body,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if attempt < attempts_limit and err.retryable:
|
|
361
|
+
retry_after_str = resp.headers.get("retry-after")
|
|
362
|
+
last_retry_after = self._parse_retry_after(retry_after_str)
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
raise err
|
|
366
|
+
|
|
367
|
+
raise TransportError("Request failed after all retries", request_id=req_id)
|
|
368
|
+
|
|
369
|
+
def _parse_json(self, body: bytes) -> Optional[Dict[str, Any]]:
|
|
370
|
+
if not body:
|
|
371
|
+
return None
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(body.decode("utf-8"))
|
|
374
|
+
if isinstance(data, dict):
|
|
375
|
+
return data
|
|
376
|
+
return None
|
|
377
|
+
except Exception:
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def _map_error(
|
|
381
|
+
self,
|
|
382
|
+
status: int,
|
|
383
|
+
problem: Optional[Dict[str, Any]],
|
|
384
|
+
request_id: str,
|
|
385
|
+
resp_body: bytes,
|
|
386
|
+
) -> ApiError:
|
|
387
|
+
if problem and ("code" in problem or "type" in problem):
|
|
388
|
+
return error_from_problem(problem, status=status, request_id=request_id)
|
|
389
|
+
|
|
390
|
+
retryable = status in (429, 502, 503, 504)
|
|
391
|
+
detail = resp_body.decode("utf-8", errors="replace") if resp_body else None
|
|
392
|
+
code = (
|
|
393
|
+
"rate_limited"
|
|
394
|
+
if status == 429
|
|
395
|
+
else ("not_ready" if status == 503 else f"http_{status}")
|
|
396
|
+
)
|
|
397
|
+
synth_problem: Dict[str, Any] = {
|
|
398
|
+
"type": f"urn:arkivector:error:{code}",
|
|
399
|
+
"title": f"HTTP {status}",
|
|
400
|
+
"status": status,
|
|
401
|
+
"code": code,
|
|
402
|
+
"retryable": retryable,
|
|
403
|
+
"detail": detail,
|
|
404
|
+
"request_id": request_id,
|
|
405
|
+
}
|
|
406
|
+
return error_from_problem(synth_problem, status=status, request_id=request_id)
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _parse_retry_after(header_val: Optional[str]) -> Optional[float]:
|
|
410
|
+
if not header_val:
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
val = float(header_val.strip())
|
|
414
|
+
return max(0.0, val)
|
|
415
|
+
except ValueError:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
def close(self) -> None:
|
|
419
|
+
"""Close connection pool."""
|
|
420
|
+
self._pool.close()
|