authgebra 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.
- authgebra/__init__.py +92 -0
- authgebra/_core.py +477 -0
- authgebra/_generated/__init__.py +2 -0
- authgebra/_generated/aio.py +1535 -0
- authgebra/_generated/codes.py +29 -0
- authgebra/_generated/models.py +4041 -0
- authgebra/_generated/operations.py +108 -0
- authgebra/_generated/sync.py +1535 -0
- authgebra/_model.py +252 -0
- authgebra/_parse.py +158 -0
- authgebra/_version.py +3 -0
- authgebra/aclient.py +256 -0
- authgebra/client.py +272 -0
- authgebra/config.py +79 -0
- authgebra/errors.py +177 -0
- authgebra/models.py +263 -0
- authgebra/py.typed +0 -0
- authgebra-0.1.0.dist-info/METADATA +181 -0
- authgebra-0.1.0.dist-info/RECORD +22 -0
- authgebra-0.1.0.dist-info/WHEEL +5 -0
- authgebra-0.1.0.dist-info/licenses/LICENSE +42 -0
- authgebra-0.1.0.dist-info/top_level.txt +1 -0
authgebra/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Official Python client for the Authgebra ``/api/v1`` HTTP API.
|
|
2
|
+
|
|
3
|
+
A thin wrapper: it calls the server and interprets responses; it never evaluates
|
|
4
|
+
RBAC/ABAC/ReBAC itself. See ``README.md`` for semantics (results vs errors, retries, tracing).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._core import new_idempotency_key, new_traceparent, parse_traceparent
|
|
8
|
+
from ._generated.operations import OPERATIONS, SPEC_SHA256
|
|
9
|
+
from ._generated import models as api_models
|
|
10
|
+
from ._model import JSONValue, UNSET, Unset
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .aclient import AsyncAuthorizationClient, AsyncClient
|
|
13
|
+
from .client import AuthorizationClient, Client
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .errors import (
|
|
16
|
+
ApiError,
|
|
17
|
+
AuthgebraError,
|
|
18
|
+
CommitState,
|
|
19
|
+
CursorStaleError,
|
|
20
|
+
NotReadyError,
|
|
21
|
+
PaginationError,
|
|
22
|
+
ProtocolError,
|
|
23
|
+
RateLimitedError,
|
|
24
|
+
ReloadPendingError,
|
|
25
|
+
TransportError,
|
|
26
|
+
Violation,
|
|
27
|
+
)
|
|
28
|
+
from .models import (
|
|
29
|
+
ApiResult,
|
|
30
|
+
BatchCheckRequest,
|
|
31
|
+
BatchCheckResult,
|
|
32
|
+
BatchDecision,
|
|
33
|
+
CheckRequest,
|
|
34
|
+
CheckResult,
|
|
35
|
+
Decision,
|
|
36
|
+
ExplainRequest,
|
|
37
|
+
ExplainResult,
|
|
38
|
+
ListResourcesRequest,
|
|
39
|
+
Meta,
|
|
40
|
+
PageInfo,
|
|
41
|
+
ResourcePage,
|
|
42
|
+
ResourceRef,
|
|
43
|
+
ResponseInfo,
|
|
44
|
+
RetryPolicy,
|
|
45
|
+
RevisionResult,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"api_models",
|
|
50
|
+
"JSONValue",
|
|
51
|
+
"UNSET",
|
|
52
|
+
"Unset",
|
|
53
|
+
"OPERATIONS",
|
|
54
|
+
"SPEC_SHA256",
|
|
55
|
+
"ApiError",
|
|
56
|
+
"ApiResult",
|
|
57
|
+
"AsyncAuthorizationClient",
|
|
58
|
+
"AsyncClient",
|
|
59
|
+
"AuthgebraError",
|
|
60
|
+
"AuthorizationClient",
|
|
61
|
+
"BatchCheckRequest",
|
|
62
|
+
"BatchCheckResult",
|
|
63
|
+
"BatchDecision",
|
|
64
|
+
"CheckRequest",
|
|
65
|
+
"CheckResult",
|
|
66
|
+
"Client",
|
|
67
|
+
"CommitState",
|
|
68
|
+
"Config",
|
|
69
|
+
"CursorStaleError",
|
|
70
|
+
"Decision",
|
|
71
|
+
"ExplainRequest",
|
|
72
|
+
"ExplainResult",
|
|
73
|
+
"ListResourcesRequest",
|
|
74
|
+
"Meta",
|
|
75
|
+
"NotReadyError",
|
|
76
|
+
"PageInfo",
|
|
77
|
+
"PaginationError",
|
|
78
|
+
"ProtocolError",
|
|
79
|
+
"RateLimitedError",
|
|
80
|
+
"ReloadPendingError",
|
|
81
|
+
"ResourcePage",
|
|
82
|
+
"ResourceRef",
|
|
83
|
+
"ResponseInfo",
|
|
84
|
+
"RetryPolicy",
|
|
85
|
+
"RevisionResult",
|
|
86
|
+
"TransportError",
|
|
87
|
+
"Violation",
|
|
88
|
+
"__version__",
|
|
89
|
+
"new_idempotency_key",
|
|
90
|
+
"new_traceparent",
|
|
91
|
+
"parse_traceparent",
|
|
92
|
+
]
|
authgebra/_core.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Transport-independent protocol core shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import ssl
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Mapping, Protocol
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._generated.codes import KNOWN_ERROR_CODES
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .errors import (
|
|
20
|
+
API_ERROR_CLASSES,
|
|
21
|
+
ApiError,
|
|
22
|
+
AuthgebraError,
|
|
23
|
+
CommitState,
|
|
24
|
+
ProtocolError,
|
|
25
|
+
TransportError,
|
|
26
|
+
Violation,
|
|
27
|
+
)
|
|
28
|
+
from .models import ApiResult, Meta, PageInfo, ResponseInfo, RetryPolicy
|
|
29
|
+
|
|
30
|
+
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,128}$")
|
|
31
|
+
TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
32
|
+
IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
33
|
+
_HEX = re.compile(r"^[0-9a-f]+$")
|
|
34
|
+
|
|
35
|
+
RETRYABLE_CODES = frozenset({"RATE_LIMITED", "NOT_READY", "IDEMPOTENCY_IN_PROGRESS"})
|
|
36
|
+
_EXCERPT = 1024
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- ids / tracing
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def valid_request_id(value: str | None) -> str | None:
|
|
43
|
+
return value if value is not None and REQUEST_ID_RE.fullmatch(value) else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def valid_trace_id(value: str | None) -> str | None:
|
|
47
|
+
if value is not None and TRACE_ID_RE.fullmatch(value) and value != "0" * 32:
|
|
48
|
+
return value
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def new_request_id() -> str:
|
|
53
|
+
return "py-" + uuid.uuid4().hex
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_traceparent(value: str) -> tuple[str, str, int] | None:
|
|
57
|
+
"""Strict W3C parse; ``None`` for anything invalid (uppercase, all-zero, ``ff`` ...)."""
|
|
58
|
+
if len(value) < 55 or value[2] != "-" or value[35] != "-" or value[52] != "-":
|
|
59
|
+
return None
|
|
60
|
+
version, trace, span, flags = value[0:2], value[3:35], value[36:52], value[53:55]
|
|
61
|
+
if not (_HEX.fullmatch(version) and _HEX.fullmatch(trace) and _HEX.fullmatch(span)):
|
|
62
|
+
return None
|
|
63
|
+
if not _HEX.fullmatch(flags) or version == "ff":
|
|
64
|
+
return None
|
|
65
|
+
if version == "00" and len(value) != 55:
|
|
66
|
+
return None
|
|
67
|
+
if version != "00" and len(value) > 55 and value[55] != "-":
|
|
68
|
+
return None
|
|
69
|
+
if trace == "0" * 32 or span == "0" * 16:
|
|
70
|
+
return None
|
|
71
|
+
return trace, span, int(flags, 16)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def new_traceparent(trace_id: str | None = None, sampled: bool = False) -> str:
|
|
75
|
+
trace = trace_id or secrets.token_hex(16)
|
|
76
|
+
while trace == "0" * 32: # pragma: no cover - 2**-128
|
|
77
|
+
trace = secrets.token_hex(16)
|
|
78
|
+
span = secrets.token_hex(8)
|
|
79
|
+
while span == "0" * 16: # pragma: no cover - 2**-64
|
|
80
|
+
span = secrets.token_hex(8)
|
|
81
|
+
return f"00-{trace}-{span}-{'01' if sampled else '00'}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def child_traceparent(traceparent: str) -> str:
|
|
85
|
+
"""Same trace, new span id (each attempt is its own span)."""
|
|
86
|
+
parsed = parse_traceparent(traceparent)
|
|
87
|
+
if parsed is None:
|
|
88
|
+
return new_traceparent()
|
|
89
|
+
return new_traceparent(parsed[0], bool(parsed[2] & 1))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_traceparent(config: Config, explicit: str | None) -> str | None:
|
|
93
|
+
for candidate in (explicit, config.trace_provider() if config.trace_provider else None):
|
|
94
|
+
if candidate is not None and parse_traceparent(candidate) is not None:
|
|
95
|
+
return candidate
|
|
96
|
+
return new_traceparent() if config.auto_traceparent else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_idempotency_key(key: str | None) -> str | None:
|
|
100
|
+
if key is None:
|
|
101
|
+
return None
|
|
102
|
+
stripped = key.strip()
|
|
103
|
+
if not IDEMPOTENCY_KEY_RE.fullmatch(stripped):
|
|
104
|
+
raise ValueError("Idempotency-Key must be 1-128 chars of [A-Za-z0-9._-]")
|
|
105
|
+
return stripped
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def new_idempotency_key() -> str:
|
|
109
|
+
"""Convenience: a fresh, valid idempotency key. Reuse it for every resend of the same write."""
|
|
110
|
+
return uuid.uuid4().hex
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- request building
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def path_with(template: str, **params: object) -> str:
|
|
117
|
+
"""Fill ``{name}`` segments with percent-encoded values."""
|
|
118
|
+
out = template
|
|
119
|
+
for name, value in params.items():
|
|
120
|
+
out = out.replace("{" + name + "}", quote(str(value), safe=""))
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def serialize_json(body: Any) -> bytes:
|
|
125
|
+
"""Compact UTF-8 JSON; non-ASCII is written as-is (no ``\\u`` escapes), NaN is rejected."""
|
|
126
|
+
return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
QueryPairs = list[tuple[str, str | int | float | bool | None]]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def encode_query(query: Mapping[str, object] | None) -> QueryPairs:
|
|
133
|
+
pairs: QueryPairs = []
|
|
134
|
+
for name, value in (query or {}).items():
|
|
135
|
+
if value is None:
|
|
136
|
+
continue
|
|
137
|
+
if isinstance(value, bool):
|
|
138
|
+
pairs.append((name, "true" if value else "false"))
|
|
139
|
+
else:
|
|
140
|
+
pairs.append((name, str(value)))
|
|
141
|
+
return pairs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Prepared:
|
|
146
|
+
method: str
|
|
147
|
+
url: str
|
|
148
|
+
params: QueryPairs
|
|
149
|
+
content: bytes | None
|
|
150
|
+
headers: dict[str, str]
|
|
151
|
+
safe: bool
|
|
152
|
+
has_key: bool
|
|
153
|
+
request_id: str | None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def prepare(
|
|
157
|
+
config: Config,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
query: Mapping[str, object] | None,
|
|
162
|
+
body: bytes | None,
|
|
163
|
+
token: str | None,
|
|
164
|
+
if_match: str | None,
|
|
165
|
+
idempotency_key: str | None,
|
|
166
|
+
request_id: str | None,
|
|
167
|
+
traceparent: str | None,
|
|
168
|
+
safe: bool,
|
|
169
|
+
) -> Prepared:
|
|
170
|
+
from ._version import __version__
|
|
171
|
+
|
|
172
|
+
headers: dict[str, str] = {name: value for name, value in config.headers.items()}
|
|
173
|
+
ua = f"authgebra-python/{__version__}"
|
|
174
|
+
if config.user_agent_suffix:
|
|
175
|
+
ua += f" {config.user_agent_suffix}"
|
|
176
|
+
headers["User-Agent"] = ua
|
|
177
|
+
headers["Accept"] = "application/json"
|
|
178
|
+
if body is not None:
|
|
179
|
+
headers["Content-Type"] = "application/json"
|
|
180
|
+
if token:
|
|
181
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
182
|
+
if if_match is not None:
|
|
183
|
+
headers["If-Match"] = if_match
|
|
184
|
+
key = validate_idempotency_key(idempotency_key)
|
|
185
|
+
if key is not None:
|
|
186
|
+
headers["Idempotency-Key"] = key
|
|
187
|
+
sent_request_id = valid_request_id(request_id)
|
|
188
|
+
if sent_request_id is None and config.auto_request_id:
|
|
189
|
+
sent_request_id = new_request_id()
|
|
190
|
+
if sent_request_id is not None:
|
|
191
|
+
headers["X-Request-ID"] = sent_request_id
|
|
192
|
+
if traceparent is not None:
|
|
193
|
+
headers["traceparent"] = traceparent
|
|
194
|
+
return Prepared(
|
|
195
|
+
method=method.upper(),
|
|
196
|
+
url=config.base_url.rstrip("/") + path,
|
|
197
|
+
params=encode_query(query),
|
|
198
|
+
content=body,
|
|
199
|
+
headers=headers,
|
|
200
|
+
safe=safe,
|
|
201
|
+
has_key=key is not None,
|
|
202
|
+
request_id=sent_request_id,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------- transport errors
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ssl_in_chain(exc: BaseException | None) -> bool:
|
|
210
|
+
seen = 0
|
|
211
|
+
while exc is not None and seen < 8:
|
|
212
|
+
if isinstance(exc, ssl.SSLError):
|
|
213
|
+
return True
|
|
214
|
+
exc = exc.__cause__ or exc.__context__
|
|
215
|
+
seen += 1
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def transport_error(exc: BaseException, prep: Prepared) -> TransportError:
|
|
220
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
221
|
+
kind = "timeout"
|
|
222
|
+
elif isinstance(exc, httpx.ConnectError):
|
|
223
|
+
kind = "tls_error" if _ssl_in_chain(exc) else "connect_error"
|
|
224
|
+
elif isinstance(exc, (httpx.ReadError, httpx.WriteError, httpx.RemoteProtocolError, httpx.CloseError)):
|
|
225
|
+
kind = "connection_reset"
|
|
226
|
+
else:
|
|
227
|
+
kind = "transport"
|
|
228
|
+
return build_transport_error(kind, prep, type(exc).__name__)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def build_transport_error(kind: str, prep: Prepared, detail: str = "") -> TransportError:
|
|
232
|
+
# A failed write may or may not have been applied: never claim "not committed".
|
|
233
|
+
state = None if prep.safe else CommitState.UNKNOWN
|
|
234
|
+
return TransportError(kind, commit_state=state, request_id=prep.request_id, detail=detail)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --------------------------------------------------------------------------- response parsing
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class RawResponse:
|
|
242
|
+
status: int
|
|
243
|
+
headers: Mapping[str, str] # lower-cased names
|
|
244
|
+
body: bytes
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _reject_constant(name: str) -> Any:
|
|
248
|
+
raise ValueError(f"non-finite JSON number {name}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _excerpt(body: bytes) -> str:
|
|
252
|
+
return body[:_EXCERPT].decode("utf-8", errors="replace")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _correlation(headers: Mapping[str, str]) -> tuple[str | None, str | None]:
|
|
256
|
+
header_rid = valid_request_id(headers.get("x-request-id"))
|
|
257
|
+
trace = valid_trace_id(headers.get("x-trace-id"))
|
|
258
|
+
if trace is None:
|
|
259
|
+
tp = headers.get("traceparent")
|
|
260
|
+
parsed = parse_traceparent(tp) if tp else None
|
|
261
|
+
trace = parsed[0] if parsed else None
|
|
262
|
+
return header_rid, trace
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_meta(value: Any, raw: RawResponse, rid: str | None, trace: str | None) -> Meta | None:
|
|
266
|
+
if value is None:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def bad() -> ProtocolError:
|
|
270
|
+
return ProtocolError(
|
|
271
|
+
"malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
|
|
272
|
+
excerpt=_excerpt(raw.body),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if not isinstance(value, dict):
|
|
276
|
+
raise bad()
|
|
277
|
+
revision = value.get("authz_revision")
|
|
278
|
+
generation = value.get("snapshot_generation")
|
|
279
|
+
if isinstance(revision, bool) or not isinstance(revision, int):
|
|
280
|
+
raise bad()
|
|
281
|
+
if generation is not None and (isinstance(generation, bool) or not isinstance(generation, int)):
|
|
282
|
+
raise bad()
|
|
283
|
+
return Meta(authz_revision=revision, snapshot_generation=generation)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_page(value: Any, raw: RawResponse, rid: str | None, trace: str | None) -> PageInfo | None:
|
|
287
|
+
if value is None:
|
|
288
|
+
return None
|
|
289
|
+
if (
|
|
290
|
+
isinstance(value, dict)
|
|
291
|
+
and isinstance(value.get("has_more"), bool)
|
|
292
|
+
and (value.get("next_cursor") is None or isinstance(value.get("next_cursor"), str))
|
|
293
|
+
):
|
|
294
|
+
return PageInfo(next_cursor=value.get("next_cursor"), has_more=value["has_more"])
|
|
295
|
+
raise ProtocolError(
|
|
296
|
+
"malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
|
|
297
|
+
excerpt=_excerpt(raw.body),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _load(raw: RawResponse, rid: str | None, trace: str | None) -> Any:
|
|
302
|
+
try:
|
|
303
|
+
return json.loads(raw.body.decode("utf-8"), parse_constant=_reject_constant)
|
|
304
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
305
|
+
raise ProtocolError(
|
|
306
|
+
"non_json_response", status=raw.status, request_id=rid, trace_id=trace,
|
|
307
|
+
excerpt=_excerpt(raw.body),
|
|
308
|
+
) from exc
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def interpret(raw: RawResponse, *, envelope: bool = True) -> ApiResult:
|
|
312
|
+
"""Turn one HTTP response into a result, or raise ``ApiError`` / ``ProtocolError``."""
|
|
313
|
+
header_rid, header_trace = _correlation(raw.headers)
|
|
314
|
+
if 200 <= raw.status < 300:
|
|
315
|
+
return _success(raw, envelope, header_rid, header_trace)
|
|
316
|
+
raise _error(raw, header_rid, header_trace)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _success(raw: RawResponse, envelope: bool, rid: str | None, trace: str | None) -> ApiResult:
|
|
320
|
+
info = ResponseInfo(status=raw.status, request_id=rid, trace_id=trace)
|
|
321
|
+
etag = raw.headers.get("etag")
|
|
322
|
+
replayed = raw.headers.get("idempotency-replayed", "").lower() == "true"
|
|
323
|
+
revision_header: int | None = None
|
|
324
|
+
header_value = raw.headers.get("x-authz-revision")
|
|
325
|
+
if header_value is not None and header_value.isdigit():
|
|
326
|
+
revision_header = int(header_value)
|
|
327
|
+
if raw.status == 204 and not raw.body:
|
|
328
|
+
return ApiResult(None, info, None, None, etag, replayed, revision_header)
|
|
329
|
+
payload = _load(raw, rid, trace)
|
|
330
|
+
if not envelope:
|
|
331
|
+
return ApiResult(payload, info, None, None, etag, replayed, revision_header)
|
|
332
|
+
if not isinstance(payload, dict) or "data" not in payload:
|
|
333
|
+
raise ProtocolError(
|
|
334
|
+
"malformed_success_body", status=raw.status, request_id=rid, trace_id=trace,
|
|
335
|
+
excerpt=_excerpt(raw.body),
|
|
336
|
+
)
|
|
337
|
+
return ApiResult(
|
|
338
|
+
data=payload["data"],
|
|
339
|
+
info=info,
|
|
340
|
+
meta=_parse_meta(payload.get("meta"), raw, rid, trace),
|
|
341
|
+
page=_parse_page(payload.get("page"), raw, rid, trace),
|
|
342
|
+
etag=etag,
|
|
343
|
+
replayed=replayed,
|
|
344
|
+
authz_revision_header=revision_header,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _error(raw: RawResponse, header_rid: str | None, header_trace: str | None) -> AuthgebraError:
|
|
349
|
+
payload = _load(raw, header_rid, header_trace)
|
|
350
|
+
err = payload.get("error") if isinstance(payload, dict) else None
|
|
351
|
+
code = err.get("code") if isinstance(err, dict) else None
|
|
352
|
+
if not isinstance(err, dict) or not isinstance(code, str) or not code:
|
|
353
|
+
return ProtocolError(
|
|
354
|
+
"malformed_error_body", status=raw.status, request_id=header_rid,
|
|
355
|
+
trace_id=header_trace, excerpt=_excerpt(raw.body),
|
|
356
|
+
)
|
|
357
|
+
raw_message = err.get("message")
|
|
358
|
+
message: str = raw_message if isinstance(raw_message, str) else ""
|
|
359
|
+
body_rid = valid_request_id(err.get("request_id") if isinstance(err.get("request_id"), str) else None)
|
|
360
|
+
request_id = header_rid or body_rid
|
|
361
|
+
mismatch = header_rid is not None and body_rid is not None and header_rid != body_rid
|
|
362
|
+
trace_value = err.get("trace_id")
|
|
363
|
+
trace = valid_trace_id(trace_value if isinstance(trace_value, str) else None) or header_trace
|
|
364
|
+
|
|
365
|
+
details = err.get("details") if isinstance(err.get("details"), dict) else None
|
|
366
|
+
violations: list[Violation] = []
|
|
367
|
+
if details is not None and isinstance(details.get("violations"), list):
|
|
368
|
+
for item in details["violations"]:
|
|
369
|
+
if isinstance(item, dict) and isinstance(item.get("reason"), str):
|
|
370
|
+
path = item.get("path")
|
|
371
|
+
text = item.get("message")
|
|
372
|
+
violations.append(
|
|
373
|
+
Violation(
|
|
374
|
+
path=path if isinstance(path, str) else None,
|
|
375
|
+
reason=item["reason"],
|
|
376
|
+
message=text if isinstance(text, str) else "",
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
retryable = err.get("retryable") if isinstance(err.get("retryable"), bool) else None
|
|
381
|
+
retry_after: int | None = None
|
|
382
|
+
header_retry = raw.headers.get("retry-after", "").strip()
|
|
383
|
+
body_retry = err.get("retry_after_seconds")
|
|
384
|
+
if header_retry.isdigit():
|
|
385
|
+
retry_after = int(header_retry)
|
|
386
|
+
elif isinstance(body_retry, int) and not isinstance(body_retry, bool) and body_retry >= 0:
|
|
387
|
+
retry_after = body_retry
|
|
388
|
+
|
|
389
|
+
commit: CommitState | None = None
|
|
390
|
+
commit_value = err.get("commit_state")
|
|
391
|
+
if isinstance(commit_value, str):
|
|
392
|
+
try:
|
|
393
|
+
commit = CommitState(commit_value)
|
|
394
|
+
except ValueError:
|
|
395
|
+
commit = CommitState.UNKNOWN
|
|
396
|
+
|
|
397
|
+
cls = API_ERROR_CLASSES.get(code, ApiError)
|
|
398
|
+
return cls(
|
|
399
|
+
status=raw.status,
|
|
400
|
+
code=code,
|
|
401
|
+
known_code=code in KNOWN_ERROR_CODES,
|
|
402
|
+
message=message,
|
|
403
|
+
request_id=request_id,
|
|
404
|
+
body_request_id=body_rid,
|
|
405
|
+
request_id_mismatch=mismatch,
|
|
406
|
+
request_id_header_valid=header_rid is not None,
|
|
407
|
+
trace_id=trace,
|
|
408
|
+
details=details,
|
|
409
|
+
violations=tuple(violations),
|
|
410
|
+
retryable=retryable,
|
|
411
|
+
retry_after_seconds=retry_after,
|
|
412
|
+
commit_state=commit,
|
|
413
|
+
raw_body_excerpt=_excerpt(raw.body),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# --------------------------------------------------------------------------- retry policy
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def should_retry(
|
|
421
|
+
policy: RetryPolicy, *, safe: bool, has_key: bool, retries_done: int, error: BaseException
|
|
422
|
+
) -> bool:
|
|
423
|
+
if retries_done >= policy.max_retries:
|
|
424
|
+
return False
|
|
425
|
+
if not safe and not (policy.retry_writes and has_key):
|
|
426
|
+
return False
|
|
427
|
+
if isinstance(error, ApiError):
|
|
428
|
+
if error.commit_state is CommitState.COMMITTED_UNPUBLISHED:
|
|
429
|
+
return False
|
|
430
|
+
return error.code in RETRYABLE_CODES
|
|
431
|
+
if isinstance(error, TransportError):
|
|
432
|
+
return error.error != "tls_error"
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def retry_delay(policy: RetryPolicy, retries_done: int, error: BaseException) -> float:
|
|
437
|
+
if isinstance(error, ApiError) and error.retry_after_seconds is not None:
|
|
438
|
+
return float(min(float(error.retry_after_seconds), policy.max_delay))
|
|
439
|
+
base = min(policy.base_delay * (2**retries_done), policy.max_delay)
|
|
440
|
+
return float(min(base * (1.0 + policy.jitter * random.random()), policy.max_delay))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# --------------------------------------------------------------------------- requester protocols
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class SyncRequester(Protocol):
|
|
447
|
+
def request(
|
|
448
|
+
self,
|
|
449
|
+
method: str,
|
|
450
|
+
path: str,
|
|
451
|
+
*,
|
|
452
|
+
query: Mapping[str, object] | None = None,
|
|
453
|
+
json: Any = None,
|
|
454
|
+
if_match: str | None = None,
|
|
455
|
+
idempotency_key: str | None = None,
|
|
456
|
+
request_id: str | None = None,
|
|
457
|
+
traceparent: str | None = None,
|
|
458
|
+
safe: bool | None = None,
|
|
459
|
+
envelope: bool = True,
|
|
460
|
+
) -> ApiResult: ...
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class AsyncRequester(Protocol):
|
|
464
|
+
async def request(
|
|
465
|
+
self,
|
|
466
|
+
method: str,
|
|
467
|
+
path: str,
|
|
468
|
+
*,
|
|
469
|
+
query: Mapping[str, object] | None = None,
|
|
470
|
+
json: Any = None,
|
|
471
|
+
if_match: str | None = None,
|
|
472
|
+
idempotency_key: str | None = None,
|
|
473
|
+
request_id: str | None = None,
|
|
474
|
+
traceparent: str | None = None,
|
|
475
|
+
safe: bool | None = None,
|
|
476
|
+
envelope: bool = True,
|
|
477
|
+
) -> ApiResult: ...
|