beakr-sdk 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.
- beakr_sdk/__init__.py +71 -0
- beakr_sdk/_base_client.py +458 -0
- beakr_sdk/_calls.py +225 -0
- beakr_sdk/_codegen.py +672 -0
- beakr_sdk/_generated/__init__.py +10 -0
- beakr_sdk/_generated/models.py +4389 -0
- beakr_sdk/_generated/operations.py +4764 -0
- beakr_sdk/_http_date.py +83 -0
- beakr_sdk/_multipart.py +40 -0
- beakr_sdk/_pagination.py +41 -0
- beakr_sdk/_paths.py +26 -0
- beakr_sdk/_redact.py +136 -0
- beakr_sdk/_replay.py +89 -0
- beakr_sdk/_resource.py +118 -0
- beakr_sdk/_sse.py +59 -0
- beakr_sdk/_typing.py +115 -0
- beakr_sdk/_version.py +3 -0
- beakr_sdk/answers.py +224 -0
- beakr_sdk/async_client.py +352 -0
- beakr_sdk/capabilities.py +169 -0
- beakr_sdk/client.py +345 -0
- beakr_sdk/credentials.py +1013 -0
- beakr_sdk/errors.py +571 -0
- beakr_sdk/py.typed +0 -0
- beakr_sdk/query_spec.py +182 -0
- beakr_sdk/resources/__init__.py +72 -0
- beakr_sdk/resources/_base.py +9 -0
- beakr_sdk/resources/_destination.py +77 -0
- beakr_sdk/resources/_multipart_upload.py +146 -0
- beakr_sdk/resources/_upload.py +391 -0
- beakr_sdk/resources/agent_review.py +75 -0
- beakr_sdk/resources/bulk_uploads.py +56 -0
- beakr_sdk/resources/conversations.py +228 -0
- beakr_sdk/resources/end_user_tokens.py +75 -0
- beakr_sdk/resources/engine.py +74 -0
- beakr_sdk/resources/external_users.py +106 -0
- beakr_sdk/resources/files.py +537 -0
- beakr_sdk/resources/folders.py +71 -0
- beakr_sdk/resources/integrations.py +97 -0
- beakr_sdk/resources/interviews.py +133 -0
- beakr_sdk/resources/jobs.py +138 -0
- beakr_sdk/resources/keys.py +74 -0
- beakr_sdk/resources/knowledge.py +610 -0
- beakr_sdk/resources/knowledge_pages.py +138 -0
- beakr_sdk/resources/knowledge_readiness.py +192 -0
- beakr_sdk/resources/labels.py +74 -0
- beakr_sdk/resources/me.py +43 -0
- beakr_sdk/resources/members.py +77 -0
- beakr_sdk/resources/model_refusals.py +85 -0
- beakr_sdk/resources/org_admin.py +172 -0
- beakr_sdk/resources/organizations.py +26 -0
- beakr_sdk/resources/projects.py +307 -0
- beakr_sdk/resources/runs.py +226 -0
- beakr_sdk/resources/skills.py +353 -0
- beakr_sdk/resources/uploads.py +82 -0
- beakr_sdk/sources.py +229 -0
- beakr_sdk-0.1.0.dist-info/METADATA +452 -0
- beakr_sdk-0.1.0.dist-info/RECORD +60 -0
- beakr_sdk-0.1.0.dist-info/WHEEL +4 -0
- beakr_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
beakr_sdk/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Beakr Python SDK.
|
|
2
|
+
|
|
3
|
+
Sync::
|
|
4
|
+
|
|
5
|
+
from beakr_sdk import Beakr
|
|
6
|
+
|
|
7
|
+
beakr = Beakr() # BEAKR_API_KEY, else the `beakr auth login` credentials
|
|
8
|
+
project = beakr.projects.create("Research")
|
|
9
|
+
beakr.files.upload("paper.pdf", project_id=project["id"])
|
|
10
|
+
print(beakr.knowledge.sections("main result", scope="Research"))
|
|
11
|
+
|
|
12
|
+
Async: ``AsyncBeakr`` takes the same arguments and has the same methods.
|
|
13
|
+
Errors are typed (:mod:`beakr_sdk.errors`).
|
|
14
|
+
|
|
15
|
+
The clients cover the documented public contract: account and keys,
|
|
16
|
+
organization members and service accounts, external users, labels and
|
|
17
|
+
end-user tokens, projects, files, Knowledge, runs, interviews, conversations
|
|
18
|
+
and integrations. ``beakr.api`` has one generated method per documented
|
|
19
|
+
operation. Modules whose names start with an underscore are private.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from beakr_sdk._version import __version__
|
|
23
|
+
from beakr_sdk.answers import RunFailedError, RunNeedsInputError
|
|
24
|
+
from beakr_sdk.async_client import AsyncBeakr
|
|
25
|
+
from beakr_sdk.capabilities import Capabilities
|
|
26
|
+
from beakr_sdk.client import Beakr
|
|
27
|
+
from beakr_sdk.errors import (
|
|
28
|
+
APIConnectionError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
BeakrError,
|
|
31
|
+
BeakrUploadInterrupted,
|
|
32
|
+
ConflictError,
|
|
33
|
+
KnowledgeCommandError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
NotProvidedError,
|
|
36
|
+
PermissionDeniedError,
|
|
37
|
+
PersonReviewRequiredError,
|
|
38
|
+
RateLimitError,
|
|
39
|
+
ServerError,
|
|
40
|
+
SessionRequiredError,
|
|
41
|
+
UnexpectedResponseError,
|
|
42
|
+
UnsafePathError,
|
|
43
|
+
ValidationError,
|
|
44
|
+
)
|
|
45
|
+
from beakr_sdk.query_spec import QuerySpec
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"APIConnectionError",
|
|
49
|
+
"AsyncBeakr",
|
|
50
|
+
"AuthenticationError",
|
|
51
|
+
"Beakr",
|
|
52
|
+
"BeakrError",
|
|
53
|
+
"BeakrUploadInterrupted",
|
|
54
|
+
"Capabilities",
|
|
55
|
+
"ConflictError",
|
|
56
|
+
"KnowledgeCommandError",
|
|
57
|
+
"NotFoundError",
|
|
58
|
+
"NotProvidedError",
|
|
59
|
+
"PermissionDeniedError",
|
|
60
|
+
"PersonReviewRequiredError",
|
|
61
|
+
"QuerySpec",
|
|
62
|
+
"RateLimitError",
|
|
63
|
+
"RunFailedError",
|
|
64
|
+
"RunNeedsInputError",
|
|
65
|
+
"ServerError",
|
|
66
|
+
"SessionRequiredError",
|
|
67
|
+
"UnexpectedResponseError",
|
|
68
|
+
"UnsafePathError",
|
|
69
|
+
"ValidationError",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Configuration, request building and retry policy shared by both clients.
|
|
2
|
+
|
|
3
|
+
Nothing here performs I/O. :class:`BaseClient` turns a :class:`Call` into an
|
|
4
|
+
``httpx.Request``, decides whether a failure is retried and for how long to
|
|
5
|
+
wait, decodes a response, and owns the credential (including when it needs a
|
|
6
|
+
refresh). The sync and async clients only add the send/sleep loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from beakr_sdk._calls import Call, clean_params, oauth_form_call
|
|
21
|
+
from beakr_sdk._redact import mask
|
|
22
|
+
from beakr_sdk._replay import IDEMPOTENCY_RETRY_CODES
|
|
23
|
+
from beakr_sdk._version import __version__
|
|
24
|
+
from beakr_sdk.credentials import (
|
|
25
|
+
Credential,
|
|
26
|
+
StoredTokens,
|
|
27
|
+
TokenStore,
|
|
28
|
+
normalize_origin,
|
|
29
|
+
resolve_base_url,
|
|
30
|
+
resolve_credential,
|
|
31
|
+
resolve_org_id,
|
|
32
|
+
)
|
|
33
|
+
from beakr_sdk.errors import (
|
|
34
|
+
APIConnectionError,
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
BeakrError,
|
|
37
|
+
RateLimitError,
|
|
38
|
+
ServerError,
|
|
39
|
+
parse_retry_after,
|
|
40
|
+
unexpected_response_error,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from beakr_sdk.resources.bulk_uploads import BulkUploads
|
|
45
|
+
from beakr_sdk.resources.conversations import Conversations
|
|
46
|
+
from beakr_sdk.resources.end_user_tokens import EndUserTokens
|
|
47
|
+
from beakr_sdk.resources.engine import Engine
|
|
48
|
+
from beakr_sdk.resources.external_users import ExternalUsers
|
|
49
|
+
from beakr_sdk.resources.files import Files
|
|
50
|
+
from beakr_sdk.resources.folders import Folders
|
|
51
|
+
from beakr_sdk.resources.integrations import Integrations
|
|
52
|
+
from beakr_sdk.resources.interviews import Interviews
|
|
53
|
+
from beakr_sdk.resources.jobs import Jobs
|
|
54
|
+
from beakr_sdk.resources.keys import Keys
|
|
55
|
+
from beakr_sdk.resources.knowledge import Knowledge
|
|
56
|
+
from beakr_sdk.resources.labels import Labels
|
|
57
|
+
from beakr_sdk.resources.me import Me, Meta
|
|
58
|
+
from beakr_sdk.resources.members import Members
|
|
59
|
+
from beakr_sdk.resources.org_admin import OrgAdmin
|
|
60
|
+
from beakr_sdk.resources.organizations import Organizations
|
|
61
|
+
from beakr_sdk.resources.projects import Projects
|
|
62
|
+
from beakr_sdk.resources.runs import Runs, Threads
|
|
63
|
+
|
|
64
|
+
DEFAULT_TIMEOUT = 60.0
|
|
65
|
+
DEFAULT_MAX_RETRIES = 2
|
|
66
|
+
|
|
67
|
+
#: Statuses where the server did not (or may not have) done the work and the
|
|
68
|
+
#: same request can succeed later. Spec W6.1.3: retries on 429 and 5xx. 501
|
|
69
|
+
#: and 505 are left out: they say the server will never do it.
|
|
70
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
71
|
+
|
|
72
|
+
#: 429 refusals that waiting a few seconds cannot fix: the organization is out of
|
|
73
|
+
#: credits, or a usage quota is spent until its window resets. Returned at once.
|
|
74
|
+
NOT_RETRIED_429_CODES = frozenset({"credits_exhausted", "quota_exceeded"})
|
|
75
|
+
|
|
76
|
+
#: Backoff bounds. The first retry waits about half a second, doubling, never
|
|
77
|
+
#: more than the cap -- unless the server asks for longer with Retry-After.
|
|
78
|
+
_BACKOFF_BASE_SECONDS = 0.5
|
|
79
|
+
_BACKOFF_CAP_SECONDS = 8.0
|
|
80
|
+
#: A Retry-After beyond this is not waited out: the caller gets the 429.
|
|
81
|
+
_MAX_RETRY_AFTER_SECONDS = 60.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _utc_now() -> datetime:
|
|
85
|
+
return datetime.now(timezone.utc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BaseClient:
|
|
89
|
+
"""Shared state and policy. Use :class:`beakr_sdk.Beakr` or ``AsyncBeakr``."""
|
|
90
|
+
|
|
91
|
+
# Attached by ``resources.attach_resources``; declared for type checkers.
|
|
92
|
+
keys: Keys
|
|
93
|
+
me: Me
|
|
94
|
+
meta: Meta
|
|
95
|
+
jobs: Jobs
|
|
96
|
+
organizations: Organizations
|
|
97
|
+
members: Members
|
|
98
|
+
org: OrgAdmin
|
|
99
|
+
external_users: ExternalUsers
|
|
100
|
+
labels: Labels
|
|
101
|
+
end_user_tokens: EndUserTokens
|
|
102
|
+
projects: Projects
|
|
103
|
+
files: Files
|
|
104
|
+
bulk_uploads: BulkUploads
|
|
105
|
+
folders: Folders
|
|
106
|
+
knowledge: Knowledge
|
|
107
|
+
runs: Runs
|
|
108
|
+
interviews: Interviews
|
|
109
|
+
threads: Threads
|
|
110
|
+
conversations: Conversations
|
|
111
|
+
engine: Engine
|
|
112
|
+
integrations: Integrations
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
api_key: str | None = None,
|
|
118
|
+
base_url: str | None = None,
|
|
119
|
+
client_name: str | None = None,
|
|
120
|
+
agent: str | None = None,
|
|
121
|
+
run: str | None = None,
|
|
122
|
+
session: str | None = None,
|
|
123
|
+
org_id: str | None = None,
|
|
124
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
125
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
126
|
+
default_headers: Mapping[str, str] | None = None,
|
|
127
|
+
token_store: TokenStore | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.base_url = resolve_base_url(base_url)
|
|
130
|
+
self.client_name = client_name or f"sdk-python/{__version__}"
|
|
131
|
+
self.agent = agent
|
|
132
|
+
self.run = run
|
|
133
|
+
# Unset means the environment's; "" sends none even when BEAKR_SESSION is set.
|
|
134
|
+
self.session = os.environ.get("BEAKR_SESSION") if session is None else session
|
|
135
|
+
self.org_id = resolve_org_id(org_id)
|
|
136
|
+
self.timeout = timeout
|
|
137
|
+
self.max_retries = max(0, max_retries)
|
|
138
|
+
self._default_headers = dict(default_headers or {})
|
|
139
|
+
self._token_store = token_store or TokenStore()
|
|
140
|
+
self._explicit_api_key = api_key
|
|
141
|
+
self._credential: Credential | None = None
|
|
142
|
+
#: Injectable so tests can pin the clock an HTTP-date ``Retry-After`` is
|
|
143
|
+
#: measured against.
|
|
144
|
+
self._now: Callable[[], datetime] = _utc_now
|
|
145
|
+
|
|
146
|
+
def __repr__(self) -> str:
|
|
147
|
+
# Never resolves the credential (that reads the keychain); shows the one
|
|
148
|
+
# in use, else the explicit key, masked either way.
|
|
149
|
+
credential: object = self._credential
|
|
150
|
+
if credential is None:
|
|
151
|
+
credential = mask(self._explicit_api_key) if self._explicit_api_key else "unresolved"
|
|
152
|
+
return (
|
|
153
|
+
f"{type(self).__name__}(base_url={self.base_url!r}, org_id={self.org_id!r}, "
|
|
154
|
+
f"credential={credential!r})"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# -- credentials -------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def origin(self) -> str:
|
|
161
|
+
"""The normalized API origin this client talks to; stored logins are filed by it."""
|
|
162
|
+
return normalize_origin(self.base_url)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def credential(self) -> Credential:
|
|
166
|
+
"""The resolved credential. Resolution is lazy so unauthenticated calls work.
|
|
167
|
+
|
|
168
|
+
A stored login is offered only when it was issued by this client's
|
|
169
|
+
origin (see :mod:`beakr_sdk.credentials`).
|
|
170
|
+
"""
|
|
171
|
+
if self._credential is None:
|
|
172
|
+
self._credential = resolve_credential(
|
|
173
|
+
self._explicit_api_key, token_store=self._token_store, base_url=self.base_url
|
|
174
|
+
)
|
|
175
|
+
return self._credential
|
|
176
|
+
|
|
177
|
+
# -- refresh coordination ------------------------------------------------
|
|
178
|
+
#
|
|
179
|
+
# A device-flow refresh rotates the pair, and the engine revokes the whole
|
|
180
|
+
# login when a spent refresh token is presented again. Several processes
|
|
181
|
+
# (the CLI, hooks, an SDK script) share one stored login, so a stored-login
|
|
182
|
+
# refresh runs under the cross-process ``RefreshLock``: reload the store,
|
|
183
|
+
# adopt a pair another process already rotated, and only otherwise spend
|
|
184
|
+
# the refresh token. A refresh whose answer is lost is never retried.
|
|
185
|
+
|
|
186
|
+
def _uses_stored_login(self) -> bool:
|
|
187
|
+
return self.credential.source == "cli_tokens"
|
|
188
|
+
|
|
189
|
+
def _adopt_rotated_pair(self) -> bool:
|
|
190
|
+
"""Under the refresh lock: take the stored pair if another process rotated ours."""
|
|
191
|
+
current = self.credential.tokens
|
|
192
|
+
stored = self._token_store.load(self.origin)
|
|
193
|
+
if current is None or stored is None:
|
|
194
|
+
return False
|
|
195
|
+
if (stored.refresh_token, stored.refresh_state) == (
|
|
196
|
+
current.refresh_token,
|
|
197
|
+
current.refresh_state,
|
|
198
|
+
):
|
|
199
|
+
return False
|
|
200
|
+
self.credential.tokens = stored
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
def _still_needs_refresh(self, stale_access_token: str | None) -> bool:
|
|
204
|
+
tokens = self.credential.tokens
|
|
205
|
+
if tokens is None:
|
|
206
|
+
return False
|
|
207
|
+
return tokens.access_token == stale_access_token or tokens.access_expired()
|
|
208
|
+
|
|
209
|
+
def _assert_refreshable(self) -> None:
|
|
210
|
+
tokens = self.credential.tokens
|
|
211
|
+
if tokens is not None and tokens.refresh_state == "uncertain":
|
|
212
|
+
raise AuthenticationError(
|
|
213
|
+
"This sign-in cannot be refreshed safely: an earlier refresh may "
|
|
214
|
+
"have succeeded on the server without its answer arriving. Run "
|
|
215
|
+
"`beakr auth login` to sign in again.",
|
|
216
|
+
code="refresh_uncertain",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _settle_failed_refresh(self, exc: BeakrError) -> None:
|
|
220
|
+
"""Turn a failed refresh into the right error. Always raises.
|
|
221
|
+
|
|
222
|
+
- Not sent (connection refused, connect timeout) or refused before any
|
|
223
|
+
work (429): the refresh token is untouched; raise the original error.
|
|
224
|
+
- Ambiguous (read timeout, dropped connection, 5xx): the server may have
|
|
225
|
+
rotated the pair. Retrying would present a possibly spent token and
|
|
226
|
+
revoke the login, so mark the pair ``uncertain`` (persisted, so other
|
|
227
|
+
processes stop too) and ask for a new sign-in.
|
|
228
|
+
- Refused (4xx): the login is expired or revoked.
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(exc, RateLimitError):
|
|
231
|
+
raise exc
|
|
232
|
+
if isinstance(exc, APIConnectionError) and isinstance(
|
|
233
|
+
exc.__cause__, (httpx.ConnectError, httpx.ConnectTimeout)
|
|
234
|
+
):
|
|
235
|
+
raise exc
|
|
236
|
+
if isinstance(exc, (APIConnectionError, ServerError)):
|
|
237
|
+
tokens = self.credential.tokens
|
|
238
|
+
if tokens is not None:
|
|
239
|
+
tokens.refresh_state = "uncertain"
|
|
240
|
+
if self._uses_stored_login():
|
|
241
|
+
self._token_store.save(tokens)
|
|
242
|
+
raise AuthenticationError(
|
|
243
|
+
"The sign-in refresh did not complete and its outcome is unknown. "
|
|
244
|
+
"Run `beakr auth login` to sign in again.",
|
|
245
|
+
code="refresh_uncertain",
|
|
246
|
+
) from exc
|
|
247
|
+
# The issuer states why in RFC 6749's ``error`` (``invalid_grant``).
|
|
248
|
+
oauth_error = exc.body.get("error") if isinstance(exc.body, dict) else None
|
|
249
|
+
raise AuthenticationError(
|
|
250
|
+
"Your sign-in has expired or was revoked. Run `beakr auth login`.",
|
|
251
|
+
code=oauth_error if isinstance(oauth_error, str) else exc.code or "refresh_failed",
|
|
252
|
+
status=exc.status,
|
|
253
|
+
) from exc
|
|
254
|
+
|
|
255
|
+
def _needs_proactive_refresh(self) -> bool:
|
|
256
|
+
credential = self.credential
|
|
257
|
+
return credential.tokens is not None and credential.tokens.access_expired()
|
|
258
|
+
|
|
259
|
+
def _refresh_call(self) -> Call:
|
|
260
|
+
tokens = self.credential.tokens
|
|
261
|
+
assert tokens is not None, "refresh requested for a credential without tokens"
|
|
262
|
+
if not tokens.token_endpoint:
|
|
263
|
+
# Issued by Beakr's own device flow, which the API no longer serves.
|
|
264
|
+
raise AuthenticationError(
|
|
265
|
+
"This sign-in was made with an older Beakr CLI and cannot be refreshed. "
|
|
266
|
+
"Run `beakr auth login`.",
|
|
267
|
+
code="login_outdated",
|
|
268
|
+
)
|
|
269
|
+
return oauth_form_call(
|
|
270
|
+
tokens.token_endpoint,
|
|
271
|
+
{
|
|
272
|
+
"grant_type": "refresh_token",
|
|
273
|
+
"refresh_token": tokens.refresh_token,
|
|
274
|
+
"client_id": tokens.oauth_client_id or "",
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _apply_refreshed_tokens(self, data: dict[str, Any]) -> None:
|
|
279
|
+
"""Adopt a rotated pair; persist it when it came from the CLI login.
|
|
280
|
+
|
|
281
|
+
Refresh rotates: the old refresh token is dead the moment the server
|
|
282
|
+
answers, so failing to store the new one would log the user out.
|
|
283
|
+
"""
|
|
284
|
+
credential = self.credential
|
|
285
|
+
previous = credential.tokens
|
|
286
|
+
refreshed = StoredTokens.from_token_response(
|
|
287
|
+
data,
|
|
288
|
+
base_url=previous.base_url if previous else self.base_url,
|
|
289
|
+
client_name=previous.client_name if previous else self.client_name,
|
|
290
|
+
)
|
|
291
|
+
if not refreshed.org_id and previous is not None:
|
|
292
|
+
refreshed.org_id = previous.org_id
|
|
293
|
+
if not refreshed.user and previous is not None:
|
|
294
|
+
refreshed.user = previous.user
|
|
295
|
+
if previous is not None:
|
|
296
|
+
refreshed.issuer = previous.issuer
|
|
297
|
+
refreshed.token_endpoint = previous.token_endpoint
|
|
298
|
+
refreshed.oauth_client_id = previous.oauth_client_id
|
|
299
|
+
refreshed.revocation_endpoint = previous.revocation_endpoint
|
|
300
|
+
# An OAuth refresh may not rotate the refresh token.
|
|
301
|
+
if not refreshed.refresh_token:
|
|
302
|
+
refreshed.refresh_token = previous.refresh_token
|
|
303
|
+
credential.tokens = refreshed
|
|
304
|
+
if credential.source == "cli_tokens":
|
|
305
|
+
self._token_store.save(refreshed)
|
|
306
|
+
|
|
307
|
+
# -- request building --------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _headers_for(self, call: Call, idempotency_key: str | None) -> dict[str, str]:
|
|
310
|
+
if call.external:
|
|
311
|
+
return dict(call.headers)
|
|
312
|
+
headers: dict[str, str] = {
|
|
313
|
+
"User-Agent": f"beakr-sdk-python/{__version__}",
|
|
314
|
+
"X-Beakr-Client": self.client_name,
|
|
315
|
+
"Accept": "application/json",
|
|
316
|
+
**self._default_headers,
|
|
317
|
+
}
|
|
318
|
+
if self.agent:
|
|
319
|
+
headers["X-Beakr-Agent"] = self.agent
|
|
320
|
+
if self.run:
|
|
321
|
+
headers["X-Beakr-Run"] = self.run
|
|
322
|
+
if self.session:
|
|
323
|
+
headers["X-Beakr-Session"] = self.session
|
|
324
|
+
if self.org_id:
|
|
325
|
+
headers["X-Org-Id"] = self.org_id
|
|
326
|
+
if call.authenticated:
|
|
327
|
+
headers.update(self.credential.auth_headers())
|
|
328
|
+
if idempotency_key:
|
|
329
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
330
|
+
headers.update(call.headers)
|
|
331
|
+
return headers
|
|
332
|
+
|
|
333
|
+
def _build_request(self, call: Call, idempotency_key: str | None) -> httpx.Request:
|
|
334
|
+
url = call.path if call.external else f"{self.base_url}{call.path}"
|
|
335
|
+
timeout = max(self.timeout, call.min_timeout or 0.0)
|
|
336
|
+
kwargs: dict[str, Any] = {
|
|
337
|
+
"headers": self._headers_for(call, idempotency_key),
|
|
338
|
+
"extensions": {"timeout": httpx.Timeout(timeout).as_dict()},
|
|
339
|
+
}
|
|
340
|
+
# Only when there are some: httpx replaces a URL's own query string with
|
|
341
|
+
# ``params``, even an empty one, and a presigned S3 URL carries its
|
|
342
|
+
# signature in that query string -- passing ``params={}`` stripped it and
|
|
343
|
+
# every upload PUT was refused by S3.
|
|
344
|
+
params = clean_params(call.params or {})
|
|
345
|
+
if params:
|
|
346
|
+
kwargs["params"] = params
|
|
347
|
+
if call.has_json:
|
|
348
|
+
kwargs["json"] = call.json
|
|
349
|
+
elif call.content is not None:
|
|
350
|
+
kwargs["content"] = call.content
|
|
351
|
+
return httpx.Request(call.method.upper(), url, **kwargs)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _new_idempotency_key(call: Call) -> str | None:
|
|
355
|
+
return str(uuid.uuid4()) if call.idempotent else None
|
|
356
|
+
|
|
357
|
+
# -- retry policy ------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
def _retry_delay(
|
|
360
|
+
self,
|
|
361
|
+
call: Call,
|
|
362
|
+
attempt: int,
|
|
363
|
+
response: httpx.Response | None,
|
|
364
|
+
) -> float | None:
|
|
365
|
+
"""Seconds to wait before retrying, or ``None`` to stop.
|
|
366
|
+
|
|
367
|
+
``response is None`` means the request failed in transport. A 429 is
|
|
368
|
+
retried (the server refused before doing work) unless its code says no
|
|
369
|
+
retry soon can succeed (:data:`NOT_RETRIED_429_CODES`). A 5xx or a
|
|
370
|
+
transport failure is retried only when resending cannot duplicate an
|
|
371
|
+
effect: an idempotent HTTP method, a create the engine replays on its
|
|
372
|
+
``Idempotency-Key`` (``beakr_sdk._replay``; the same key is resent), or
|
|
373
|
+
a route that deduplicates resends itself. A keyed create's 409
|
|
374
|
+
``idempotency_in_progress``/``idempotency_superseded`` (an earlier
|
|
375
|
+
attempt still holds the key) is waited out the same way.
|
|
376
|
+
"""
|
|
377
|
+
if attempt >= self.max_retries:
|
|
378
|
+
return None
|
|
379
|
+
if response is not None:
|
|
380
|
+
if not self._retryable_response(call, response):
|
|
381
|
+
return None
|
|
382
|
+
hinted = parse_retry_after(response.headers.get("retry-after"), now=self._now())
|
|
383
|
+
if hinted is not None:
|
|
384
|
+
return hinted if hinted <= _MAX_RETRY_AFTER_SECONDS else None
|
|
385
|
+
elif not call.retry_safe:
|
|
386
|
+
return None
|
|
387
|
+
backoff = min(_BACKOFF_CAP_SECONDS, _BACKOFF_BASE_SECONDS * (2**attempt))
|
|
388
|
+
# Full jitter keeps many clients that failed together from retrying together.
|
|
389
|
+
return backoff * (0.5 + random.random() / 2)
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _retryable_response(call: Call, response: httpx.Response) -> bool:
|
|
393
|
+
status = response.status_code
|
|
394
|
+
if status == 429:
|
|
395
|
+
return _error_code(response) not in NOT_RETRIED_429_CODES
|
|
396
|
+
if status in RETRY_STATUSES:
|
|
397
|
+
return call.retry_safe
|
|
398
|
+
if status == 409 and call.replays_key:
|
|
399
|
+
return _error_code(response) in IDEMPOTENCY_RETRY_CODES
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# -- response decoding ---------------------------------------------------
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _decode(call: Call, response: httpx.Response) -> Any:
|
|
406
|
+
if call.response == "headers":
|
|
407
|
+
return {key.lower(): value for key, value in response.headers.items()}
|
|
408
|
+
if call.response == "none" or response.status_code == 204:
|
|
409
|
+
return None
|
|
410
|
+
if call.response == "bytes":
|
|
411
|
+
return response.content
|
|
412
|
+
if call.response == "text":
|
|
413
|
+
return response.text
|
|
414
|
+
if not response.content:
|
|
415
|
+
return None
|
|
416
|
+
# A JSON-typed call that receives a file must not be decoded as JSON:
|
|
417
|
+
# return the bytes rather than raising or corrupting them.
|
|
418
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
419
|
+
if content_type and not _is_textual(content_type):
|
|
420
|
+
return response.content
|
|
421
|
+
try:
|
|
422
|
+
return response.json()
|
|
423
|
+
except ValueError:
|
|
424
|
+
# An HTML page answering 200 for any path: the base URL is not a Beakr API.
|
|
425
|
+
raise unexpected_response_error(response) from None
|
|
426
|
+
|
|
427
|
+
def _auth_retry_allowed(self, call: Call, response: httpx.Response, refreshed: bool) -> bool:
|
|
428
|
+
"""A 401 on an access token that can be refreshed gets one refresh and resend."""
|
|
429
|
+
return (
|
|
430
|
+
response.status_code == 401
|
|
431
|
+
and call.authenticated
|
|
432
|
+
and not call.external
|
|
433
|
+
and not refreshed
|
|
434
|
+
and self.credential.can_refresh
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _error_code(response: httpx.Response) -> str | None:
|
|
439
|
+
"""The error code of a refusal: the envelope's, else a structured ``detail``'s."""
|
|
440
|
+
try:
|
|
441
|
+
body = response.json()
|
|
442
|
+
except ValueError:
|
|
443
|
+
return None
|
|
444
|
+
if not isinstance(body, dict):
|
|
445
|
+
return None
|
|
446
|
+
for holder in (body.get("error"), body.get("detail")):
|
|
447
|
+
if isinstance(holder, dict) and isinstance(holder.get("code"), str):
|
|
448
|
+
return holder["code"]
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _is_textual(content_type: str) -> bool:
|
|
453
|
+
return (
|
|
454
|
+
"json" in content_type
|
|
455
|
+
or content_type.startswith("text/")
|
|
456
|
+
or "xml" in content_type
|
|
457
|
+
or "x-www-form-urlencoded" in content_type
|
|
458
|
+
)
|