lorebase-client 0.2.3__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.
- lorebase_client/__init__.py +86 -0
- lorebase_client/aclient.py +1585 -0
- lorebase_client/client.py +1587 -0
- lorebase_client/config.py +160 -0
- lorebase_client/core.py +330 -0
- lorebase_client/endpoints.py +1292 -0
- lorebase_client/errors.py +242 -0
- lorebase_client/models.py +1662 -0
- lorebase_client/py.typed +0 -0
- lorebase_client-0.2.3.dist-info/METADATA +7 -0
- lorebase_client-0.2.3.dist-info/RECORD +12 -0
- lorebase_client-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Client configuration: constructor arguments, then environment variables, then defaults.
|
|
2
|
+
|
|
3
|
+
| Argument | Environment variable | Default |
|
|
4
|
+
|----------|----------------------|---------|
|
|
5
|
+
| `base_url` | `LOREBASE_URL` | required |
|
|
6
|
+
| `api_key` | `LOREBASE_API_KEY` | none |
|
|
7
|
+
| `token` | `LOREBASE_TOKEN` | none |
|
|
8
|
+
| `timeout` | `LOREBASE_TIMEOUT` (seconds) | `DEFAULT_TIMEOUT_S` |
|
|
9
|
+
| `api_prefix` | `LOREBASE_API_PREFIX` | `DEFAULT_API_PREFIX` |
|
|
10
|
+
| `session_id`, `tool`, `trace` | none | none (agent trace headers) |
|
|
11
|
+
|
|
12
|
+
An API key (`lbk_...`) and an OIDC access token both travel as `Authorization: Bearer`; when
|
|
13
|
+
both are set the API key wins. `token` may be a callable returning the current token, called
|
|
14
|
+
for every request, so that a refreshing token source plugs in.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from lorebase_client.errors import LorebaseConfigError
|
|
26
|
+
|
|
27
|
+
ENV_URL = "LOREBASE_URL"
|
|
28
|
+
ENV_API_KEY = "LOREBASE_API_KEY"
|
|
29
|
+
ENV_TOKEN = "LOREBASE_TOKEN"
|
|
30
|
+
ENV_TIMEOUT = "LOREBASE_TIMEOUT"
|
|
31
|
+
ENV_API_PREFIX = "LOREBASE_API_PREFIX"
|
|
32
|
+
|
|
33
|
+
DEFAULT_API_PREFIX = "/api/v1"
|
|
34
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
35
|
+
DEFAULT_MAX_RETRIES = 3
|
|
36
|
+
DEFAULT_BACKOFF_BASE_S = 0.5
|
|
37
|
+
DEFAULT_BACKOFF_MAX_S = 30.0
|
|
38
|
+
DEFAULT_CACHE_ENTRIES = 256
|
|
39
|
+
RETRY_STATUSES = frozenset({429, 503})
|
|
40
|
+
|
|
41
|
+
SESSION_HEADER = "X-Lorebase-Session"
|
|
42
|
+
TOOL_HEADER = "X-Lorebase-Tool"
|
|
43
|
+
TRACE_HEADER = "X-Lorebase-Trace"
|
|
44
|
+
"""Default names of the agent trace headers (Keeper can be configured with others; pass those
|
|
45
|
+
through `headers=`)."""
|
|
46
|
+
SESSION_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
47
|
+
|
|
48
|
+
TokenSource = str | Callable[[], str]
|
|
49
|
+
TraceMode = Literal["on", "debug"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_session_id(session_id: str) -> str:
|
|
53
|
+
"""The session id, or `LorebaseConfigError` when it is not 1-128 of `A-Za-z0-9._:-`."""
|
|
54
|
+
if not SESSION_ID_PATTERN.fullmatch(session_id):
|
|
55
|
+
raise LorebaseConfigError("session_id must be 1-128 characters of A-Za-z0-9._:-")
|
|
56
|
+
return session_id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class RetryPolicy:
|
|
61
|
+
"""Retries on 429, 503 and connection failures, with exponential backoff.
|
|
62
|
+
|
|
63
|
+
The wait before attempt `n` (from 0) is `min(backoff_max_s, backoff_base_s * 2**n)`, or the
|
|
64
|
+
`Retry-After` of the response when there is one (capped at `backoff_max_s`). A random
|
|
65
|
+
share up to `jitter` of the computed wait is subtracted so that clients do not retry in step.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
69
|
+
backoff_base_s: float = DEFAULT_BACKOFF_BASE_S
|
|
70
|
+
backoff_max_s: float = DEFAULT_BACKOFF_MAX_S
|
|
71
|
+
jitter: float = 0.25
|
|
72
|
+
statuses: frozenset[int] = RETRY_STATUSES
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ClientConfig:
|
|
77
|
+
"""Resolved settings of a client."""
|
|
78
|
+
|
|
79
|
+
base_url: str
|
|
80
|
+
api_prefix: str = DEFAULT_API_PREFIX
|
|
81
|
+
api_key: str | None = None
|
|
82
|
+
token: TokenSource | None = None
|
|
83
|
+
timeout_s: float = DEFAULT_TIMEOUT_S
|
|
84
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
85
|
+
auto_idempotency_keys: bool = False
|
|
86
|
+
cache_entries: int | None = None
|
|
87
|
+
"""Entries of the ETag cache; `None` leaves caching off."""
|
|
88
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
89
|
+
session_id: str | None = None
|
|
90
|
+
"""Agent trace session sent as `X-Lorebase-Session` with every request."""
|
|
91
|
+
tool: str | None = None
|
|
92
|
+
"""Tool name sent as `X-Lorebase-Tool`."""
|
|
93
|
+
trace: TraceMode | None = None
|
|
94
|
+
"""`X-Lorebase-Trace` value: `on` opts requests in, `debug` adds query plans."""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def root(self) -> str:
|
|
98
|
+
"""URL every request path is appended to: server root plus API prefix."""
|
|
99
|
+
return self.base_url.rstrip("/") + self.api_prefix
|
|
100
|
+
|
|
101
|
+
def bearer(self) -> str | None:
|
|
102
|
+
"""The credential to send, or `None` for anonymous requests."""
|
|
103
|
+
if self.api_key:
|
|
104
|
+
return self.api_key
|
|
105
|
+
if callable(self.token):
|
|
106
|
+
return self.token() or None
|
|
107
|
+
return self.token or None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_config(
|
|
111
|
+
base_url: str | None = None,
|
|
112
|
+
*,
|
|
113
|
+
api_key: str | None = None,
|
|
114
|
+
token: TokenSource | None = None,
|
|
115
|
+
timeout: float | None = None,
|
|
116
|
+
api_prefix: str | None = None,
|
|
117
|
+
retry: RetryPolicy | None = None,
|
|
118
|
+
auto_idempotency_keys: bool = False,
|
|
119
|
+
cache: bool | int = False,
|
|
120
|
+
headers: Mapping[str, str] | None = None,
|
|
121
|
+
session_id: str | None = None,
|
|
122
|
+
tool: str | None = None,
|
|
123
|
+
trace: TraceMode | None = None,
|
|
124
|
+
env: Mapping[str, str] | None = None,
|
|
125
|
+
) -> ClientConfig:
|
|
126
|
+
"""Merge arguments with the environment (`env`, default `os.environ`).
|
|
127
|
+
|
|
128
|
+
`cache` turns the ETag cache on (`True`: `DEFAULT_CACHE_ENTRIES` entries; an integer:
|
|
129
|
+
that many).
|
|
130
|
+
"""
|
|
131
|
+
environment = os.environ if env is None else env
|
|
132
|
+
url = base_url or environment.get(ENV_URL)
|
|
133
|
+
if not url:
|
|
134
|
+
raise LorebaseConfigError(f"no server URL: pass base_url or set {ENV_URL}")
|
|
135
|
+
timeout_s = timeout
|
|
136
|
+
if timeout_s is None:
|
|
137
|
+
raw = environment.get(ENV_TIMEOUT)
|
|
138
|
+
try:
|
|
139
|
+
timeout_s = float(raw) if raw else DEFAULT_TIMEOUT_S
|
|
140
|
+
except ValueError:
|
|
141
|
+
raise LorebaseConfigError(f"{ENV_TIMEOUT} must be a number of seconds") from None
|
|
142
|
+
entries = None
|
|
143
|
+
if cache is True:
|
|
144
|
+
entries = DEFAULT_CACHE_ENTRIES
|
|
145
|
+
elif cache:
|
|
146
|
+
entries = int(cache)
|
|
147
|
+
return ClientConfig(
|
|
148
|
+
base_url=url,
|
|
149
|
+
api_prefix=api_prefix or environment.get(ENV_API_PREFIX) or DEFAULT_API_PREFIX,
|
|
150
|
+
api_key=api_key or environment.get(ENV_API_KEY) or None,
|
|
151
|
+
token=token or environment.get(ENV_TOKEN) or None,
|
|
152
|
+
timeout_s=timeout_s,
|
|
153
|
+
retry=retry or RetryPolicy(),
|
|
154
|
+
auto_idempotency_keys=auto_idempotency_keys,
|
|
155
|
+
cache_entries=entries,
|
|
156
|
+
headers=dict(headers or {}),
|
|
157
|
+
session_id=check_session_id(session_id) if session_id else None,
|
|
158
|
+
tool=tool or None,
|
|
159
|
+
trace=trace,
|
|
160
|
+
)
|
lorebase_client/core.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""The request layer shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
It is sans-IO: a `Call` describes one API request; an `Exchange` turns it into `httpx` request
|
|
4
|
+
arguments and decides, for each outcome, whether the answer is final or the request is sent
|
|
5
|
+
again after a wait. The clients only run the loop (send, sleep) with their own I/O:
|
|
6
|
+
|
|
7
|
+
exchange = Exchange(core, call)
|
|
8
|
+
while True:
|
|
9
|
+
try:
|
|
10
|
+
response = http.send(http.build_request(**exchange.request_args()))
|
|
11
|
+
except httpx.TransportError as error:
|
|
12
|
+
step = exchange.on_error(error)
|
|
13
|
+
else:
|
|
14
|
+
step = exchange.on_response(response)
|
|
15
|
+
if isinstance(step, Done):
|
|
16
|
+
return step.value
|
|
17
|
+
sleep(step.delay)
|
|
18
|
+
|
|
19
|
+
Retries (policy in `config.RetryPolicy`):
|
|
20
|
+
|
|
21
|
+
- 429 and, for problem documents, 503 are retried for every request: the server refused them
|
|
22
|
+
before doing anything. A 503 that is not a problem document (a proxy) is retried only for
|
|
23
|
+
requests that are safe to repeat.
|
|
24
|
+
- Connection and pool failures, where the request was never sent, are retried for every request.
|
|
25
|
+
- Other transport failures (timeouts while reading, resets) are retried only for requests that
|
|
26
|
+
are safe to repeat: reads, deletes and writes carrying an idempotency key.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import random
|
|
32
|
+
import re
|
|
33
|
+
import threading
|
|
34
|
+
import uuid
|
|
35
|
+
from collections import OrderedDict
|
|
36
|
+
from collections.abc import Callable, Mapping
|
|
37
|
+
from dataclasses import dataclass, field, replace
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
|
|
43
|
+
from lorebase_client.config import SESSION_HEADER, TOOL_HEADER, TRACE_HEADER, ClientConfig
|
|
44
|
+
from lorebase_client.errors import (
|
|
45
|
+
LorebaseConnectionError,
|
|
46
|
+
error_from_response,
|
|
47
|
+
is_problem,
|
|
48
|
+
parse_retry_after,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
|
52
|
+
_KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
|
|
53
|
+
_KEY_MAX = 255
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_idempotency_key(prefix: str | None = None) -> str:
|
|
57
|
+
"""A fresh key for a write, valid for the `Idempotency-Key` header.
|
|
58
|
+
|
|
59
|
+
Retrying a write with the same key returns the original result instead of repeating it.
|
|
60
|
+
"""
|
|
61
|
+
token = uuid.uuid4().hex
|
|
62
|
+
if not prefix:
|
|
63
|
+
return token
|
|
64
|
+
return f"{_KEY_CHARS.sub('-', prefix)[: _KEY_MAX - len(token) - 1]}-{token}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Call[T]:
|
|
69
|
+
"""One API request and how to read its answer."""
|
|
70
|
+
|
|
71
|
+
method: str
|
|
72
|
+
path: str
|
|
73
|
+
parse: Callable[[httpx.Response], T]
|
|
74
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
75
|
+
body: Any = None
|
|
76
|
+
"""JSON body."""
|
|
77
|
+
content: str | None = None
|
|
78
|
+
"""Raw text body (used instead of `body`) with `content_type`."""
|
|
79
|
+
content_type: str | None = None
|
|
80
|
+
files: tuple[tuple[str, str, bytes], ...] | None = None
|
|
81
|
+
"""Multipart upload (used instead of `body`): form field, file name and content."""
|
|
82
|
+
form: Mapping[str, str] = field(default_factory=dict)
|
|
83
|
+
"""Text fields sent along with `files`."""
|
|
84
|
+
accept: str = "application/json"
|
|
85
|
+
safe: bool = False
|
|
86
|
+
"""True when sending the request twice is harmless (reads, deletes)."""
|
|
87
|
+
accepts_key: bool = False
|
|
88
|
+
"""True for writes that honor an `Idempotency-Key`."""
|
|
89
|
+
idempotency_key: str | None = None
|
|
90
|
+
cacheable: bool = False
|
|
91
|
+
"""GET reads that carry an ETag; used by the opt-in cache."""
|
|
92
|
+
|
|
93
|
+
def with_params(self, **updates: Any) -> Call[T]:
|
|
94
|
+
"""The same call with query parameters replaced (for example the page cursor)."""
|
|
95
|
+
return replace(self, params={**self.params, **updates})
|
|
96
|
+
|
|
97
|
+
def with_key(self, key: str | None) -> Call[T]:
|
|
98
|
+
return replace(self, idempotency_key=key)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def repeatable(self) -> bool:
|
|
102
|
+
"""Whether a request whose fate is unknown may be sent again."""
|
|
103
|
+
return self.safe or self.idempotency_key is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_model[M: BaseModel](model: type[M]) -> Callable[[httpx.Response], M]:
|
|
107
|
+
"""Parser of a JSON body into a response model."""
|
|
108
|
+
return lambda response: model.model_validate_json(response.content)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_none(response: httpx.Response) -> None:
|
|
112
|
+
"""Parser of answers without a body."""
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_text(response: httpx.Response) -> str:
|
|
117
|
+
"""Parser of text answers (the YAML schema)."""
|
|
118
|
+
return response.text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_json(response: httpx.Response) -> Any:
|
|
122
|
+
"""Parser of JSON answers kept as plain data."""
|
|
123
|
+
return response.json()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Done[T]:
|
|
128
|
+
"""The final value of a call."""
|
|
129
|
+
|
|
130
|
+
value: T
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Retry:
|
|
135
|
+
"""Send the request again after `delay` seconds."""
|
|
136
|
+
|
|
137
|
+
delay: float
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class _Entry:
|
|
142
|
+
content: bytes
|
|
143
|
+
headers: tuple[tuple[str, str], ...]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ETagCache:
|
|
147
|
+
"""In-memory LRU of GET answers keyed by URL, revalidated with `If-None-Match`.
|
|
148
|
+
|
|
149
|
+
The server binds the `ETag` of a read to the Revision it was served from, so an entry stays
|
|
150
|
+
valid until the branch moves; the server answers 304 and the cached body is returned.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, max_entries: int) -> None:
|
|
154
|
+
self._max = max_entries
|
|
155
|
+
self._entries: OrderedDict[tuple[Any, ...], tuple[str, _Entry]] = OrderedDict()
|
|
156
|
+
self._lock = threading.Lock()
|
|
157
|
+
|
|
158
|
+
def __len__(self) -> int:
|
|
159
|
+
return len(self._entries)
|
|
160
|
+
|
|
161
|
+
def get(self, key: tuple[Any, ...]) -> tuple[str, _Entry] | None:
|
|
162
|
+
with self._lock:
|
|
163
|
+
found = self._entries.get(key)
|
|
164
|
+
if found is not None:
|
|
165
|
+
self._entries.move_to_end(key)
|
|
166
|
+
return found
|
|
167
|
+
|
|
168
|
+
def put(self, key: tuple[Any, ...], etag: str, response: httpx.Response) -> None:
|
|
169
|
+
entry = _Entry(response.content, tuple(response.headers.items()))
|
|
170
|
+
with self._lock:
|
|
171
|
+
self._entries[key] = (etag, entry)
|
|
172
|
+
self._entries.move_to_end(key)
|
|
173
|
+
while len(self._entries) > self._max:
|
|
174
|
+
self._entries.popitem(last=False)
|
|
175
|
+
|
|
176
|
+
def clear(self) -> None:
|
|
177
|
+
with self._lock:
|
|
178
|
+
self._entries.clear()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RequestCore:
|
|
182
|
+
"""Configuration, credentials, cache and clock of retries shared by one client."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, config: ClientConfig, *, rng: Callable[[], float] = random.random) -> None:
|
|
185
|
+
self.config = config
|
|
186
|
+
self.cache = None if config.cache_entries is None else ETagCache(config.cache_entries)
|
|
187
|
+
self._rng = rng
|
|
188
|
+
|
|
189
|
+
def url(self, path: str) -> str:
|
|
190
|
+
return self.config.root + path
|
|
191
|
+
|
|
192
|
+
def headers(self, call: Call[Any]) -> dict[str, str]:
|
|
193
|
+
config = self.config
|
|
194
|
+
headers = {"Accept": call.accept, **config.headers}
|
|
195
|
+
for name, value in (
|
|
196
|
+
(SESSION_HEADER, config.session_id),
|
|
197
|
+
(TOOL_HEADER, config.tool),
|
|
198
|
+
(TRACE_HEADER, config.trace),
|
|
199
|
+
):
|
|
200
|
+
if value:
|
|
201
|
+
headers[name] = value
|
|
202
|
+
bearer = self.config.bearer()
|
|
203
|
+
if bearer:
|
|
204
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
205
|
+
if call.content_type:
|
|
206
|
+
headers["Content-Type"] = call.content_type
|
|
207
|
+
if call.idempotency_key:
|
|
208
|
+
headers[IDEMPOTENCY_HEADER] = call.idempotency_key
|
|
209
|
+
return headers
|
|
210
|
+
|
|
211
|
+
def delay(self, attempt: int, retry_after: float | None) -> float:
|
|
212
|
+
"""Seconds to wait before retry number `attempt + 1`."""
|
|
213
|
+
policy = self.config.retry
|
|
214
|
+
if retry_after is not None:
|
|
215
|
+
return min(retry_after, policy.backoff_max_s)
|
|
216
|
+
computed = min(policy.backoff_max_s, policy.backoff_base_s * float(2**attempt))
|
|
217
|
+
return computed * (1.0 - policy.jitter * self._rng())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Exchange[T]:
|
|
221
|
+
"""The state of one call across its attempts."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, core: RequestCore, call: Call[T]) -> None:
|
|
224
|
+
if call.accepts_key and call.idempotency_key is None and core.config.auto_idempotency_keys:
|
|
225
|
+
call = call.with_key(new_idempotency_key())
|
|
226
|
+
self._core = core
|
|
227
|
+
self.call = call
|
|
228
|
+
self._attempt = 0
|
|
229
|
+
self._cache_key = self._key() if self._uses_cache() else None
|
|
230
|
+
|
|
231
|
+
def _uses_cache(self) -> bool:
|
|
232
|
+
return self._core.cache is not None and self.call.cacheable and self.call.method == "GET"
|
|
233
|
+
|
|
234
|
+
def _key(self) -> tuple[Any, ...]:
|
|
235
|
+
params = tuple(sorted((k, str(v)) for k, v in _flatten(self.call.params) if v is not None))
|
|
236
|
+
return (self._core.url(self.call.path), self.call.accept, params)
|
|
237
|
+
|
|
238
|
+
def request_args(self) -> dict[str, Any]:
|
|
239
|
+
"""Keyword arguments of `build_request`."""
|
|
240
|
+
call = self.call
|
|
241
|
+
headers = self._core.headers(call)
|
|
242
|
+
if self._cache_key is not None and self._core.cache is not None:
|
|
243
|
+
cached = self._core.cache.get(self._cache_key)
|
|
244
|
+
if cached is not None:
|
|
245
|
+
headers["If-None-Match"] = cached[0]
|
|
246
|
+
args: dict[str, Any] = {
|
|
247
|
+
"method": call.method,
|
|
248
|
+
"url": self._core.url(call.path),
|
|
249
|
+
"params": _query(call.params),
|
|
250
|
+
"headers": headers,
|
|
251
|
+
}
|
|
252
|
+
if call.files is not None:
|
|
253
|
+
args["files"] = [(name, (filename, data)) for name, filename, data in call.files]
|
|
254
|
+
if call.form:
|
|
255
|
+
args["data"] = dict(call.form)
|
|
256
|
+
elif call.content is not None:
|
|
257
|
+
args["content"] = call.content.encode()
|
|
258
|
+
elif call.body is not None:
|
|
259
|
+
args["json"] = call.body
|
|
260
|
+
return args
|
|
261
|
+
|
|
262
|
+
def on_response(self, response: httpx.Response) -> Done[T] | Retry:
|
|
263
|
+
"""Final value, or a retry; raises the typed error for a final failure."""
|
|
264
|
+
status = response.status_code
|
|
265
|
+
if status == 304 and self._cache_key is not None and self._core.cache is not None:
|
|
266
|
+
cached = self._core.cache.get(self._cache_key)
|
|
267
|
+
if cached is not None:
|
|
268
|
+
replay = httpx.Response(
|
|
269
|
+
200,
|
|
270
|
+
headers=list(cached[1].headers),
|
|
271
|
+
content=cached[1].content,
|
|
272
|
+
request=response.request,
|
|
273
|
+
)
|
|
274
|
+
return Done(self.call.parse(replay))
|
|
275
|
+
if status >= 400:
|
|
276
|
+
return self._failed(response)
|
|
277
|
+
if self._cache_key is not None and self._core.cache is not None:
|
|
278
|
+
etag = response.headers.get("etag")
|
|
279
|
+
if etag and status == 200:
|
|
280
|
+
self._core.cache.put(self._cache_key, etag, response)
|
|
281
|
+
return Done(self.call.parse(response))
|
|
282
|
+
|
|
283
|
+
def _failed(self, response: httpx.Response) -> Retry:
|
|
284
|
+
policy = self._core.config.retry
|
|
285
|
+
status = response.status_code
|
|
286
|
+
retryable = status in policy.statuses and (
|
|
287
|
+
status != 503 or self.call.repeatable or is_problem(response)
|
|
288
|
+
)
|
|
289
|
+
if retryable and self._attempt < policy.max_retries:
|
|
290
|
+
wait = self._core.delay(
|
|
291
|
+
self._attempt, parse_retry_after(response.headers.get("retry-after"))
|
|
292
|
+
)
|
|
293
|
+
self._attempt += 1
|
|
294
|
+
return Retry(wait)
|
|
295
|
+
raise error_from_response(response)
|
|
296
|
+
|
|
297
|
+
def on_error(self, error: httpx.TransportError) -> Retry:
|
|
298
|
+
"""A retry for a failure that may be repeated; raises `LorebaseConnectionError` else."""
|
|
299
|
+
policy = self._core.config.retry
|
|
300
|
+
never_sent = isinstance(
|
|
301
|
+
error, httpx.ConnectError | httpx.ConnectTimeout | httpx.PoolTimeout
|
|
302
|
+
)
|
|
303
|
+
if (never_sent or self.call.repeatable) and self._attempt < policy.max_retries:
|
|
304
|
+
wait = self._core.delay(self._attempt, None)
|
|
305
|
+
self._attempt += 1
|
|
306
|
+
return Retry(wait)
|
|
307
|
+
text = str(error) or type(error).__name__
|
|
308
|
+
raise LorebaseConnectionError(
|
|
309
|
+
f"cannot reach the server at {self._core.config.base_url}: {text}"
|
|
310
|
+
) from error
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _flatten(params: Mapping[str, Any]) -> list[tuple[str, Any]]:
|
|
314
|
+
out: list[tuple[str, Any]] = []
|
|
315
|
+
for name, value in params.items():
|
|
316
|
+
if isinstance(value, list | tuple):
|
|
317
|
+
out.extend((name, item) for item in value)
|
|
318
|
+
else:
|
|
319
|
+
out.append((name, value))
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _query(params: Mapping[str, Any]) -> list[tuple[str, str]]:
|
|
324
|
+
"""Query string members: unset ones dropped, booleans as `true` and `false`."""
|
|
325
|
+
out: list[tuple[str, str]] = []
|
|
326
|
+
for name, value in _flatten(params):
|
|
327
|
+
if value is None:
|
|
328
|
+
continue
|
|
329
|
+
out.append((name, "true" if value is True else "false" if value is False else str(value)))
|
|
330
|
+
return out
|