originchain 0.3.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.
- originchain/__init__.py +78 -0
- originchain/async_client.py +471 -0
- originchain/client.py +596 -0
- originchain/errors.py +95 -0
- originchain/models.py +179 -0
- originchain-0.3.0.dist-info/METADATA +154 -0
- originchain-0.3.0.dist-info/RECORD +8 -0
- originchain-0.3.0.dist-info/WHEEL +4 -0
originchain/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""OriginChain Python client.
|
|
2
|
+
|
|
3
|
+
Two clients ship in this package:
|
|
4
|
+
|
|
5
|
+
- :class:`OriginChain` — synchronous, suitable for scripts and Jupyter.
|
|
6
|
+
- :class:`AsyncOriginChain` — asyncio, suitable for ASGI / async data
|
|
7
|
+
pipelines.
|
|
8
|
+
|
|
9
|
+
Both wrap the same ``/v1`` HTTP surface that the engine exposes; they
|
|
10
|
+
encode bearer auth, idempotency keys, and retries so callers don't have
|
|
11
|
+
to repeat themselves.
|
|
12
|
+
|
|
13
|
+
Quick start::
|
|
14
|
+
|
|
15
|
+
from originchain import OriginChain
|
|
16
|
+
|
|
17
|
+
db = OriginChain.from_env() # OC_BASE_URL + OC_BEARER
|
|
18
|
+
db.schemas.register(open("orders.toml").read())
|
|
19
|
+
db.rows.put("trading.orders", {"order_id": "o1", "symbol": "AAPL", "qty": 100})
|
|
20
|
+
rows = db.ask("orders for AAPL above 50 shares last week")
|
|
21
|
+
|
|
22
|
+
The four substrate-extension surfaces (SQL, vector, full-text, graph)
|
|
23
|
+
are also typed first-class. See :class:`OriginChain.sql`,
|
|
24
|
+
``vector_topk`` / ``vector_put``, ``fts_search``, and the ``graph``
|
|
25
|
+
namespace.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .client import OriginChain
|
|
29
|
+
from .async_client import AsyncOriginChain
|
|
30
|
+
from .errors import (
|
|
31
|
+
OCAuthError,
|
|
32
|
+
OCError,
|
|
33
|
+
OCNotFoundError,
|
|
34
|
+
OCPaymentRequiredError,
|
|
35
|
+
OCRateLimitedError,
|
|
36
|
+
OCReplicationDegraded,
|
|
37
|
+
OCServerError,
|
|
38
|
+
OCValidationError,
|
|
39
|
+
)
|
|
40
|
+
from .models import (
|
|
41
|
+
DijkstraResult,
|
|
42
|
+
FtsHit,
|
|
43
|
+
GraphBfsHit,
|
|
44
|
+
GraphPath,
|
|
45
|
+
Neighbor,
|
|
46
|
+
SqlDelete,
|
|
47
|
+
SqlInsert,
|
|
48
|
+
SqlResponse,
|
|
49
|
+
SqlSelect,
|
|
50
|
+
VectorHit,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"OriginChain",
|
|
55
|
+
"AsyncOriginChain",
|
|
56
|
+
# Errors
|
|
57
|
+
"OCError",
|
|
58
|
+
"OCAuthError",
|
|
59
|
+
"OCNotFoundError",
|
|
60
|
+
"OCPaymentRequiredError",
|
|
61
|
+
"OCRateLimitedError",
|
|
62
|
+
"OCServerError",
|
|
63
|
+
"OCValidationError",
|
|
64
|
+
"OCReplicationDegraded",
|
|
65
|
+
# Models
|
|
66
|
+
"SqlSelect",
|
|
67
|
+
"SqlInsert",
|
|
68
|
+
"SqlDelete",
|
|
69
|
+
"SqlResponse",
|
|
70
|
+
"VectorHit",
|
|
71
|
+
"FtsHit",
|
|
72
|
+
"Neighbor",
|
|
73
|
+
"GraphBfsHit",
|
|
74
|
+
"GraphPath",
|
|
75
|
+
"DijkstraResult",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Async variant of :class:`OriginChain`. Mirrors the sync surface.
|
|
2
|
+
|
|
3
|
+
Both clients are kept side-by-side rather than sharing a common base
|
|
4
|
+
because httpx's sync/async clients have subtly different timeout +
|
|
5
|
+
context-manager semantics. Duplication is the lesser evil; pyright
|
|
6
|
+
catches drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json as _json
|
|
13
|
+
import os
|
|
14
|
+
import warnings
|
|
15
|
+
from typing import Any, List, Literal, Mapping, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .client import (
|
|
20
|
+
DEFAULT_MAX_RETRIES,
|
|
21
|
+
DEFAULT_TIMEOUT_S,
|
|
22
|
+
RETRYABLE_STATUSES,
|
|
23
|
+
_HTTP2_AVAILABLE,
|
|
24
|
+
_MUTATING_METHODS,
|
|
25
|
+
_new_idempotency_key,
|
|
26
|
+
)
|
|
27
|
+
from .errors import (
|
|
28
|
+
OCAuthError,
|
|
29
|
+
OCError,
|
|
30
|
+
OCNotFoundError,
|
|
31
|
+
OCPaymentRequiredError,
|
|
32
|
+
OCRateLimitedError,
|
|
33
|
+
OCReplicationDegraded,
|
|
34
|
+
OCServerError,
|
|
35
|
+
OCValidationError,
|
|
36
|
+
)
|
|
37
|
+
from .models import (
|
|
38
|
+
DijkstraResult,
|
|
39
|
+
FtsHit,
|
|
40
|
+
GraphBfsHit,
|
|
41
|
+
GraphPath,
|
|
42
|
+
Neighbor,
|
|
43
|
+
SqlResponse,
|
|
44
|
+
SqlSelect,
|
|
45
|
+
VectorHit,
|
|
46
|
+
_decode_sql_response,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _AsyncSchemas:
|
|
51
|
+
def __init__(self, parent: "AsyncOriginChain") -> None:
|
|
52
|
+
self._p = parent
|
|
53
|
+
|
|
54
|
+
async def list(self) -> list[str]:
|
|
55
|
+
r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/schemas")
|
|
56
|
+
return r.json()
|
|
57
|
+
|
|
58
|
+
async def get(self, schema: str) -> str:
|
|
59
|
+
r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/schemas/{schema}")
|
|
60
|
+
return r.text
|
|
61
|
+
|
|
62
|
+
async def register(self, toml_source: str) -> dict[str, Any]:
|
|
63
|
+
r = await self._p._request(
|
|
64
|
+
"POST",
|
|
65
|
+
f"/v1/tenants/{self._p.tenant}/schemas",
|
|
66
|
+
content=toml_source.encode("utf-8"),
|
|
67
|
+
headers={"Content-Type": "text/plain"},
|
|
68
|
+
)
|
|
69
|
+
return r.json()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _AsyncRows:
|
|
73
|
+
def __init__(self, parent: "AsyncOriginChain") -> None:
|
|
74
|
+
self._p = parent
|
|
75
|
+
|
|
76
|
+
async def get(self, schema: str, pk: str) -> dict[str, Any]:
|
|
77
|
+
r = await self._p._request("GET", f"/v1/tenants/{self._p.tenant}/rows/{schema}/{pk}")
|
|
78
|
+
return r.json()
|
|
79
|
+
|
|
80
|
+
async def put(
|
|
81
|
+
self,
|
|
82
|
+
schema: str,
|
|
83
|
+
row: Mapping[str, Any],
|
|
84
|
+
*,
|
|
85
|
+
expect_insert: bool = False,
|
|
86
|
+
idempotency_key: Optional[str] = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
params = {"expect": "insert"} if expect_insert else None
|
|
89
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
90
|
+
r = await self._p._request(
|
|
91
|
+
"POST",
|
|
92
|
+
f"/v1/tenants/{self._p.tenant}/rows/{schema}",
|
|
93
|
+
json=row,
|
|
94
|
+
params=params,
|
|
95
|
+
headers=headers,
|
|
96
|
+
)
|
|
97
|
+
return r.json()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _AsyncGraph:
|
|
101
|
+
"""Async ``db.graph.*`` namespace. Mirrors the sync ``_Graph``."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, parent: "AsyncOriginChain") -> None:
|
|
104
|
+
self._p = parent
|
|
105
|
+
|
|
106
|
+
async def neighbors(self, schema: str, *, rel: str, pk: str) -> List[Neighbor]:
|
|
107
|
+
params = {"rel": rel, "pk": pk}
|
|
108
|
+
r = await self._p._request(
|
|
109
|
+
"GET",
|
|
110
|
+
f"/v1/tenants/{self._p.tenant}/graph/{schema}/neighbors",
|
|
111
|
+
params=params,
|
|
112
|
+
)
|
|
113
|
+
return [Neighbor(pk=str(p), depth=1) for p in r.json()]
|
|
114
|
+
|
|
115
|
+
async def reverse_neighbors(
|
|
116
|
+
self, schema: str, *, rel: str, pk: str
|
|
117
|
+
) -> List[Neighbor]:
|
|
118
|
+
params = {"rel": rel, "pk": pk}
|
|
119
|
+
r = await self._p._request(
|
|
120
|
+
"GET",
|
|
121
|
+
f"/v1/tenants/{self._p.tenant}/graph/{schema}/reverse",
|
|
122
|
+
params=params,
|
|
123
|
+
)
|
|
124
|
+
return [Neighbor(pk=str(p), depth=1) for p in r.json()]
|
|
125
|
+
|
|
126
|
+
async def bfs(
|
|
127
|
+
self,
|
|
128
|
+
schema: str,
|
|
129
|
+
*,
|
|
130
|
+
rel: str,
|
|
131
|
+
pk: str,
|
|
132
|
+
max_depth: int = 3,
|
|
133
|
+
) -> List[GraphBfsHit]:
|
|
134
|
+
params = {"rel": rel, "pk": pk, "max_depth": str(max_depth)}
|
|
135
|
+
r = await self._p._request(
|
|
136
|
+
"GET",
|
|
137
|
+
f"/v1/tenants/{self._p.tenant}/graph/{schema}/bfs",
|
|
138
|
+
params=params,
|
|
139
|
+
)
|
|
140
|
+
return [GraphBfsHit._from_payload(h) for h in r.json()]
|
|
141
|
+
|
|
142
|
+
async def path(
|
|
143
|
+
self,
|
|
144
|
+
schema: str,
|
|
145
|
+
*,
|
|
146
|
+
rel: str,
|
|
147
|
+
src: str,
|
|
148
|
+
dst: str,
|
|
149
|
+
max_depth: int = 3,
|
|
150
|
+
) -> GraphPath:
|
|
151
|
+
params = {"rel": rel, "src": src, "dst": dst, "max_depth": str(max_depth)}
|
|
152
|
+
r = await self._p._request(
|
|
153
|
+
"GET",
|
|
154
|
+
f"/v1/tenants/{self._p.tenant}/graph/{schema}/path",
|
|
155
|
+
params=params,
|
|
156
|
+
)
|
|
157
|
+
body = r.json()
|
|
158
|
+
return GraphPath(reachable=bool(body.get("reachable", False)))
|
|
159
|
+
|
|
160
|
+
async def dijkstra(
|
|
161
|
+
self,
|
|
162
|
+
schema: str,
|
|
163
|
+
*,
|
|
164
|
+
rel: str,
|
|
165
|
+
src: str,
|
|
166
|
+
dst: str,
|
|
167
|
+
weights: Mapping[str, float],
|
|
168
|
+
) -> DijkstraResult:
|
|
169
|
+
params = {
|
|
170
|
+
"rel": rel,
|
|
171
|
+
"src": src,
|
|
172
|
+
"dst": dst,
|
|
173
|
+
"weights_json": _json.dumps(dict(weights)),
|
|
174
|
+
}
|
|
175
|
+
r = await self._p._request(
|
|
176
|
+
"GET",
|
|
177
|
+
f"/v1/tenants/{self._p.tenant}/graph/{schema}/dijkstra",
|
|
178
|
+
params=params,
|
|
179
|
+
)
|
|
180
|
+
body = r.json()
|
|
181
|
+
cost = body.get("cost")
|
|
182
|
+
return DijkstraResult(cost=None if cost is None else float(cost))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AsyncOriginChain:
|
|
186
|
+
"""asyncio-native client. ``async with`` for resource cleanup."""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
*,
|
|
191
|
+
base_url: str,
|
|
192
|
+
bearer: str,
|
|
193
|
+
tenant: str,
|
|
194
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
195
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
196
|
+
verify: bool | str = True,
|
|
197
|
+
user_agent: Optional[str] = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
self.base_url = base_url.rstrip("/")
|
|
200
|
+
self.bearer = bearer
|
|
201
|
+
self.tenant = tenant
|
|
202
|
+
self.max_retries = max_retries
|
|
203
|
+
self._client = httpx.AsyncClient(
|
|
204
|
+
base_url=self.base_url,
|
|
205
|
+
timeout=timeout,
|
|
206
|
+
verify=verify,
|
|
207
|
+
http2=_HTTP2_AVAILABLE,
|
|
208
|
+
headers={
|
|
209
|
+
"Authorization": f"Bearer {bearer}",
|
|
210
|
+
"User-Agent": user_agent or "originchain-python/0.3.0",
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
self.schemas = _AsyncSchemas(self)
|
|
214
|
+
self.rows = _AsyncRows(self)
|
|
215
|
+
self.graph = _AsyncGraph(self)
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def from_env(cls, **kwargs: Any) -> "AsyncOriginChain":
|
|
219
|
+
try:
|
|
220
|
+
base_url = os.environ["OC_BASE_URL"]
|
|
221
|
+
bearer = os.environ["OC_BEARER"]
|
|
222
|
+
tenant = os.environ["OC_TENANT"]
|
|
223
|
+
except KeyError as e:
|
|
224
|
+
raise OCError(f"missing required env var: {e.args[0]}") from None
|
|
225
|
+
return cls(base_url=base_url, bearer=bearer, tenant=tenant, **kwargs)
|
|
226
|
+
|
|
227
|
+
async def health(self) -> dict[str, Any]:
|
|
228
|
+
r = await self._request("GET", "/health")
|
|
229
|
+
return r.json()
|
|
230
|
+
|
|
231
|
+
async def ask(
|
|
232
|
+
self, nl: str, *, schemas: Optional[list[str]] = None
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
body: dict[str, Any] = {"nl": nl}
|
|
235
|
+
if schemas is not None:
|
|
236
|
+
body["schemas"] = schemas
|
|
237
|
+
r = await self._request("POST", f"/v1/tenants/{self.tenant}/ask", json=body)
|
|
238
|
+
return r.json()
|
|
239
|
+
|
|
240
|
+
async def query(self, plan: dict[str, Any]) -> list[Any]:
|
|
241
|
+
r = await self._request(
|
|
242
|
+
"POST", f"/v1/tenants/{self.tenant}/query", json=plan
|
|
243
|
+
)
|
|
244
|
+
return r.json()
|
|
245
|
+
|
|
246
|
+
# ── SQL ───────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
async def sql(self, query: str) -> SqlResponse:
|
|
249
|
+
"""See :meth:`OriginChain.sql` for the contract."""
|
|
250
|
+
r = await self._request(
|
|
251
|
+
"POST",
|
|
252
|
+
f"/v1/tenants/{self.tenant}/sql",
|
|
253
|
+
json={"sql": query},
|
|
254
|
+
)
|
|
255
|
+
return _decode_sql_response(r.json())
|
|
256
|
+
|
|
257
|
+
async def sql_one(self, query: str) -> Optional[dict[str, Any]]:
|
|
258
|
+
resp = await self.sql(query)
|
|
259
|
+
if not isinstance(resp, SqlSelect):
|
|
260
|
+
raise OCValidationError(
|
|
261
|
+
f"sql_one expected SELECT, got {type(resp).__name__}"
|
|
262
|
+
)
|
|
263
|
+
if not resp.rows:
|
|
264
|
+
return None
|
|
265
|
+
first = resp.rows[0]
|
|
266
|
+
if not isinstance(first, dict):
|
|
267
|
+
return {"value": first}
|
|
268
|
+
return dict(first)
|
|
269
|
+
|
|
270
|
+
# ── Vector ────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
async def vector_put(
|
|
273
|
+
self,
|
|
274
|
+
table: str,
|
|
275
|
+
*,
|
|
276
|
+
id: str,
|
|
277
|
+
embedding: list[float],
|
|
278
|
+
dim: int,
|
|
279
|
+
metric: str = "cosine",
|
|
280
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
body: dict[str, Any] = {
|
|
283
|
+
"id": id,
|
|
284
|
+
"embedding": list(embedding),
|
|
285
|
+
"dim": dim,
|
|
286
|
+
"metric": metric,
|
|
287
|
+
}
|
|
288
|
+
if metadata is not None:
|
|
289
|
+
body["metadata"] = dict(metadata)
|
|
290
|
+
await self._request(
|
|
291
|
+
"POST",
|
|
292
|
+
f"/v1/tenants/{self.tenant}/vector/{table}/put",
|
|
293
|
+
json=body,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async def vector_topk(
|
|
297
|
+
self,
|
|
298
|
+
table: str,
|
|
299
|
+
*,
|
|
300
|
+
query: list[float],
|
|
301
|
+
k: int = 10,
|
|
302
|
+
dim: int,
|
|
303
|
+
metric: str = "cosine",
|
|
304
|
+
filter: Optional[Mapping[str, Any]] = None,
|
|
305
|
+
mode: Optional[Literal["fast", "high_recall"]] = None,
|
|
306
|
+
) -> list[VectorHit]:
|
|
307
|
+
body: dict[str, Any] = {
|
|
308
|
+
"query": list(query),
|
|
309
|
+
"k": k,
|
|
310
|
+
"dim": dim,
|
|
311
|
+
"metric": metric,
|
|
312
|
+
}
|
|
313
|
+
if mode is not None:
|
|
314
|
+
body["mode"] = mode
|
|
315
|
+
if filter is not None:
|
|
316
|
+
body["filter"] = dict(filter)
|
|
317
|
+
r = await self._request(
|
|
318
|
+
"POST",
|
|
319
|
+
f"/v1/tenants/{self.tenant}/vector/{table}/topk",
|
|
320
|
+
json=body,
|
|
321
|
+
)
|
|
322
|
+
return [VectorHit._from_payload(h) for h in r.json()]
|
|
323
|
+
|
|
324
|
+
# ── Full-text ─────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
async def fts_index(
|
|
327
|
+
self,
|
|
328
|
+
table: str,
|
|
329
|
+
field: str,
|
|
330
|
+
*,
|
|
331
|
+
doc_id: str,
|
|
332
|
+
text: str,
|
|
333
|
+
) -> None:
|
|
334
|
+
await self._request(
|
|
335
|
+
"POST",
|
|
336
|
+
f"/v1/tenants/{self.tenant}/fts/{table}/{field}",
|
|
337
|
+
json={"doc_id": doc_id, "text": text},
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def fts_search(
|
|
341
|
+
self,
|
|
342
|
+
table: str,
|
|
343
|
+
field: str,
|
|
344
|
+
*,
|
|
345
|
+
q: str,
|
|
346
|
+
mode: str = "boolean",
|
|
347
|
+
k: int = 10,
|
|
348
|
+
) -> list[FtsHit]:
|
|
349
|
+
params: dict[str, str] = {"q": q, "mode": mode}
|
|
350
|
+
if mode == "bm25":
|
|
351
|
+
params["k"] = str(k)
|
|
352
|
+
r = await self._request(
|
|
353
|
+
"GET",
|
|
354
|
+
f"/v1/tenants/{self.tenant}/fts/{table}/{field}",
|
|
355
|
+
params=params,
|
|
356
|
+
)
|
|
357
|
+
body = r.json()
|
|
358
|
+
if mode == "bm25":
|
|
359
|
+
return [FtsHit._from_ranked(h) for h in body]
|
|
360
|
+
return [FtsHit._from_doc_id(str(d)) for d in body]
|
|
361
|
+
|
|
362
|
+
async def aclose(self) -> None:
|
|
363
|
+
await self._client.aclose()
|
|
364
|
+
|
|
365
|
+
async def __aenter__(self) -> "AsyncOriginChain":
|
|
366
|
+
return self
|
|
367
|
+
|
|
368
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
369
|
+
await self.aclose()
|
|
370
|
+
|
|
371
|
+
async def _request(
|
|
372
|
+
self,
|
|
373
|
+
method: str,
|
|
374
|
+
path: str,
|
|
375
|
+
*,
|
|
376
|
+
params: Optional[dict] = None,
|
|
377
|
+
json: Any = None,
|
|
378
|
+
content: Optional[bytes] = None,
|
|
379
|
+
headers: Optional[dict[str, str]] = None,
|
|
380
|
+
) -> httpx.Response:
|
|
381
|
+
# Auto-Idempotency-Key on mutating calls. See the sync client's
|
|
382
|
+
# `_request` for the rationale; engine cache is LRU-bounded so
|
|
383
|
+
# fresh-per-call is safe, and callers retain override semantics
|
|
384
|
+
# by passing the header explicitly.
|
|
385
|
+
if method.upper() in _MUTATING_METHODS:
|
|
386
|
+
if headers is None:
|
|
387
|
+
headers = {"Idempotency-Key": _new_idempotency_key()}
|
|
388
|
+
elif not any(k.lower() == "idempotency-key" for k in headers):
|
|
389
|
+
headers = {**headers, "Idempotency-Key": _new_idempotency_key()}
|
|
390
|
+
last_exc: Optional[Exception] = None
|
|
391
|
+
for attempt in range(self.max_retries + 1):
|
|
392
|
+
try:
|
|
393
|
+
resp = await self._client.request(
|
|
394
|
+
method,
|
|
395
|
+
path,
|
|
396
|
+
params=params,
|
|
397
|
+
json=json,
|
|
398
|
+
content=content,
|
|
399
|
+
headers=headers,
|
|
400
|
+
)
|
|
401
|
+
except httpx.RequestError as e:
|
|
402
|
+
last_exc = e
|
|
403
|
+
if attempt < self.max_retries:
|
|
404
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
405
|
+
continue
|
|
406
|
+
raise OCError(f"transport error: {e}") from e
|
|
407
|
+
|
|
408
|
+
if resp.status_code < 400:
|
|
409
|
+
if resp.headers.get("X-OC-Replication", "").lower() == "degraded":
|
|
410
|
+
warnings.warn(
|
|
411
|
+
"leader returned 200 but follower(s) didn't ack within"
|
|
412
|
+
" --sync-timeout-ms; write is durable but RPO=0 not met",
|
|
413
|
+
OCReplicationDegraded,
|
|
414
|
+
stacklevel=3,
|
|
415
|
+
)
|
|
416
|
+
return resp
|
|
417
|
+
|
|
418
|
+
if resp.status_code in RETRYABLE_STATUSES and attempt < self.max_retries:
|
|
419
|
+
wait = self._retry_after(resp) or self._backoff(attempt)
|
|
420
|
+
await asyncio.sleep(wait)
|
|
421
|
+
continue
|
|
422
|
+
self._raise_for(resp)
|
|
423
|
+
raise OCError(f"request failed after retries: {last_exc}")
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _backoff(attempt: int) -> float:
|
|
427
|
+
return min(4.0, 0.25 * (2 ** attempt))
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _retry_after(resp: httpx.Response) -> Optional[float]:
|
|
431
|
+
ra = resp.headers.get("Retry-After")
|
|
432
|
+
if ra is None:
|
|
433
|
+
return None
|
|
434
|
+
try:
|
|
435
|
+
return float(ra)
|
|
436
|
+
except ValueError:
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _raise_for(resp: httpx.Response) -> None:
|
|
441
|
+
body: Any
|
|
442
|
+
try:
|
|
443
|
+
body = resp.json()
|
|
444
|
+
except Exception:
|
|
445
|
+
body = resp.text
|
|
446
|
+
msg = body.get("error") if isinstance(body, dict) else str(body)
|
|
447
|
+
status = resp.status_code
|
|
448
|
+
if status in (401, 403):
|
|
449
|
+
raise OCAuthError(msg or "unauthorized", status=status, body=body)
|
|
450
|
+
if status == 402:
|
|
451
|
+
addon_msg = (
|
|
452
|
+
body.get("msg") if isinstance(body, dict) else None
|
|
453
|
+
) or msg or "payment required (add-on)"
|
|
454
|
+
raise OCPaymentRequiredError(addon_msg, status=status, body=body)
|
|
455
|
+
if status == 404:
|
|
456
|
+
raise OCNotFoundError(msg or "not found", status=status, body=body)
|
|
457
|
+
if status == 400:
|
|
458
|
+
raise OCValidationError(msg or "validation failed", status=status, body=body)
|
|
459
|
+
if status == 429:
|
|
460
|
+
raise OCRateLimitedError(
|
|
461
|
+
msg or "rate limited",
|
|
462
|
+
retry_after=AsyncOriginChain._retry_after(resp) or 1.0,
|
|
463
|
+
status=status,
|
|
464
|
+
body=body,
|
|
465
|
+
)
|
|
466
|
+
if 500 <= status < 600:
|
|
467
|
+
raise OCServerError(msg or f"server error {status}", status=status, body=body)
|
|
468
|
+
raise OCError(msg or f"unexpected status {status}", status=status, body=body)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
__all__ = ["AsyncOriginChain"]
|