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/_model.py ADDED
@@ -0,0 +1,252 @@
1
+ """JSON model codec shared by generated synchronous and asynchronous operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import uuid
8
+ from collections.abc import Iterator, Mapping
9
+ from dataclasses import fields
10
+ from enum import Enum
11
+ from typing import ClassVar, TypeVar
12
+
13
+ from .errors import ProtocolError
14
+ from .models import ApiResult, JSONValue as JSONValue
15
+
16
+
17
+ class Unset(Enum):
18
+ """A field absent from JSON; distinct from explicit JSON null."""
19
+
20
+ TOKEN = 0
21
+
22
+
23
+ UNSET = Unset.TOKEN
24
+
25
+
26
+ class StringEnum(str, Enum):
27
+ """Named wire enum that preserves a newer server's unknown string values."""
28
+
29
+ @classmethod
30
+ def _missing_(cls, value: object) -> StringEnum | None:
31
+ if not isinstance(value, str):
32
+ return None
33
+ member = str.__new__(cls, value)
34
+ member._name_ = "UNKNOWN"
35
+ member._value_ = value
36
+ return member
37
+
38
+
39
+ class Model(Mapping[str, object]):
40
+ """A generated named object; mapping access preserves existing dict consumers."""
41
+
42
+ __schema_pointer__: ClassVar[str]
43
+ __json_fields__: ClassVar[dict[str, str]]
44
+ __request_model__: ClassVar[bool] = False
45
+
46
+ def __getitem__(self, key: str) -> object:
47
+ name = self.__json_fields__.get(key)
48
+ if name is None:
49
+ raise KeyError(key)
50
+ value: object = getattr(self, name)
51
+ if isinstance(value, Unset):
52
+ raise KeyError(key)
53
+ return value
54
+
55
+ def __iter__(self) -> Iterator[str]:
56
+ return (key for key, name in self.__json_fields__.items() if not isinstance(getattr(self, name), Unset))
57
+
58
+ def __len__(self) -> int:
59
+ return sum(1 for _ in self)
60
+
61
+ def to_json(self) -> dict[str, JSONValue]:
62
+ return {key: json_value(self[key]) for key in self}
63
+
64
+ @classmethod
65
+ def from_json(cls: type[M], value: object) -> M:
66
+ parsed = decode(cls.__schema_pointer__, value, request=cls.__request_model__)
67
+ if not isinstance(parsed, cls):
68
+ raise ValueError(f"expected {cls.__name__}")
69
+ return parsed
70
+
71
+
72
+ M = TypeVar("M", bound=Model)
73
+ R = TypeVar("R", bound=ApiResult)
74
+
75
+
76
+ def json_value(value: object) -> JSONValue:
77
+ """Convert only JSON values and named models, without a lossy floating-point hop."""
78
+ if isinstance(value, Unset):
79
+ raise ValueError("UNSET is only valid as an omitted object field")
80
+ if isinstance(value, Enum):
81
+ return json_value(value.value)
82
+ if isinstance(value, Model):
83
+ return value.to_json()
84
+ if value is None or isinstance(value, (str, bool, int)):
85
+ return value
86
+ if isinstance(value, float):
87
+ if not math.isfinite(value):
88
+ raise ValueError("JSON numbers must be finite")
89
+ return value
90
+ if isinstance(value, Mapping):
91
+ result: dict[str, JSONValue] = {}
92
+ for key, item in value.items():
93
+ if not isinstance(key, str):
94
+ raise ValueError("JSON object keys must be strings")
95
+ result[key] = json_value(item)
96
+ return result
97
+ if isinstance(value, (list, tuple)):
98
+ return [json_value(item) for item in value]
99
+ raise ValueError(f"unsupported JSON value type: {type(value).__name__}")
100
+
101
+
102
+ def request_body(value: Model | Mapping[str, object] | None) -> JSONValue:
103
+ # The legacy dict path remains server-validated; generated model inputs validate locally.
104
+ if isinstance(value, Model):
105
+ raw = value.to_json()
106
+ decode(value.__schema_pointer__, raw, request=True)
107
+ return raw
108
+ return json_value(value)
109
+
110
+
111
+ def _schema(pointer: str) -> dict[str, object]:
112
+ from ._generated.models import SCHEMAS
113
+
114
+ return SCHEMAS[pointer]
115
+
116
+
117
+ def _child(pointer: str, value: object, *, request: bool) -> object:
118
+ return decode(pointer, value, request=request)
119
+
120
+
121
+ def decode(pointer: str, value: object, *, request: bool = False) -> object:
122
+ """Decode one schema location. Unknown response properties remain forward compatible."""
123
+ from ._generated.models import MODEL_TYPES, ENUM_TYPES
124
+
125
+ schema = _schema(pointer)
126
+ ref = schema.get("$ref")
127
+ if isinstance(ref, str):
128
+ return decode(ref, value, request=request)
129
+ for keyword in ("oneOf", "anyOf", "allOf"):
130
+ arms = schema.get(keyword)
131
+ if isinstance(arms, list):
132
+ if keyword == "allOf":
133
+ if len(arms) != 1:
134
+ raise ValueError(f"unsupported allOf at {pointer}")
135
+ return _child(f"{pointer}/{keyword}/0", value, request=request)
136
+ successes: list[object] = []
137
+ for index in range(len(arms)):
138
+ try:
139
+ successes.append(_child(f"{pointer}/{keyword}/{index}", value, request=request))
140
+ except ValueError:
141
+ pass
142
+ if not successes or (keyword == "oneOf" and len(successes) != 1):
143
+ raise ValueError(f"value does not match {keyword} at {pointer}")
144
+ return successes[0]
145
+ kind = schema.get("type")
146
+ if isinstance(kind, list):
147
+ if value is None and "null" in kind:
148
+ return None
149
+ kind = next((item for item in kind if item != "null"), None)
150
+ if value is None:
151
+ if kind == "null" or kind is None:
152
+ return None
153
+ raise ValueError(f"null is not accepted at {pointer}")
154
+ enum = schema.get("enum")
155
+ if isinstance(enum, list):
156
+ if value not in enum and (request or len(enum) == 1):
157
+ raise ValueError(f"unknown enum value at {pointer}")
158
+ enum_type = ENUM_TYPES.get(pointer)
159
+ return enum_type(value) if enum_type is not None else value
160
+ if kind == "string":
161
+ if not isinstance(value, str):
162
+ raise ValueError(f"string required at {pointer}")
163
+ if request:
164
+ minimum, maximum = schema.get("minLength"), schema.get("maxLength")
165
+ if isinstance(minimum, int) and len(value) < minimum:
166
+ raise ValueError(f"string too short at {pointer}")
167
+ if isinstance(maximum, int) and len(value) > maximum:
168
+ raise ValueError(f"string too long at {pointer}")
169
+ pattern = schema.get("pattern")
170
+ if isinstance(pattern, str) and re.fullmatch(pattern, value) is None:
171
+ raise ValueError(f"string pattern mismatch at {pointer}")
172
+ if schema.get("format") == "uuid":
173
+ try:
174
+ uuid.UUID(value)
175
+ except ValueError:
176
+ raise ValueError(f"UUID required at {pointer}") from None
177
+ return value
178
+ if kind in ("integer", "number"):
179
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or (kind == "integer" and not isinstance(value, int)):
180
+ raise ValueError(f"{kind} required at {pointer}")
181
+ if isinstance(value, float) and not math.isfinite(value):
182
+ raise ValueError(f"finite number required at {pointer}")
183
+ if request:
184
+ minimum, maximum = schema.get("minimum"), schema.get("maximum")
185
+ if isinstance(minimum, (int, float)) and value < minimum:
186
+ raise ValueError(f"number below minimum at {pointer}")
187
+ if isinstance(maximum, (int, float)) and value > maximum:
188
+ raise ValueError(f"number above maximum at {pointer}")
189
+ return value
190
+ if kind == "boolean":
191
+ if not isinstance(value, bool):
192
+ raise ValueError(f"boolean required at {pointer}")
193
+ return value
194
+ if kind == "array":
195
+ if not isinstance(value, (list, tuple)):
196
+ raise ValueError(f"array required at {pointer}")
197
+ if request:
198
+ minimum, maximum = schema.get("minItems"), schema.get("maxItems")
199
+ if isinstance(minimum, int) and len(value) < minimum:
200
+ raise ValueError(f"array too short at {pointer}")
201
+ if isinstance(maximum, int) and len(value) > maximum:
202
+ raise ValueError(f"array too long at {pointer}")
203
+ if schema.get("uniqueItems") is True:
204
+ import json
205
+ canonical = [json.dumps(json_value(item), sort_keys=True) for item in value]
206
+ if len(set(canonical)) != len(canonical):
207
+ raise ValueError(f"duplicate array items at {pointer}")
208
+ return [_child(f"{pointer}/items", item, request=request) for item in value]
209
+ if kind == "object" or "properties" in schema:
210
+ if not isinstance(value, Mapping):
211
+ raise ValueError(f"object required at {pointer}")
212
+ if any(not isinstance(key, str) for key in value):
213
+ raise ValueError(f"string object keys required at {pointer}")
214
+ props = schema.get("properties")
215
+ if isinstance(props, dict):
216
+ required = schema.get("required", [])
217
+ if not isinstance(required, list):
218
+ raise ValueError(f"invalid required metadata at {pointer}")
219
+ if any(key not in value for key in required):
220
+ raise ValueError(f"required field absent at {pointer}")
221
+ if request and schema.get("additionalProperties") is False and any(key not in props for key in value):
222
+ raise ValueError(f"unknown field at {pointer}")
223
+ cls = MODEL_TYPES[pointer]
224
+ params: dict[str, object] = {}
225
+ for key, name in cls.__json_fields__.items():
226
+ if key in value:
227
+ escaped = key.replace("~", "~0").replace("/", "~1")
228
+ params[name] = _child(f"{pointer}/properties/{escaped}", value[key], request=request)
229
+ return cls(**params)
230
+ additional = schema.get("additionalProperties")
231
+ if isinstance(additional, dict):
232
+ return {key: _child(f"{pointer}/additionalProperties", item, request=request) for key, item in value.items()}
233
+ return json_value(value)
234
+ if kind == "null":
235
+ raise ValueError(f"null required at {pointer}")
236
+ if kind is None:
237
+ return json_value(value)
238
+ raise ValueError(f"unsupported schema type at {pointer}: {kind}")
239
+
240
+
241
+ def typed_result(result: ApiResult, cls: type[R], pointer: str) -> R:
242
+ try:
243
+ data = decode(pointer, result.data)
244
+ except ValueError as exc:
245
+ raise ProtocolError(
246
+ "malformed_success_body", status=result.info.status,
247
+ request_id=result.info.request_id, trace_id=result.info.trace_id,
248
+ excerpt=str(exc),
249
+ ) from None
250
+ values = {field.name: getattr(result, field.name) for field in fields(ApiResult)}
251
+ values["data"] = data
252
+ return cls(**values)
authgebra/_parse.py ADDED
@@ -0,0 +1,158 @@
1
+ """Typed parsing of the authorization endpoints' ``data`` payloads (shared by sync and async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .errors import ProtocolError
8
+ from ._generated import models as api_models
9
+ from .models import (
10
+ ApiResult,
11
+ BatchCheckResult,
12
+ BatchDecision,
13
+ CheckResult,
14
+ Decision,
15
+ ExplainResult,
16
+ ResourcePage,
17
+ ResourceRef,
18
+ RevisionResult,
19
+ )
20
+
21
+
22
+ def _bad(result: ApiResult, what: str) -> ProtocolError:
23
+ return ProtocolError(
24
+ "malformed_success_body",
25
+ status=result.info.status,
26
+ request_id=result.info.request_id,
27
+ trace_id=result.info.trace_id,
28
+ excerpt=what,
29
+ )
30
+
31
+
32
+ def _decision(result: ApiResult, value: Any) -> Decision:
33
+ try:
34
+ return Decision(value)
35
+ except ValueError:
36
+ # Fail closed: an unrecognised decision is never interpreted as allow (or deny).
37
+ raise ProtocolError(
38
+ "unknown_decision",
39
+ status=result.info.status,
40
+ request_id=result.info.request_id,
41
+ trace_id=result.info.trace_id,
42
+ ) from None
43
+
44
+
45
+ def _obj(result: ApiResult, value: Any, what: str) -> Mapping[str, Any]:
46
+ if not isinstance(value, dict):
47
+ raise _bad(result, what)
48
+ return value
49
+
50
+
51
+ def _str(result: ApiResult, obj: Mapping[str, Any], key: str) -> str:
52
+ value = obj.get(key)
53
+ if not isinstance(value, str):
54
+ raise _bad(result, f"{key} must be a string")
55
+ return value
56
+
57
+
58
+ def _int(result: ApiResult, obj: Mapping[str, Any], key: str) -> int:
59
+ value = obj.get(key)
60
+ if isinstance(value, bool) or not isinstance(value, int):
61
+ raise _bad(result, f"{key} must be an integer")
62
+ return value
63
+
64
+
65
+ def _policy(result: ApiResult, obj: Mapping[str, Any]) -> api_models.AuthzPolicyRef | None:
66
+ value = obj.get("matched_policy")
67
+ if value is None:
68
+ return None
69
+ try:
70
+ return api_models.AuthzPolicyRef.from_json(value)
71
+ except ValueError:
72
+ raise _bad(result, "matched_policy") from None
73
+
74
+
75
+ def resource_ref(result: ApiResult, value: Any) -> ResourceRef:
76
+ obj = _obj(result, value, "resource")
77
+ return ResourceRef(_str(result, obj, "resource_type_code"), _str(result, obj, "external_id"))
78
+
79
+
80
+ def check(result: ApiResult) -> CheckResult:
81
+ data = _obj(result, result.data, "data")
82
+ return CheckResult(
83
+ decision=_decision(result, data.get("decision")),
84
+ reason_code=_str(result, data, "reason_code"),
85
+ matched_policy=_policy(result, data),
86
+ info=result.info,
87
+ meta=result.meta,
88
+ )
89
+
90
+
91
+ def _batch_items(result: ApiResult, value: Any) -> tuple[BatchDecision, ...]:
92
+ if not isinstance(value, list):
93
+ raise _bad(result, "decision list expected")
94
+ items = []
95
+ for entry in value:
96
+ obj = _obj(result, entry, "batch item")
97
+ items.append(
98
+ BatchDecision(
99
+ resource=resource_ref(result, obj.get("resource")),
100
+ reason_code=_str(result, obj, "reason_code"),
101
+ matched_policy=_policy(result, obj),
102
+ )
103
+ )
104
+ return tuple(items)
105
+
106
+
107
+ def batch(result: ApiResult) -> BatchCheckResult:
108
+ data = _obj(result, result.data, "data")
109
+ return BatchCheckResult(
110
+ allowed=_batch_items(result, data.get("allowed")),
111
+ denied=_batch_items(result, data.get("denied")),
112
+ info=result.info,
113
+ meta=result.meta,
114
+ )
115
+
116
+
117
+ def resource_page(result: ApiResult) -> ResourcePage:
118
+ if not isinstance(result.data, list) or result.page is None:
119
+ raise _bad(result, "paged list expected")
120
+ return ResourcePage(
121
+ items=tuple(resource_ref(result, entry) for entry in result.data),
122
+ page=result.page,
123
+ info=result.info,
124
+ meta=result.meta,
125
+ )
126
+
127
+
128
+ def explain(result: ApiResult) -> ExplainResult:
129
+ data = _obj(result, result.data, "data")
130
+ policies = data.get("policies")
131
+ truncated = data.get("truncated")
132
+ if not isinstance(policies, list) or not isinstance(truncated, bool):
133
+ raise _bad(result, "policies/truncated")
134
+ try:
135
+ explanations = tuple(api_models.AuthzPolicyExplanation.from_json(policy) for policy in policies)
136
+ except ValueError:
137
+ raise _bad(result, "policies") from None
138
+ return ExplainResult(
139
+ decision=_decision(result, data.get("decision")),
140
+ reason_code=_str(result, data, "reason_code"),
141
+ matched_policy=_policy(result, data),
142
+ policies=explanations,
143
+ truncated=truncated,
144
+ total_steps=_int(result, data, "total_steps"),
145
+ info=result.info,
146
+ meta=result.meta,
147
+ )
148
+
149
+
150
+ def revision(result: ApiResult) -> RevisionResult:
151
+ data = _obj(result, result.data, "data")
152
+ return RevisionResult(
153
+ db_revision=_int(result, data, "db_revision"),
154
+ snapshot_generation=_int(result, data, "snapshot_generation"),
155
+ status=_str(result, data, "status"),
156
+ info=result.info,
157
+ meta=result.meta,
158
+ )
authgebra/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """SDK package version (independent of the server API version)."""
2
+
3
+ __version__ = "0.1.0"
authgebra/aclient.py ADDED
@@ -0,0 +1,256 @@
1
+ """Asynchronous client (``asyncio``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import time
9
+ from types import TracebackType
10
+ from typing import Any, AsyncIterator, Mapping
11
+
12
+ import httpx
13
+
14
+ from . import _core, _parse
15
+ from ._generated.aio import AsyncAdminClient
16
+ from .client import DEFAULT_MAX_PAGES
17
+ from .config import Config
18
+ from .errors import ApiError, PaginationError, ProtocolError, TransportError
19
+ from .models import (
20
+ ApiResult,
21
+ BatchCheckRequest,
22
+ BatchCheckResult,
23
+ CheckRequest,
24
+ CheckResult,
25
+ ExplainRequest,
26
+ ExplainResult,
27
+ ListResourcesRequest,
28
+ ResourcePage,
29
+ RevisionResult,
30
+ )
31
+
32
+ log = logging.getLogger("authgebra")
33
+
34
+
35
+ class AsyncAuthorizationClient:
36
+ """Async twin of :class:`~authgebra.client.AuthorizationClient` (identical semantics)."""
37
+
38
+ def __init__(self, client: AsyncClient) -> None:
39
+ self._c = client
40
+
41
+ async def check(
42
+ self, request: CheckRequest, *, request_id: str | None = None, traceparent: str | None = None
43
+ ) -> CheckResult:
44
+ return _parse.check(await self._post("/api/v1/authz/check", request.to_json(), request_id, traceparent))
45
+
46
+ async def batch_check(
47
+ self, request: BatchCheckRequest, *, request_id: str | None = None, traceparent: str | None = None
48
+ ) -> BatchCheckResult:
49
+ return _parse.batch(
50
+ await self._post("/api/v1/authz/batch-check", request.to_json(), request_id, traceparent)
51
+ )
52
+
53
+ async def explain(
54
+ self, request: ExplainRequest, *, request_id: str | None = None, traceparent: str | None = None
55
+ ) -> ExplainResult:
56
+ return _parse.explain(
57
+ await self._post("/api/v1/authz/explain", request.to_json(), request_id, traceparent)
58
+ )
59
+
60
+ async def list_resources(
61
+ self,
62
+ request: ListResourcesRequest,
63
+ *,
64
+ request_id: str | None = None,
65
+ traceparent: str | None = None,
66
+ ) -> ResourcePage:
67
+ return _parse.resource_page(
68
+ await self._post("/api/v1/authz/list-resources", request.to_json(), request_id, traceparent)
69
+ )
70
+
71
+ async def iter_list_resources(
72
+ self,
73
+ request: ListResourcesRequest,
74
+ *,
75
+ max_pages: int = DEFAULT_MAX_PAGES,
76
+ traceparent: str | None = None,
77
+ ) -> AsyncIterator[ResourcePage]:
78
+ """Async page iterator: lazy (backpressure), cancellable, bounded, never stitches generations."""
79
+ if max_pages < 1:
80
+ raise ValueError("max_pages must be >= 1")
81
+ trace = _core.resolve_traceparent(self._c.config, traceparent)
82
+ current = request
83
+ generation: int | None = None
84
+ seen: set[str] = set()
85
+ for _ in range(max_pages):
86
+ page = await self.list_resources(current, traceparent=trace)
87
+ gen = page.meta.snapshot_generation if page.meta else None
88
+ if generation is None:
89
+ generation = gen
90
+ elif gen is not None and gen != generation:
91
+ raise PaginationError(
92
+ f"snapshot generation changed during paging ({generation} -> {gen}); restart explicitly"
93
+ )
94
+ yield page
95
+ cursor = page.page.next_cursor
96
+ if not page.page.has_more or cursor is None:
97
+ return
98
+ if cursor in seen:
99
+ raise PaginationError("server returned a repeated cursor; refusing to loop")
100
+ seen.add(cursor)
101
+ current = request.with_cursor(cursor)
102
+ raise PaginationError(f"page limit of {max_pages} reached while more pages remain")
103
+
104
+ async def revision(
105
+ self, *, request_id: str | None = None, traceparent: str | None = None
106
+ ) -> RevisionResult:
107
+ return _parse.revision(
108
+ await self._c.request(
109
+ "GET", "/api/v1/authz/revision", request_id=request_id, traceparent=traceparent
110
+ )
111
+ )
112
+
113
+ async def _post(
114
+ self, path: str, body: Mapping[str, Any], request_id: str | None, traceparent: str | None
115
+ ) -> ApiResult:
116
+ return await self._c.request(
117
+ "POST", path, json=body, safe=True, request_id=request_id, traceparent=traceparent
118
+ )
119
+
120
+
121
+ class AsyncClient:
122
+ """Asynchronous Authgebra client; use ``async with``.
123
+
124
+ Cancelling the awaiting task cancels the in-flight request; no retry is attempted after a
125
+ cancellation.
126
+ """
127
+
128
+ def __init__(self, config: Config) -> None:
129
+ self.config = config
130
+ self._owns_http = config.http_client is None
131
+ self._http: httpx.AsyncClient = config.http_client or httpx.AsyncClient(
132
+ verify=config.verify,
133
+ transport=config.transport,
134
+ follow_redirects=False,
135
+ trust_env=False,
136
+ )
137
+ self.authorization = AsyncAuthorizationClient(self)
138
+ self.admin = AsyncAdminClient(self)
139
+
140
+ async def __aenter__(self) -> AsyncClient:
141
+ return self
142
+
143
+ async def __aexit__(
144
+ self,
145
+ exc_type: type[BaseException] | None,
146
+ exc: BaseException | None,
147
+ tb: TracebackType | None,
148
+ ) -> None:
149
+ await self.aclose()
150
+
151
+ async def aclose(self) -> None:
152
+ if self._owns_http:
153
+ await self._http.aclose()
154
+
155
+ async def request(
156
+ self,
157
+ method: str,
158
+ path: str,
159
+ *,
160
+ query: Mapping[str, object] | None = None,
161
+ json: Any = None,
162
+ if_match: str | None = None,
163
+ idempotency_key: str | None = None,
164
+ request_id: str | None = None,
165
+ traceparent: str | None = None,
166
+ safe: bool | None = None,
167
+ envelope: bool = True,
168
+ ) -> ApiResult:
169
+ cfg = self.config
170
+ is_safe = method.upper() in ("GET", "HEAD") if safe is None else safe
171
+ body = None if json is None else _core.serialize_json(json)
172
+ trace = _core.resolve_traceparent(cfg, traceparent)
173
+ retries_done = 0
174
+ while True:
175
+ attempt_trace = trace if trace is None or retries_done == 0 else _core.child_traceparent(trace)
176
+ prep = _core.prepare(
177
+ cfg,
178
+ method,
179
+ path,
180
+ query=query,
181
+ body=body,
182
+ token=await self._token(),
183
+ if_match=if_match,
184
+ idempotency_key=idempotency_key,
185
+ request_id=request_id,
186
+ traceparent=attempt_trace,
187
+ safe=is_safe,
188
+ )
189
+ try:
190
+ return _core.interpret(await self._send(prep), envelope=envelope)
191
+ except (ApiError, TransportError, ProtocolError) as exc:
192
+ exc.attempts = retries_done + 1
193
+ if not _core.should_retry(
194
+ cfg.retry,
195
+ safe=prep.safe,
196
+ has_key=prep.has_key,
197
+ retries_done=retries_done,
198
+ error=exc,
199
+ ):
200
+ raise
201
+ delay = _core.retry_delay(cfg.retry, retries_done, exc)
202
+ log.debug("retrying %s %s in %.3fs (attempt %d)", prep.method, path, delay, retries_done + 2)
203
+ await (cfg.retry.async_sleep or asyncio.sleep)(delay)
204
+ retries_done += 1
205
+
206
+ async def _token(self) -> str | None:
207
+ provider = self.config.token_provider
208
+ if provider is None:
209
+ return None
210
+ token = provider()
211
+ if inspect.isawaitable(token):
212
+ token = await token
213
+ if token is not None and not isinstance(token, str):
214
+ raise TypeError("token_provider must return str or None")
215
+ return token
216
+
217
+ async def _send(self, prep: _core.Prepared) -> _core.RawResponse:
218
+ cfg = self.config
219
+ try:
220
+ return await asyncio.wait_for(self._attempt(prep), timeout=cfg.timeout)
221
+ except asyncio.TimeoutError:
222
+ raise _core.build_transport_error("timeout", prep, "deadline exceeded") from None
223
+
224
+ async def _attempt(self, prep: _core.Prepared) -> _core.RawResponse:
225
+ cfg = self.config
226
+ timeout = httpx.Timeout(
227
+ connect=min(cfg.connect_timeout, cfg.timeout), read=cfg.timeout, write=cfg.timeout, pool=cfg.timeout
228
+ )
229
+ try:
230
+ async with self._http.stream(
231
+ prep.method,
232
+ prep.url,
233
+ params=prep.params or None,
234
+ content=prep.content,
235
+ headers=prep.headers,
236
+ timeout=timeout,
237
+ ) as response:
238
+ declared = response.headers.get("content-length")
239
+ if declared is not None and declared.isdigit() and int(declared) > cfg.max_response_bytes:
240
+ raise ProtocolError("response_too_large", status=response.status_code, request_id=prep.request_id)
241
+ chunks: list[bytes] = []
242
+ size = 0
243
+ async for chunk in response.aiter_bytes():
244
+ size += len(chunk)
245
+ if size > cfg.max_response_bytes:
246
+ raise ProtocolError(
247
+ "response_too_large", status=response.status_code, request_id=prep.request_id
248
+ )
249
+ chunks.append(chunk)
250
+ headers = {name.lower(): value for name, value in response.headers.items()}
251
+ log.debug("%s %s -> %d", prep.method, prep.url.split("?", 1)[0], response.status_code)
252
+ return _core.RawResponse(response.status_code, headers, b"".join(chunks))
253
+ except (ProtocolError, TransportError):
254
+ raise
255
+ except httpx.HTTPError as exc:
256
+ raise _core.transport_error(exc, prep) from exc