odcp-client 1.0.0__tar.gz

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.
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: odcp-client
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Owndivision Control Plane (license verify, JWKS, whoami caching).
5
+ Author: Owndivision
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://github.com/owndivision/owndivision-control-plane
8
+ Keywords: owndivision,control-plane,sdk
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: odcp-contracts<3,>=2.2
16
+ Requires-Dist: httpx<0.29,>=0.27
17
+ Requires-Dist: pyjwt<3,>=2.10.1
18
+ Requires-Dist: cryptography>=42
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: pytest-cov>=5; extra == "dev"
23
+ Requires-Dist: respx>=0.21; extra == "dev"
24
+ Requires-Dist: freezegun>=1.5; extra == "dev"
25
+ Requires-Dist: ruff>=0.6; extra == "dev"
26
+
27
+ # odcp-client
28
+
29
+ Official Python SDK for the Owndivision Control Plane. Provides offline-capable
30
+ license JWT verification with JWKS caching, whoami lookup with revision-aware
31
+ cache invalidation, and branding retrieval — all with both sync and async clients.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install odcp-client
37
+ ```
38
+
39
+ Requires Python 3.12+. Runtime dependencies: `odcp-contracts>=2.2,<3`, `httpx`,
40
+ `pyjwt`, `cryptography`.
41
+
42
+ ## Quickstart (sync)
43
+
44
+ ```python
45
+ from odcp_client import OdcpClient, LicenseInvalid, WhoamiNotFound
46
+
47
+ client = OdcpClient(
48
+ base_url="https://cp.your-domain.com",
49
+ api_token="your-cp-api-token",
50
+ license_token="eyJ...", # the signed license JWT
51
+ # Required when CP is configured with non-default iss/aud — must match
52
+ # CP's CP_SIGNING_ISS / CP_SIGNING_AUD. Defaults are
53
+ # "owndivision-control-plane" / "owndivision-dp".
54
+ signing_issuer="owndivision-control-plane",
55
+ signing_audience="owndivision-dp",
56
+ )
57
+
58
+ # Verify the license (offline after first JWKS fetch)
59
+ try:
60
+ claims = client.verify_license()
61
+ print(f"Seat cap: {claims.license.seat_cap}")
62
+ except LicenseInvalid as exc:
63
+ print(f"License invalid: {exc.reason}")
64
+
65
+ # Whoami lookup with revision-aware caching
66
+ try:
67
+ whoami = client.whoami(
68
+ provider="auth0",
69
+ subject="auth0|user_id",
70
+ workspace_id="ws-uuid",
71
+ )
72
+ print(whoami.user.email)
73
+ except WhoamiNotFound:
74
+ print("Unknown identity")
75
+
76
+ # Permission check (never raises)
77
+ can_read = client.has_permission(
78
+ provider="auth0",
79
+ subject="auth0|user_id",
80
+ workspace_id="ws-uuid",
81
+ permission="workspace.read",
82
+ )
83
+
84
+ client.close() # or use as a context manager: `with OdcpClient(...) as client:`
85
+ ```
86
+
87
+ ## Quickstart (async)
88
+
89
+ ```python
90
+ import asyncio
91
+ from odcp_client import AsyncOdcpClient
92
+
93
+ async def main() -> None:
94
+ async with AsyncOdcpClient(
95
+ base_url="https://cp.your-domain.com",
96
+ api_token="your-cp-api-token",
97
+ license_token="eyJ...",
98
+ ) as client:
99
+ claims = await client.verify_license()
100
+ whoami = await client.whoami(
101
+ provider="auth0", subject="auth0|user_id", workspace_id="ws-uuid",
102
+ )
103
+ can_write = await client.has_permission(
104
+ provider="auth0",
105
+ subject="auth0|user_id",
106
+ workspace_id="ws-uuid",
107
+ permission="workspace.write",
108
+ )
109
+
110
+ asyncio.run(main())
111
+ ```
112
+
113
+ ## FastAPI dependency recipe
114
+
115
+ ```python
116
+ from fastapi import Depends, HTTPException, Request
117
+ from odcp_client import AsyncOdcpClient, WhoamiNotFound
118
+ from odcp_contracts.whoami import WhoAmIResponse
119
+
120
+ # Construct once at app startup (shared across requests).
121
+ odcp = AsyncOdcpClient(
122
+ base_url=settings.CP_BASE_URL,
123
+ api_token=settings.CP_API_TOKEN,
124
+ license_token=settings.LICENSE_TOKEN,
125
+ )
126
+
127
+
128
+ async def get_current_user(request: Request) -> WhoAmIResponse:
129
+ sub = extract_auth0_sub(request) # your Auth0 subject extractor
130
+ ws_id = extract_workspace_from_license(request) # your workspace resolver
131
+ try:
132
+ return await odcp.whoami(
133
+ provider="auth0", subject=sub, workspace_id=ws_id
134
+ )
135
+ except WhoamiNotFound:
136
+ raise HTTPException(status_code=401, detail="unknown_identity")
137
+
138
+
139
+ @app.get("/protected")
140
+ async def protected(user: WhoAmIResponse = Depends(get_current_user)) -> dict:
141
+ return {"email": user.user.email}
142
+ ```
143
+
144
+ ## Cache backends
145
+
146
+ The default backend is an in-process LRU+TTL cache (`InMemoryTTLCache`). For
147
+ multi-process deployments, swap in a Redis backend:
148
+
149
+ ```python
150
+ import pickle
151
+ import redis
152
+
153
+ class RedisCacheBackend:
154
+ def __init__(self, redis_client: redis.Redis) -> None:
155
+ self._r = redis_client
156
+
157
+ def get(self, key: str):
158
+ value = self._r.get(key)
159
+ return None if value is None else pickle.loads(value)
160
+
161
+ def set(self, key: str, value, ttl_seconds: int) -> None:
162
+ self._r.setex(key, ttl_seconds, pickle.dumps(value))
163
+
164
+ def delete(self, key: str) -> None:
165
+ self._r.delete(key)
166
+
167
+
168
+ client = OdcpClient(
169
+ base_url="https://cp.your-domain.com",
170
+ cache_backend=RedisCacheBackend(redis.Redis()),
171
+ )
172
+ ```
173
+
174
+ > **Important:** Custom backends MUST be thread-safe and SHOULD be
175
+ > picklable-friendly. Async clients call backend methods via
176
+ > `asyncio.to_thread` automatically when the backend is not an
177
+ > `InMemoryTTLCache` instance.
178
+
179
+ ## License verification semantics
180
+
181
+ `verify_license()` works **offline after the first JWKS fetch**. The SDK fetches
182
+ `/api/v1/.well-known/jwks.json` once (default TTL: 3600 s), caches RSA public
183
+ keys by `kid`, and verifies subsequent tokens locally.
184
+
185
+ Key rotation is handled transparently: if a token references an unknown `kid`, the
186
+ SDK forces a JWKS refresh before raising. `LicenseInvalid.reason` codes:
187
+
188
+ | Reason | Meaning |
189
+ |--------|---------|
190
+ | `expired` | Token has passed its `exp` claim |
191
+ | `bad_signature` | Signature verification failed |
192
+ | `wrong_issuer` | `iss` claim does not match |
193
+ | `wrong_audience` | `aud` claim does not match |
194
+ | `unknown_kid` | No signing key found for the token's `kid` |
195
+ | `malformed` | Token is structurally invalid or claims fail validation |
196
+ | `jwks_unavailable` | CP JWKS endpoint could not be reached |
197
+
198
+ ## Revision-aware whoami
199
+
200
+ `whoami()` caches responses keyed by `(provider, subject, workspace_id, rbac_revision)`.
201
+ When CP increments `rbac_revision` for a workspace (on role/permission change), the
202
+ pointer entry expires or mismatches, causing the next call to re-fetch automatically.
203
+
204
+ No explicit cache invalidation is needed on the DP side.
205
+
206
+ > **Note:** During the Plan 02 rollout window, CP may omit `rbac_revision`. The
207
+ > SDK degrades gracefully to TTL-only caching in that case.
208
+
209
+ ## Versioning & compatibility
210
+
211
+ `odcp-client 1.x` requires `odcp-contracts>=2.2,<3`. The public API is frozen at
212
+ v1.0:
213
+
214
+ ```python
215
+ from odcp_client import OdcpClient, AsyncOdcpClient, OdcpError, LicenseInvalid, WhoamiNotFound
216
+ ```
217
+
218
+ Everything else (modules with a leading underscore, internal classes) may change
219
+ without a major version bump.
220
+
221
+ ## Development
222
+
223
+ ```bash
224
+ git clone https://github.com/owndivision/owndivision-control-plane
225
+ cd owndivision-control-plane
226
+ pip install -e odcp_client[dev] -e .
227
+ pytest tests/odcp_client --cov=odcp_client --cov-fail-under=90
228
+ ```
229
+
230
+ See [docs/plans/06_odcp_client_package.md](../docs/plans/06_odcp_client_package.md)
231
+ and [docs/specs/06_odcp_client_package/](../docs/specs/06_odcp_client_package/) for
232
+ implementation details.
@@ -0,0 +1,206 @@
1
+ # odcp-client
2
+
3
+ Official Python SDK for the Owndivision Control Plane. Provides offline-capable
4
+ license JWT verification with JWKS caching, whoami lookup with revision-aware
5
+ cache invalidation, and branding retrieval — all with both sync and async clients.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install odcp-client
11
+ ```
12
+
13
+ Requires Python 3.12+. Runtime dependencies: `odcp-contracts>=2.2,<3`, `httpx`,
14
+ `pyjwt`, `cryptography`.
15
+
16
+ ## Quickstart (sync)
17
+
18
+ ```python
19
+ from odcp_client import OdcpClient, LicenseInvalid, WhoamiNotFound
20
+
21
+ client = OdcpClient(
22
+ base_url="https://cp.your-domain.com",
23
+ api_token="your-cp-api-token",
24
+ license_token="eyJ...", # the signed license JWT
25
+ # Required when CP is configured with non-default iss/aud — must match
26
+ # CP's CP_SIGNING_ISS / CP_SIGNING_AUD. Defaults are
27
+ # "owndivision-control-plane" / "owndivision-dp".
28
+ signing_issuer="owndivision-control-plane",
29
+ signing_audience="owndivision-dp",
30
+ )
31
+
32
+ # Verify the license (offline after first JWKS fetch)
33
+ try:
34
+ claims = client.verify_license()
35
+ print(f"Seat cap: {claims.license.seat_cap}")
36
+ except LicenseInvalid as exc:
37
+ print(f"License invalid: {exc.reason}")
38
+
39
+ # Whoami lookup with revision-aware caching
40
+ try:
41
+ whoami = client.whoami(
42
+ provider="auth0",
43
+ subject="auth0|user_id",
44
+ workspace_id="ws-uuid",
45
+ )
46
+ print(whoami.user.email)
47
+ except WhoamiNotFound:
48
+ print("Unknown identity")
49
+
50
+ # Permission check (never raises)
51
+ can_read = client.has_permission(
52
+ provider="auth0",
53
+ subject="auth0|user_id",
54
+ workspace_id="ws-uuid",
55
+ permission="workspace.read",
56
+ )
57
+
58
+ client.close() # or use as a context manager: `with OdcpClient(...) as client:`
59
+ ```
60
+
61
+ ## Quickstart (async)
62
+
63
+ ```python
64
+ import asyncio
65
+ from odcp_client import AsyncOdcpClient
66
+
67
+ async def main() -> None:
68
+ async with AsyncOdcpClient(
69
+ base_url="https://cp.your-domain.com",
70
+ api_token="your-cp-api-token",
71
+ license_token="eyJ...",
72
+ ) as client:
73
+ claims = await client.verify_license()
74
+ whoami = await client.whoami(
75
+ provider="auth0", subject="auth0|user_id", workspace_id="ws-uuid",
76
+ )
77
+ can_write = await client.has_permission(
78
+ provider="auth0",
79
+ subject="auth0|user_id",
80
+ workspace_id="ws-uuid",
81
+ permission="workspace.write",
82
+ )
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ ## FastAPI dependency recipe
88
+
89
+ ```python
90
+ from fastapi import Depends, HTTPException, Request
91
+ from odcp_client import AsyncOdcpClient, WhoamiNotFound
92
+ from odcp_contracts.whoami import WhoAmIResponse
93
+
94
+ # Construct once at app startup (shared across requests).
95
+ odcp = AsyncOdcpClient(
96
+ base_url=settings.CP_BASE_URL,
97
+ api_token=settings.CP_API_TOKEN,
98
+ license_token=settings.LICENSE_TOKEN,
99
+ )
100
+
101
+
102
+ async def get_current_user(request: Request) -> WhoAmIResponse:
103
+ sub = extract_auth0_sub(request) # your Auth0 subject extractor
104
+ ws_id = extract_workspace_from_license(request) # your workspace resolver
105
+ try:
106
+ return await odcp.whoami(
107
+ provider="auth0", subject=sub, workspace_id=ws_id
108
+ )
109
+ except WhoamiNotFound:
110
+ raise HTTPException(status_code=401, detail="unknown_identity")
111
+
112
+
113
+ @app.get("/protected")
114
+ async def protected(user: WhoAmIResponse = Depends(get_current_user)) -> dict:
115
+ return {"email": user.user.email}
116
+ ```
117
+
118
+ ## Cache backends
119
+
120
+ The default backend is an in-process LRU+TTL cache (`InMemoryTTLCache`). For
121
+ multi-process deployments, swap in a Redis backend:
122
+
123
+ ```python
124
+ import pickle
125
+ import redis
126
+
127
+ class RedisCacheBackend:
128
+ def __init__(self, redis_client: redis.Redis) -> None:
129
+ self._r = redis_client
130
+
131
+ def get(self, key: str):
132
+ value = self._r.get(key)
133
+ return None if value is None else pickle.loads(value)
134
+
135
+ def set(self, key: str, value, ttl_seconds: int) -> None:
136
+ self._r.setex(key, ttl_seconds, pickle.dumps(value))
137
+
138
+ def delete(self, key: str) -> None:
139
+ self._r.delete(key)
140
+
141
+
142
+ client = OdcpClient(
143
+ base_url="https://cp.your-domain.com",
144
+ cache_backend=RedisCacheBackend(redis.Redis()),
145
+ )
146
+ ```
147
+
148
+ > **Important:** Custom backends MUST be thread-safe and SHOULD be
149
+ > picklable-friendly. Async clients call backend methods via
150
+ > `asyncio.to_thread` automatically when the backend is not an
151
+ > `InMemoryTTLCache` instance.
152
+
153
+ ## License verification semantics
154
+
155
+ `verify_license()` works **offline after the first JWKS fetch**. The SDK fetches
156
+ `/api/v1/.well-known/jwks.json` once (default TTL: 3600 s), caches RSA public
157
+ keys by `kid`, and verifies subsequent tokens locally.
158
+
159
+ Key rotation is handled transparently: if a token references an unknown `kid`, the
160
+ SDK forces a JWKS refresh before raising. `LicenseInvalid.reason` codes:
161
+
162
+ | Reason | Meaning |
163
+ |--------|---------|
164
+ | `expired` | Token has passed its `exp` claim |
165
+ | `bad_signature` | Signature verification failed |
166
+ | `wrong_issuer` | `iss` claim does not match |
167
+ | `wrong_audience` | `aud` claim does not match |
168
+ | `unknown_kid` | No signing key found for the token's `kid` |
169
+ | `malformed` | Token is structurally invalid or claims fail validation |
170
+ | `jwks_unavailable` | CP JWKS endpoint could not be reached |
171
+
172
+ ## Revision-aware whoami
173
+
174
+ `whoami()` caches responses keyed by `(provider, subject, workspace_id, rbac_revision)`.
175
+ When CP increments `rbac_revision` for a workspace (on role/permission change), the
176
+ pointer entry expires or mismatches, causing the next call to re-fetch automatically.
177
+
178
+ No explicit cache invalidation is needed on the DP side.
179
+
180
+ > **Note:** During the Plan 02 rollout window, CP may omit `rbac_revision`. The
181
+ > SDK degrades gracefully to TTL-only caching in that case.
182
+
183
+ ## Versioning & compatibility
184
+
185
+ `odcp-client 1.x` requires `odcp-contracts>=2.2,<3`. The public API is frozen at
186
+ v1.0:
187
+
188
+ ```python
189
+ from odcp_client import OdcpClient, AsyncOdcpClient, OdcpError, LicenseInvalid, WhoamiNotFound
190
+ ```
191
+
192
+ Everything else (modules with a leading underscore, internal classes) may change
193
+ without a major version bump.
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ git clone https://github.com/owndivision/owndivision-control-plane
199
+ cd owndivision-control-plane
200
+ pip install -e odcp_client[dev] -e .
201
+ pytest tests/odcp_client --cov=odcp_client --cov-fail-under=90
202
+ ```
203
+
204
+ See [docs/plans/06_odcp_client_package.md](../docs/plans/06_odcp_client_package.md)
205
+ and [docs/specs/06_odcp_client_package/](../docs/specs/06_odcp_client_package/) for
206
+ implementation details.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "odcp-client"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for the Owndivision Control Plane (license verify, JWKS, whoami caching)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Owndivision" }]
13
+ keywords = ["owndivision", "control-plane", "sdk"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "odcp-contracts>=2.2,<3",
22
+ "httpx>=0.27,<0.29",
23
+ "pyjwt>=2.10.1,<3",
24
+ "cryptography>=42",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8",
30
+ "pytest-asyncio>=0.23",
31
+ "pytest-cov>=5",
32
+ "respx>=0.21",
33
+ "freezegun>=1.5",
34
+ "ruff>=0.6",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/owndivision/owndivision-control-plane"
39
+
40
+ # src/ layout. The previous flat layout mapped the package onto the project
41
+ # directory itself (package-dir = {"odcp_client" = "."}), which produced an
42
+ # editable install with no real package directory on sys.path. Under pytest the
43
+ # sibling test directory tests/odcp_client/ - which has no __init__.py - was
44
+ # then picked up as a namespace package of the same name and shadowed the real
45
+ # one, so `from odcp_client import AsyncOdcpClient` failed with
46
+ # "cannot import name ... (unknown location)". A src/ layout keeps the
47
+ # importable package in exactly one place.
48
+ [tool.setuptools]
49
+ package-dir = {"" = "src"}
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """odcp-client — official Python SDK for the Owndivision Control Plane.
2
+
3
+ Public API is frozen at v1.0. Only the symbols re-exported below are
4
+ considered stable. Anything else (modules with leading underscore, internal
5
+ classes) may change without a major version bump.
6
+ """
7
+
8
+ from odcp_client.client import AsyncOdcpClient, OdcpClient
9
+ from odcp_client.exceptions import (
10
+ LicenseInvalid,
11
+ OdcpError,
12
+ WhoamiNotFound,
13
+ )
14
+
15
+ __all__ = [
16
+ "OdcpClient",
17
+ "AsyncOdcpClient",
18
+ "OdcpError",
19
+ "LicenseInvalid",
20
+ "WhoamiNotFound",
21
+ ]
22
+
23
+ __version__ = "1.0.0"
@@ -0,0 +1,147 @@
1
+ """Internal HTTP helpers — sync + async with bounded retries.
2
+
3
+ Retry policy:
4
+ - On ``httpx.ConnectError``, ``httpx.ReadTimeout``, or 5xx response
5
+ - Up to ``retry_max`` additional attempts (so total = retry_max + 1)
6
+ - Backoff: ``min(0.1 * 2**attempt, 2.0)`` seconds
7
+ - Never retry on 4xx
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from typing import Any, Mapping
15
+
16
+ import httpx
17
+
18
+ _RETRYABLE_STATUS = (500, 502, 503, 504)
19
+ _BACKOFF_BASE_SECONDS = 0.1
20
+ _BACKOFF_MAX_SECONDS = 2.0
21
+
22
+
23
+ def _backoff_seconds(attempt: int) -> float:
24
+ return min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_MAX_SECONDS)
25
+
26
+
27
+ def _auth_headers(api_token: str | None) -> dict[str, str]:
28
+ if not api_token:
29
+ return {}
30
+ return {"Authorization": f"Bearer {api_token}"}
31
+
32
+
33
+ class SyncHttpClient:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ base_url: str,
38
+ api_token: str | None,
39
+ timeout: float,
40
+ retry_max: int,
41
+ ) -> None:
42
+ self._client = httpx.Client(base_url=base_url, timeout=timeout)
43
+ self._api_token = api_token
44
+ self._retry_max = retry_max
45
+
46
+ def close(self) -> None:
47
+ self._client.close()
48
+
49
+ def request(
50
+ self,
51
+ method: str,
52
+ url: str,
53
+ *,
54
+ headers: Mapping[str, str] | None = None,
55
+ params: Mapping[str, Any] | None = None,
56
+ json: Any | None = None,
57
+ authenticated: bool = True,
58
+ ) -> httpx.Response:
59
+ merged_headers: dict[str, str] = {}
60
+ if authenticated:
61
+ merged_headers.update(_auth_headers(self._api_token))
62
+ if headers:
63
+ merged_headers.update(headers)
64
+
65
+ last_exc: Exception | None = None
66
+ for attempt in range(self._retry_max + 1):
67
+ try:
68
+ response = self._client.request(
69
+ method,
70
+ url,
71
+ headers=merged_headers,
72
+ params=params,
73
+ json=json,
74
+ )
75
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
76
+ last_exc = exc
77
+ if attempt >= self._retry_max:
78
+ raise
79
+ time.sleep(_backoff_seconds(attempt))
80
+ continue
81
+
82
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
83
+ time.sleep(_backoff_seconds(attempt))
84
+ continue
85
+ return response
86
+
87
+ # Unreachable but keeps mypy happy.
88
+ assert last_exc is not None
89
+ raise last_exc
90
+
91
+
92
+ class AsyncHttpClient:
93
+ def __init__(
94
+ self,
95
+ *,
96
+ base_url: str,
97
+ api_token: str | None,
98
+ timeout: float,
99
+ retry_max: int,
100
+ ) -> None:
101
+ self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
102
+ self._api_token = api_token
103
+ self._retry_max = retry_max
104
+
105
+ async def aclose(self) -> None:
106
+ await self._client.aclose()
107
+
108
+ async def request(
109
+ self,
110
+ method: str,
111
+ url: str,
112
+ *,
113
+ headers: Mapping[str, str] | None = None,
114
+ params: Mapping[str, Any] | None = None,
115
+ json: Any | None = None,
116
+ authenticated: bool = True,
117
+ ) -> httpx.Response:
118
+ merged_headers: dict[str, str] = {}
119
+ if authenticated:
120
+ merged_headers.update(_auth_headers(self._api_token))
121
+ if headers:
122
+ merged_headers.update(headers)
123
+
124
+ last_exc: Exception | None = None
125
+ for attempt in range(self._retry_max + 1):
126
+ try:
127
+ response = await self._client.request(
128
+ method,
129
+ url,
130
+ headers=merged_headers,
131
+ params=params,
132
+ json=json,
133
+ )
134
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
135
+ last_exc = exc
136
+ if attempt >= self._retry_max:
137
+ raise
138
+ await asyncio.sleep(_backoff_seconds(attempt))
139
+ continue
140
+
141
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
142
+ await asyncio.sleep(_backoff_seconds(attempt))
143
+ continue
144
+ return response
145
+
146
+ assert last_exc is not None
147
+ raise last_exc