driftstack-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.
driftstack/errors.py ADDED
@@ -0,0 +1,194 @@
1
+ """Error class hierarchy for the Driftstack Python SDK.
2
+
3
+ Mirrors the server's RFC 7807 problem-types (apps/server/src/lib/errors.ts).
4
+ The HTTP layer maps `application/problem+json` responses to the right
5
+ subclass; non-HTTP failures (timeouts, parse errors, network) raise
6
+ ``TransportError``.
7
+
8
+ Callers can catch with the granularity they need::
9
+
10
+ try:
11
+ client.sessions.create()
12
+ except RateLimitError as e:
13
+ time.sleep(e.retry_after_seconds or 1)
14
+ except DriftstackError as e:
15
+ # any other typed problem
16
+ log.error("driftstack call failed: %s", e)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+
24
+ class DriftstackError(Exception):
25
+ """Base for every error raised by the Driftstack SDK.
26
+
27
+ All HTTP-derived errors carry the parsed problem document so callers
28
+ can read additional fields (``e.problem.get("retry_after_seconds")``,
29
+ ``e.problem.get("current_sessions")``, etc.) without knowing the
30
+ specific subclass shape.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ *,
37
+ status: int | None = None,
38
+ problem_type: str | None = None,
39
+ problem: dict[str, Any] | None = None,
40
+ ) -> None:
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.status = status
44
+ self.problem_type = problem_type
45
+ self.problem: dict[str, Any] = problem or {}
46
+
47
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
48
+ return f"{type(self).__name__}({self.message!r}, status={self.status})"
49
+
50
+
51
+ # ── Auth (401, 403) ───────────────────────────────────────────────────────
52
+
53
+
54
+ class AuthError(DriftstackError):
55
+ """Base for authentication / authorisation failures."""
56
+
57
+
58
+ class InvalidKeyError(AuthError):
59
+ """The provided API key was not recognised (malformed or unknown)."""
60
+
61
+
62
+ class ExpiredKeyError(AuthError):
63
+ """The API key passed its ``expires_at`` deadline."""
64
+
65
+
66
+ class RevokedKeyError(AuthError):
67
+ """The API key was revoked (DELETE /v1/api-keys/:id)."""
68
+
69
+
70
+ class ForbiddenError(AuthError):
71
+ """The caller is authenticated but lacks the required scope."""
72
+
73
+
74
+ # ── Validation / domain (400, 404, 409, 410) ──────────────────────────────
75
+
76
+
77
+ class ValidationError(DriftstackError):
78
+ """Request body or query parameters failed schema validation."""
79
+
80
+
81
+ class NotFoundError(DriftstackError):
82
+ """The targeted resource doesn't exist."""
83
+
84
+
85
+ class ConflictError(DriftstackError):
86
+ """The request would violate an invariant (duplicate, capacity, etc.)."""
87
+
88
+
89
+ class SessionNotFoundError(NotFoundError):
90
+ """Specifically: the addressed session id has no row in our store."""
91
+
92
+
93
+ class SessionDestroyedError(DriftstackError):
94
+ """The session was destroyed; further operations on it are rejected (410)."""
95
+
96
+
97
+ # ── Rate / quota (429) ────────────────────────────────────────────────────
98
+
99
+
100
+ class RateLimitError(DriftstackError):
101
+ """Token-bucket rate limit hit. ``retry_after_seconds`` is the hint."""
102
+
103
+ def __init__(
104
+ self,
105
+ message: str,
106
+ *,
107
+ retry_after_seconds: int | None = None,
108
+ status: int | None = 429,
109
+ problem_type: str | None = None,
110
+ problem: dict[str, Any] | None = None,
111
+ ) -> None:
112
+ super().__init__(message, status=status, problem_type=problem_type, problem=problem)
113
+ self.retry_after_seconds = retry_after_seconds
114
+
115
+
116
+ class QuotaExceededError(DriftstackError):
117
+ """Per-period usage quota exhausted."""
118
+
119
+ def __init__(
120
+ self,
121
+ message: str,
122
+ *,
123
+ current: int | None = None,
124
+ limit: int | None = None,
125
+ record_type: str | None = None,
126
+ status: int | None = 429,
127
+ problem_type: str | None = None,
128
+ problem: dict[str, Any] | None = None,
129
+ ) -> None:
130
+ super().__init__(message, status=status, problem_type=problem_type, problem=problem)
131
+ self.current = current
132
+ self.limit = limit
133
+ self.record_type = record_type
134
+
135
+
136
+ class ConcurrencyLimitError(DriftstackError):
137
+ """Active-session count would exceed the tier's concurrent limit."""
138
+
139
+ def __init__(
140
+ self,
141
+ message: str,
142
+ *,
143
+ current_sessions: int | None = None,
144
+ limit: int | None = None,
145
+ status: int | None = 429,
146
+ problem_type: str | None = None,
147
+ problem: dict[str, Any] | None = None,
148
+ ) -> None:
149
+ super().__init__(message, status=status, problem_type=problem_type, problem=problem)
150
+ self.current_sessions = current_sessions
151
+ self.limit = limit
152
+
153
+
154
+ # ── Driver / upstream (502) ───────────────────────────────────────────────
155
+
156
+
157
+ class DriverError(DriftstackError):
158
+ """The driver returned an unrecoverable error during the operation."""
159
+
160
+
161
+ # ── Transport (network, timeout, parse) ───────────────────────────────────
162
+
163
+
164
+ class TransportError(DriftstackError):
165
+ """A network-level or response-parsing failure that didn't reach the server.
166
+
167
+ Distinguished from server-returned errors so retry logic can decide
168
+ whether the request was idempotent enough to retry without surprises.
169
+ """
170
+
171
+
172
+ # ── Mapping problem-type URI → subclass ──────────────────────────────────
173
+
174
+ # Keep the mapping in one place for ease of audit + extension. The HTTP
175
+ # layer in `driftstack.http` consults this; the keys match the server
176
+ # constants in apps/server/src/lib/problem-types.ts.
177
+
178
+ PROBLEM_TYPE_TO_ERROR: dict[str, type[DriftstackError]] = {
179
+ "https://errors.driftstack.dev/bad-request": ValidationError,
180
+ "https://errors.driftstack.dev/unauthorized": AuthError,
181
+ "https://errors.driftstack.dev/forbidden": ForbiddenError,
182
+ "https://errors.driftstack.dev/not-found": NotFoundError,
183
+ "https://errors.driftstack.dev/conflict": ConflictError,
184
+ "https://errors.driftstack.dev/rate-limited": RateLimitError,
185
+ "https://errors.driftstack.dev/concurrency-limit": ConcurrencyLimitError,
186
+ "https://errors.driftstack.dev/tier-limit": QuotaExceededError,
187
+ "https://errors.driftstack.dev/revoked-key": RevokedKeyError,
188
+ "https://errors.driftstack.dev/expired-key": ExpiredKeyError,
189
+ "https://errors.driftstack.dev/invalid-key": InvalidKeyError,
190
+ "https://errors.driftstack.dev/session-destroyed": SessionDestroyedError,
191
+ "https://errors.driftstack.dev/driver-error": DriverError,
192
+ "https://errors.driftstack.dev/driver-not-integrated": DriverError,
193
+ "https://errors.driftstack.dev/validation-failed": ValidationError,
194
+ }
driftstack/http.py ADDED
@@ -0,0 +1,291 @@
1
+ """HTTP client wrapper.
2
+
3
+ Customers don't construct this directly — they get a :class:`Driftstack`
4
+ or :class:`AsyncDriftstack`, which wraps an :class:`HttpClient`
5
+ internally. The wrapper handles:
6
+
7
+ * Bearer auth header injection
8
+ * RFC 7807 problem-json → typed error mapping (``error_from_response``)
9
+ * Per-request timeout
10
+ * Retry policy delegation (see :mod:`driftstack.retry`)
11
+
12
+ Both sync and async variants share the same problem-mapping logic in
13
+ :func:`_error_from_response_data`, so a future shape change to the
14
+ server's error envelope updates both paths in one place.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from driftstack._version import __version__
25
+ from driftstack.errors import (
26
+ PROBLEM_TYPE_TO_ERROR,
27
+ ConcurrencyLimitError,
28
+ DriftstackError,
29
+ QuotaExceededError,
30
+ RateLimitError,
31
+ TransportError,
32
+ )
33
+ from driftstack.retry import RetryConfig, with_retry, with_retry_async
34
+
35
+ DEFAULT_TIMEOUT_S = 30.0
36
+ USER_AGENT = f"driftstack-sdk-python/{__version__}"
37
+
38
+
39
+ def _build_headers(api_key: str, has_body: bool) -> dict[str, str]:
40
+ headers = {
41
+ "authorization": f"Bearer {api_key}",
42
+ "user-agent": USER_AGENT,
43
+ "accept": "application/json",
44
+ }
45
+ if has_body:
46
+ headers["content-type"] = "application/json"
47
+ return headers
48
+
49
+
50
+ def _problem_from_text(text: str, status: int) -> dict[str, Any] | None:
51
+ """Parse a response body as RFC 7807 problem+json. Return None on parse fail."""
52
+ if not text:
53
+ return None
54
+ try:
55
+ parsed = json.loads(text)
56
+ except (json.JSONDecodeError, ValueError):
57
+ return None
58
+ if not isinstance(parsed, dict):
59
+ return None
60
+ if "type" not in parsed or "title" not in parsed or "status" not in parsed:
61
+ return None
62
+ return parsed
63
+
64
+
65
+ def _error_from_response_data(
66
+ status: int,
67
+ text: str,
68
+ retry_after_header: str | None,
69
+ ) -> DriftstackError:
70
+ """Map a non-2xx response to the right :class:`DriftstackError` subclass.
71
+
72
+ Falls back to :class:`TransportError` when the body isn't a proper
73
+ problem document — that surfaces as a server contract violation,
74
+ which is the right diagnostic for "we got a 500 with HTML in it."
75
+ """
76
+ problem = _problem_from_text(text, status)
77
+ if problem is None:
78
+ return TransportError(
79
+ f"non-2xx response ({status}) with non-problem body",
80
+ status=status,
81
+ )
82
+
83
+ problem_type = str(problem.get("type", ""))
84
+ title = str(problem.get("title", ""))
85
+ detail = str(problem.get("detail") or title)
86
+
87
+ # Retry-After can come from either the header or from a problem field.
88
+ retry_after_seconds: int | None = None
89
+ if retry_after_header is not None:
90
+ try:
91
+ retry_after_seconds = int(retry_after_header)
92
+ except ValueError:
93
+ retry_after_seconds = None
94
+ if retry_after_seconds is None and "retry_after_seconds" in problem:
95
+ try:
96
+ retry_after_seconds = int(problem["retry_after_seconds"])
97
+ except (TypeError, ValueError):
98
+ retry_after_seconds = None
99
+
100
+ error_cls = PROBLEM_TYPE_TO_ERROR.get(problem_type, DriftstackError)
101
+
102
+ if error_cls is RateLimitError:
103
+ return RateLimitError(
104
+ detail,
105
+ retry_after_seconds=retry_after_seconds,
106
+ status=status,
107
+ problem_type=problem_type,
108
+ problem=problem,
109
+ )
110
+ if error_cls is QuotaExceededError:
111
+ return QuotaExceededError(
112
+ detail,
113
+ current=_int_or_none(problem.get("current")),
114
+ limit=_int_or_none(problem.get("limit")),
115
+ record_type=str(problem["record_type"]) if problem.get("record_type") else None,
116
+ status=status,
117
+ problem_type=problem_type,
118
+ problem=problem,
119
+ )
120
+ if error_cls is ConcurrencyLimitError:
121
+ return ConcurrencyLimitError(
122
+ detail,
123
+ current_sessions=_int_or_none(problem.get("current_sessions")),
124
+ limit=_int_or_none(problem.get("limit")),
125
+ status=status,
126
+ problem_type=problem_type,
127
+ problem=problem,
128
+ )
129
+ return error_cls(detail, status=status, problem_type=problem_type, problem=problem)
130
+
131
+
132
+ def _int_or_none(value: Any) -> int | None:
133
+ if value is None:
134
+ return None
135
+ try:
136
+ return int(value)
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
141
+ # ──────────────────────────────────────────────────────────────────────────
142
+ # Sync HTTP client (httpx.Client)
143
+ # ──────────────────────────────────────────────────────────────────────────
144
+
145
+
146
+ class HttpClient:
147
+ """Thin wrapper around ``httpx.Client`` for the sync :class:`Driftstack`."""
148
+
149
+ def __init__(
150
+ self,
151
+ api_key: str,
152
+ *,
153
+ base_url: str,
154
+ timeout_s: float = DEFAULT_TIMEOUT_S,
155
+ retry: RetryConfig | None = None,
156
+ client: httpx.Client | None = None,
157
+ ) -> None:
158
+ self._api_key = api_key
159
+ self._base_url = base_url.rstrip("/")
160
+ self._retry = retry
161
+ self._client = client or httpx.Client(timeout=timeout_s)
162
+ self._owns_client = client is None
163
+
164
+ def close(self) -> None:
165
+ if self._owns_client:
166
+ self._client.close()
167
+
168
+ def __enter__(self) -> HttpClient:
169
+ return self
170
+
171
+ def __exit__(self, *_excinfo: Any) -> None:
172
+ self.close()
173
+
174
+ def request(
175
+ self,
176
+ method: str,
177
+ path: str,
178
+ *,
179
+ params: dict[str, Any] | None = None,
180
+ json_body: Any | None = None,
181
+ retry: RetryConfig | None = None,
182
+ ) -> Any:
183
+ url = self._base_url + path
184
+ headers = _build_headers(self._api_key, has_body=json_body is not None)
185
+
186
+ def _do() -> Any:
187
+ try:
188
+ response = self._client.request(
189
+ method,
190
+ url,
191
+ params=params,
192
+ json=json_body,
193
+ headers=headers,
194
+ )
195
+ except httpx.TimeoutException as err:
196
+ raise TransportError("request timed out", status=0) from err
197
+ except httpx.HTTPError as err:
198
+ raise TransportError(str(err), status=0) from err
199
+
200
+ return _decode_or_raise(response)
201
+
202
+ return with_retry(_do, retry or self._retry)
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ # Async HTTP client (httpx.AsyncClient)
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+
209
+
210
+ class AsyncHttpClient:
211
+ """Async analogue of :class:`HttpClient`."""
212
+
213
+ def __init__(
214
+ self,
215
+ api_key: str,
216
+ *,
217
+ base_url: str,
218
+ timeout_s: float = DEFAULT_TIMEOUT_S,
219
+ retry: RetryConfig | None = None,
220
+ client: httpx.AsyncClient | None = None,
221
+ ) -> None:
222
+ self._api_key = api_key
223
+ self._base_url = base_url.rstrip("/")
224
+ self._retry = retry
225
+ self._client = client or httpx.AsyncClient(timeout=timeout_s)
226
+ self._owns_client = client is None
227
+
228
+ async def aclose(self) -> None:
229
+ if self._owns_client:
230
+ await self._client.aclose()
231
+
232
+ async def __aenter__(self) -> AsyncHttpClient:
233
+ return self
234
+
235
+ async def __aexit__(self, *_excinfo: Any) -> None:
236
+ await self.aclose()
237
+
238
+ async def request(
239
+ self,
240
+ method: str,
241
+ path: str,
242
+ *,
243
+ params: dict[str, Any] | None = None,
244
+ json_body: Any | None = None,
245
+ retry: RetryConfig | None = None,
246
+ ) -> Any:
247
+ url = self._base_url + path
248
+ headers = _build_headers(self._api_key, has_body=json_body is not None)
249
+
250
+ async def _do() -> Any:
251
+ try:
252
+ response = await self._client.request(
253
+ method,
254
+ url,
255
+ params=params,
256
+ json=json_body,
257
+ headers=headers,
258
+ )
259
+ except httpx.TimeoutException as err:
260
+ raise TransportError("request timed out", status=0) from err
261
+ except httpx.HTTPError as err:
262
+ raise TransportError(str(err), status=0) from err
263
+
264
+ return _decode_or_raise(response)
265
+
266
+ return await with_retry_async(_do, retry or self._retry)
267
+
268
+
269
+ # ──────────────────────────────────────────────────────────────────────────
270
+ # Shared response handling
271
+ # ──────────────────────────────────────────────────────────────────────────
272
+
273
+
274
+ def _decode_or_raise(response: httpx.Response) -> Any:
275
+ """2xx → parsed JSON (or None on 204). Anything else → raise typed error."""
276
+ if 200 <= response.status_code < 300:
277
+ if response.status_code == 204 or not response.content:
278
+ return None
279
+ try:
280
+ return response.json()
281
+ except (json.JSONDecodeError, ValueError) as err:
282
+ raise TransportError(
283
+ "failed to parse JSON response body",
284
+ status=response.status_code,
285
+ ) from err
286
+
287
+ raise _error_from_response_data(
288
+ status=response.status_code,
289
+ text=response.text,
290
+ retry_after_header=response.headers.get("retry-after"),
291
+ )
driftstack/py.typed ADDED
File without changes
@@ -0,0 +1,29 @@
1
+ """Resource accessors mounted on the top-level Driftstack clients.
2
+
3
+ Each module exposes two classes — a sync resource and an async one —
4
+ that share the same method signatures but back onto :class:`HttpClient`
5
+ or :class:`AsyncHttpClient` respectively.
6
+
7
+ Customers don't import these directly; they reach them through the
8
+ client::
9
+
10
+ client = Driftstack(api_key="…")
11
+ client.sessions.create() # sync
12
+ await async_client.sessions.create() # async (separate client)
13
+ """
14
+
15
+ from driftstack.resources.api_keys import ApiKeysResource, AsyncApiKeysResource
16
+ from driftstack.resources.sessions import AsyncSessionsResource, SessionsResource
17
+ from driftstack.resources.usage import AsyncUsageResource, UsageResource
18
+ from driftstack.resources.webhooks import AsyncWebhooksResource, WebhooksResource
19
+
20
+ __all__ = [
21
+ "ApiKeysResource",
22
+ "AsyncApiKeysResource",
23
+ "SessionsResource",
24
+ "AsyncSessionsResource",
25
+ "UsageResource",
26
+ "AsyncUsageResource",
27
+ "WebhooksResource",
28
+ "AsyncWebhooksResource",
29
+ ]
@@ -0,0 +1,38 @@
1
+ """Shared helpers for the resource layer.
2
+
3
+ Customers can pass either a Pydantic model OR a dict to mutating
4
+ methods. Both serialise to the same JSON shape on the wire — the
5
+ helper here normalises before hand-off to httpx.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel
13
+
14
+
15
+ def coerce_body(body: BaseModel | dict[str, Any] | None) -> dict[str, Any] | None:
16
+ """Convert a Pydantic model or dict to the dict that httpx will JSON-encode.
17
+
18
+ ``None`` round-trips as ``None`` so a route with no body works
19
+ without callers having to pass an empty dict.
20
+
21
+ Pydantic models go through ``model_dump(mode="json", exclude_none=True)``
22
+ so optional unset fields don't pollute the wire payload (e.g.
23
+ ``CreateSessionRequest()`` shouldn't emit ``{"label": null}``).
24
+ """
25
+ if body is None:
26
+ return None
27
+ if isinstance(body, BaseModel):
28
+ return body.model_dump(mode="json", exclude_none=True)
29
+ return body
30
+
31
+
32
+ def coerce_query(query: BaseModel | dict[str, Any] | None) -> dict[str, Any] | None:
33
+ """Same as :func:`coerce_body` but for query-string params."""
34
+ if query is None:
35
+ return None
36
+ if isinstance(query, BaseModel):
37
+ return query.model_dump(mode="json", exclude_none=True)
38
+ return {k: v for k, v in query.items() if v is not None}
@@ -0,0 +1,61 @@
1
+ """API keys resource — /v1/api-keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from driftstack._generated.models import ApiKey, CreateApiKeyRequest, CreateApiKeyResponse
11
+ from driftstack.http import AsyncHttpClient, HttpClient
12
+ from driftstack.resources._common import coerce_body
13
+
14
+
15
+ class ApiKeyList(BaseModel):
16
+ """Response shape for ``GET /v1/api-keys``."""
17
+
18
+ data: list[ApiKey]
19
+
20
+
21
+ class ApiKeysResource:
22
+ """Synchronous API keys resource."""
23
+
24
+ def __init__(self, http: HttpClient) -> None:
25
+ self._http = http
26
+
27
+ def create(self, body: CreateApiKeyRequest | dict[str, Any]) -> CreateApiKeyResponse:
28
+ """Create an API key.
29
+
30
+ Plaintext is in the response — store it now, it cannot be
31
+ retrieved later. Requires the ``admin`` scope on the calling key.
32
+ """
33
+ data = self._http.request("POST", "/v1/api-keys", json_body=coerce_body(body))
34
+ return CreateApiKeyResponse.model_validate(data)
35
+
36
+ def list(self) -> ApiKeyList:
37
+ """List API keys for the current account. Plaintext never included."""
38
+ data = self._http.request("GET", "/v1/api-keys")
39
+ return ApiKeyList.model_validate(data)
40
+
41
+ def revoke(self, key_id: str) -> None:
42
+ """Revoke an API key. Idempotent — revoking an already-revoked key is a no-op."""
43
+ self._http.request("DELETE", f"/v1/api-keys/{quote(key_id, safe='')}")
44
+
45
+
46
+ class AsyncApiKeysResource:
47
+ """Async API keys resource."""
48
+
49
+ def __init__(self, http: AsyncHttpClient) -> None:
50
+ self._http = http
51
+
52
+ async def create(self, body: CreateApiKeyRequest | dict[str, Any]) -> CreateApiKeyResponse:
53
+ data = await self._http.request("POST", "/v1/api-keys", json_body=coerce_body(body))
54
+ return CreateApiKeyResponse.model_validate(data)
55
+
56
+ async def list(self) -> ApiKeyList:
57
+ data = await self._http.request("GET", "/v1/api-keys")
58
+ return ApiKeyList.model_validate(data)
59
+
60
+ async def revoke(self, key_id: str) -> None:
61
+ await self._http.request("DELETE", f"/v1/api-keys/{quote(key_id, safe='')}")