archastro.platform.runtime.http_client

  1# Runtime: async HTTP client for the generated Platform SDK.
  2# This file is hand-maintained, not generated.
  3
  4from __future__ import annotations
  5
  6import asyncio
  7import json
  8import threading
  9from collections.abc import AsyncIterator, Callable, Coroutine, Iterator
 10from functools import cache
 11from typing import Any, TypeVar, overload
 12
 13import httpx
 14from pydantic import TypeAdapter
 15
 16DEFAULT_API_PREFIX = "/api/v1"
 17
 18T = TypeVar("T")
 19
 20
 21@cache
 22def _type_adapter(tp: Any) -> TypeAdapter[Any]:
 23    """Cache adapters per response type; TypeAdapter construction is costly."""
 24    return TypeAdapter(tp)
 25
 26
 27class ApiError(Exception):
 28    """Structured API error with status code, error code, and message."""
 29
 30    def __init__(
 31        self,
 32        status: int,
 33        error_code: str,
 34        message: str,
 35        body: dict[str, Any] | None = None,
 36    ):
 37        super().__init__(message)
 38        self.status = status
 39        self.error_code = error_code
 40        self.body = body
 41
 42
 43class HttpClient:
 44    def __init__(
 45        self,
 46        *,
 47        base_url: str,
 48        access_token: str | None = None,
 49        get_access_token: Callable[[], str | None] | None = None,
 50        on_refresh_token: Callable[[], Coroutine[Any, Any, str]] | None = None,
 51        path_prefix: str | None = None,
 52        default_headers: dict[str, str] | None = None,
 53        refresh_only: bool = False,
 54    ):
 55        self._base_url = base_url.rstrip("/")
 56        self._access_token = access_token
 57        self._get_access_token = get_access_token
 58        self._on_refresh_token = on_refresh_token
 59        self._path_prefix = path_prefix
 60        self._default_headers = default_headers or {}
 61        self._client = httpx.AsyncClient(timeout=30.0)
 62        self._refresh_task: asyncio.Task[str] | None = None
 63        self._refresh_only = refresh_only
 64
 65    def _get_token(self) -> str | None:
 66        if self._get_access_token:
 67            return self._get_access_token()
 68        return self._access_token
 69
 70    def _transform_path(self, path: str) -> str:
 71        if self._path_prefix is None:
 72            return path
 73        if path.startswith(DEFAULT_API_PREFIX):
 74            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
 75        return path
 76
 77    def set_access_token(self, token: str) -> None:
 78        self._access_token = token
 79
 80    def set_refresh_handler(self, handler: Callable[[], Coroutine[Any, Any, str]]) -> None:
 81        self._on_refresh_token = handler
 82
 83    async def _do_fetch(
 84        self,
 85        path: str,
 86        *,
 87        method: str = "GET",
 88        body: Any = None,
 89        headers: dict[str, str] | None = None,
 90        query: dict[str, Any] | None = None,
 91    ) -> httpx.Response:
 92        token = self._get_token()
 93        url = f"{self._base_url}{self._transform_path(path)}"
 94
 95        req_headers = {
 96            **self._default_headers,
 97            "Content-Type": "application/json",
 98        }
 99        if token:
100            req_headers["Authorization"] = f"Bearer {token}"
101        if headers:
102            req_headers.update(headers)
103
104        params = None
105        if query:
106            params = {k: v for k, v in query.items() if v is not None}
107
108        return await self._client.request(
109            method,
110            url,
111            json=body if body is not None and method not in ("GET", "HEAD") else None,
112            headers=req_headers,
113            params=params,
114        )
115
116    async def _execute(
117        self,
118        path: str,
119        *,
120        method: str = "GET",
121        body: Any = None,
122        headers: dict[str, str] | None = None,
123        query: dict[str, Any] | None = None,
124    ) -> httpx.Response:
125        """Fetch with auth gate, 401 auto-refresh, and error handling.
126
127        Returns the successful response for callers to interpret (JSON, raw bytes, etc.).
128        """
129        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
130        if self._refresh_only and not path.startswith(auth_prefix):
131            raise RuntimeError(
132                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
133            )
134
135        response = await self._do_fetch(
136            path, method=method, body=body, headers=headers, query=query
137        )
138
139        # Auto-refresh: on 401, attempt one token refresh and retry.
140        # The refresh handler runs on a separate HttpClient (refresh_only),
141        # so it cannot re-enter this block. Concurrent 401s piggyback on
142        # the same _refresh_task.
143        if (
144            response.status_code == 401
145            and self._on_refresh_token
146            and not path.startswith(auth_prefix)
147        ):
148            if self._refresh_task is None:
149
150                async def _do_refresh() -> str:
151                    try:
152                        return await self._on_refresh_token()  # type: ignore[misc]
153                    finally:
154                        self._refresh_task = None
155
156                self._refresh_task = asyncio.create_task(_do_refresh())
157            try:
158                new_token = await self._refresh_task
159            except Exception:
160                pass  # refresh failed — fall through to throw original 401
161            else:
162                self._access_token = new_token
163                response = await self._do_fetch(
164                    path, method=method, body=body, headers=headers, query=query
165                )
166
167        if response.status_code >= 400:
168            raw_data: dict[str, Any] = {}
169            try:
170                raw_data = response.json()
171            except Exception:
172                pass
173            error_code, message = _parse_error(raw_data, response.status_code)
174            raise ApiError(response.status_code, error_code, message, raw_data)
175
176        return response
177
178    @overload
179    async def request(
180        self,
181        path: str,
182        *,
183        method: str = "GET",
184        body: Any = None,
185        headers: dict[str, str] | None = None,
186        query: dict[str, Any] | None = None,
187        response_type: type[T],
188    ) -> T: ...
189
190    @overload
191    async def request(
192        self,
193        path: str,
194        *,
195        method: str = "GET",
196        body: Any = None,
197        headers: dict[str, str] | None = None,
198        query: dict[str, Any] | None = None,
199        response_type: None = None,
200    ) -> Any: ...
201
202    async def request(
203        self,
204        path: str,
205        *,
206        method: str = "GET",
207        body: Any = None,
208        headers: dict[str, str] | None = None,
209        query: dict[str, Any] | None = None,
210        response_type: type[T] | None = None,
211    ) -> T | Any:
212        """Issue a request and return the JSON body.
213
214        With `response_type`, the body is validated into that type (a Pydantic
215        model or a generic alias like list[Model]); a bodyless 204 then raises
216        ValidationError, since the operation promised a typed body. Without
217        `response_type`, the raw parsed JSON is returned, or None on 204.
218        """
219        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
220
221        if response.status_code == 204:
222            if response_type is None:
223                return None
224            # A 204 on an operation that promises a typed body is a server
225            # contract violation; validating None fails loudly here instead
226            # of surfacing later as an AttributeError far from the call.
227            return _type_adapter(response_type).validate_python(None)
228
229        raw = response.json()
230        if response_type is None:
231            return raw
232        return _type_adapter(response_type).validate_python(raw)
233
234    async def request_raw(
235        self,
236        path: str,
237        *,
238        method: str = "GET",
239        body: Any = None,
240        headers: dict[str, str] | None = None,
241        query: dict[str, Any] | None = None,
242    ) -> dict[str, Any]:
243        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
244
245        return {
246            "content": response.content,
247            "mime_type": response.headers.get("content-type", "text/plain"),
248        }
249
250    async def stream_sse(
251        self,
252        path: str,
253        *,
254        method: str = "GET",
255        body: Any = None,
256        headers: dict[str, str] | None = None,
257        query: dict[str, Any] | None = None,
258    ) -> AsyncIterator[dict[str, Any]]:
259        """Open a Server-Sent Events stream, yielding parsed ``{"event", "data"}``.
260
261        Backs the generated async ``stream()`` methods for ``x-sdk-streaming``
262        endpoints. Raises :class:`ApiError` on a non-2xx response before the
263        stream opens. (Token refresh is not retried mid-stream.)
264        """
265        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
266        if self._refresh_only and not path.startswith(auth_prefix):
267            raise RuntimeError(
268                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
269            )
270
271        url = f"{self._base_url}{self._transform_path(path)}"
272        sends_body = body is not None and method not in ("GET", "HEAD")
273        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
274        if sends_body:
275            req_headers["Content-Type"] = "application/json"
276        token = self._get_token()
277        if token:
278            req_headers["Authorization"] = f"Bearer {token}"
279        if headers:
280            req_headers.update(headers)
281        params = {k: v for k, v in (query or {}).items() if v is not None} or None
282
283        async with self._client.stream(
284            method,
285            url,
286            json=body if sends_body else None,
287            headers=req_headers,
288            params=params,
289        ) as response:
290            if response.status_code >= 400:
291                await response.aread()
292                raw: dict[str, Any] = {}
293                try:
294                    raw = response.json()
295                except Exception:
296                    pass
297                code, message = _parse_error(raw, response.status_code)
298                raise ApiError(response.status_code, code, message, raw)
299
300            event: str | None = None
301            data_lines: list[str] = []
302            async for line in response.aiter_lines():
303                if line == "":
304                    parsed = _build_sse_event(event, data_lines)
305                    if parsed is not None:
306                        yield parsed
307                    event, data_lines = None, []
308                elif line.startswith("event:"):
309                    event = line[6:].strip()
310                elif line.startswith("data:"):
311                    data_lines.append(line[5:].strip())
312            parsed = _build_sse_event(event, data_lines)
313            if parsed is not None:
314                yield parsed
315
316    async def close(self) -> None:
317        await self._client.aclose()
318
319
320class SyncHttpClient:
321    def __init__(
322        self,
323        *,
324        base_url: str,
325        access_token: str | None = None,
326        get_access_token: Callable[[], str | None] | None = None,
327        on_refresh_token: Callable[[], str] | None = None,
328        path_prefix: str | None = None,
329        default_headers: dict[str, str] | None = None,
330        refresh_only: bool = False,
331    ):
332        self._base_url = base_url.rstrip("/")
333        self._access_token = access_token
334        self._get_access_token = get_access_token
335        self._on_refresh_token = on_refresh_token
336        self._path_prefix = path_prefix
337        self._default_headers = default_headers or {}
338        self._client = httpx.Client(timeout=30.0)
339        self._refresh_only = refresh_only
340        self._refresh_lock = threading.Lock()
341
342    def _get_token(self) -> str | None:
343        if self._get_access_token:
344            return self._get_access_token()
345        return self._access_token
346
347    def _transform_path(self, path: str) -> str:
348        if self._path_prefix is None:
349            return path
350        if path.startswith(DEFAULT_API_PREFIX):
351            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
352        return path
353
354    def set_access_token(self, token: str) -> None:
355        self._access_token = token
356
357    def set_refresh_handler(self, handler: Callable[[], str]) -> None:
358        self._on_refresh_token = handler
359
360    def _do_fetch(
361        self,
362        path: str,
363        *,
364        method: str = "GET",
365        body: Any = None,
366        headers: dict[str, str] | None = None,
367        query: dict[str, Any] | None = None,
368    ) -> httpx.Response:
369        token = self._get_token()
370        url = f"{self._base_url}{self._transform_path(path)}"
371
372        req_headers = {
373            **self._default_headers,
374            "Content-Type": "application/json",
375        }
376        if token:
377            req_headers["Authorization"] = f"Bearer {token}"
378        if headers:
379            req_headers.update(headers)
380
381        params = None
382        if query:
383            params = {k: v for k, v in query.items() if v is not None}
384
385        return self._client.request(
386            method,
387            url,
388            json=body if body is not None and method not in ("GET", "HEAD") else None,
389            headers=req_headers,
390            params=params,
391        )
392
393    def _execute(
394        self,
395        path: str,
396        *,
397        method: str = "GET",
398        body: Any = None,
399        headers: dict[str, str] | None = None,
400        query: dict[str, Any] | None = None,
401    ) -> httpx.Response:
402        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
403        if self._refresh_only and not path.startswith(auth_prefix):
404            raise RuntimeError(
405                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
406            )
407
408        original_token = self._get_token()
409        response = self._do_fetch(path, method=method, body=body, headers=headers, query=query)
410
411        if (
412            response.status_code == 401
413            and self._on_refresh_token
414            and not path.startswith(auth_prefix)
415        ):
416            try:
417                with self._refresh_lock:
418                    if self._get_token() == original_token:
419                        self._access_token = self._on_refresh_token()
420            except Exception:
421                pass
422            else:
423                response = self._do_fetch(
424                    path, method=method, body=body, headers=headers, query=query
425                )
426
427        if response.status_code >= 400:
428            raw_data: dict[str, Any] = {}
429            try:
430                raw_data = response.json()
431            except Exception:
432                pass
433            error_code, message = _parse_error(raw_data, response.status_code)
434            raise ApiError(response.status_code, error_code, message, raw_data)
435
436        return response
437
438    @overload
439    def request(
440        self,
441        path: str,
442        *,
443        method: str = "GET",
444        body: Any = None,
445        headers: dict[str, str] | None = None,
446        query: dict[str, Any] | None = None,
447        response_type: type[T],
448    ) -> T: ...
449
450    @overload
451    def request(
452        self,
453        path: str,
454        *,
455        method: str = "GET",
456        body: Any = None,
457        headers: dict[str, str] | None = None,
458        query: dict[str, Any] | None = None,
459        response_type: None = None,
460    ) -> Any: ...
461
462    def request(
463        self,
464        path: str,
465        *,
466        method: str = "GET",
467        body: Any = None,
468        headers: dict[str, str] | None = None,
469        query: dict[str, Any] | None = None,
470        response_type: type[T] | None = None,
471    ) -> T | Any:
472        """Issue a request and return the JSON body.
473
474        With `response_type`, the body is validated into that type (a Pydantic
475        model or a generic alias like list[Model]); a bodyless 204 then raises
476        ValidationError, since the operation promised a typed body. Without
477        `response_type`, the raw parsed JSON is returned, or None on 204.
478        """
479        response = self._execute(path, method=method, body=body, headers=headers, query=query)
480
481        if response.status_code == 204:
482            if response_type is None:
483                return None
484            # A 204 on an operation that promises a typed body is a server
485            # contract violation; validating None fails loudly here instead
486            # of surfacing later as an AttributeError far from the call.
487            return _type_adapter(response_type).validate_python(None)
488
489        raw = response.json()
490        if response_type is None:
491            return raw
492        return _type_adapter(response_type).validate_python(raw)
493
494    def request_raw(
495        self,
496        path: str,
497        *,
498        method: str = "GET",
499        body: Any = None,
500        headers: dict[str, str] | None = None,
501        query: dict[str, Any] | None = None,
502    ) -> dict[str, Any]:
503        response = self._execute(path, method=method, body=body, headers=headers, query=query)
504
505        return {
506            "content": response.content,
507            "mime_type": response.headers.get("content-type", "text/plain"),
508        }
509
510    def stream_sse_sync(
511        self,
512        path: str,
513        *,
514        method: str = "GET",
515        body: Any = None,
516        headers: dict[str, str] | None = None,
517        query: dict[str, Any] | None = None,
518    ) -> Iterator[dict[str, Any]]:
519        """Synchronous counterpart to :meth:`HttpClient.stream_sse`.
520
521        Backs the generated sync ``stream()`` methods. Raises :class:`ApiError`
522        on a non-2xx response before the stream opens.
523        """
524        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
525        if self._refresh_only and not path.startswith(auth_prefix):
526            raise RuntimeError(
527                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
528            )
529
530        url = f"{self._base_url}{self._transform_path(path)}"
531        sends_body = body is not None and method not in ("GET", "HEAD")
532        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
533        if sends_body:
534            req_headers["Content-Type"] = "application/json"
535        token = self._get_token()
536        if token:
537            req_headers["Authorization"] = f"Bearer {token}"
538        if headers:
539            req_headers.update(headers)
540        params = {k: v for k, v in (query or {}).items() if v is not None} or None
541
542        with self._client.stream(
543            method,
544            url,
545            json=body if sends_body else None,
546            headers=req_headers,
547            params=params,
548        ) as response:
549            if response.status_code >= 400:
550                response.read()
551                raw: dict[str, Any] = {}
552                try:
553                    raw = response.json()
554                except Exception:
555                    pass
556                code, message = _parse_error(raw, response.status_code)
557                raise ApiError(response.status_code, code, message, raw)
558
559            event: str | None = None
560            data_lines: list[str] = []
561            for line in response.iter_lines():
562                if line == "":
563                    parsed = _build_sse_event(event, data_lines)
564                    if parsed is not None:
565                        yield parsed
566                    event, data_lines = None, []
567                elif line.startswith("event:"):
568                    event = line[6:].strip()
569                elif line.startswith("data:"):
570                    data_lines.append(line[5:].strip())
571            parsed = _build_sse_event(event, data_lines)
572            if parsed is not None:
573                yield parsed
574
575    def close(self) -> None:
576        self._client.close()
577
578
579def _build_sse_event(event: str | None, data_lines: list[str]) -> dict[str, Any] | None:
580    """Assemble one SSE frame into ``{"event", "data"}``; ``None`` if empty."""
581    if event is None and not data_lines:
582        return None
583    raw = "\n".join(data_lines)
584    try:
585        data: Any = json.loads(raw)
586    except Exception:
587        data = raw
588    return {"event": event or "message", "data": data}
589
590
591def _parse_error(raw_data: dict[str, Any], status: int) -> tuple[str, str]:
592    error = raw_data.get("error")
593    if isinstance(error, dict):
594        code = error.get("code") or error.get("type") or "unknown_error"
595        message = error.get("message") or f"HTTP {status}"
596        return code, message
597    error_str = error if isinstance(error, str) else None
598    message = raw_data.get("message") or error_str or f"HTTP {status}"
599    return error_str or "unknown_error", message
DEFAULT_API_PREFIX = '/api/v1'
class ApiError(builtins.Exception):
28class ApiError(Exception):
29    """Structured API error with status code, error code, and message."""
30
31    def __init__(
32        self,
33        status: int,
34        error_code: str,
35        message: str,
36        body: dict[str, Any] | None = None,
37    ):
38        super().__init__(message)
39        self.status = status
40        self.error_code = error_code
41        self.body = body

Structured API error with status code, error code, and message.

ApiError( status: int, error_code: str, message: str, body: dict[str, typing.Any] | None = None)
31    def __init__(
32        self,
33        status: int,
34        error_code: str,
35        message: str,
36        body: dict[str, Any] | None = None,
37    ):
38        super().__init__(message)
39        self.status = status
40        self.error_code = error_code
41        self.body = body
status
error_code
body
class HttpClient:
 44class HttpClient:
 45    def __init__(
 46        self,
 47        *,
 48        base_url: str,
 49        access_token: str | None = None,
 50        get_access_token: Callable[[], str | None] | None = None,
 51        on_refresh_token: Callable[[], Coroutine[Any, Any, str]] | None = None,
 52        path_prefix: str | None = None,
 53        default_headers: dict[str, str] | None = None,
 54        refresh_only: bool = False,
 55    ):
 56        self._base_url = base_url.rstrip("/")
 57        self._access_token = access_token
 58        self._get_access_token = get_access_token
 59        self._on_refresh_token = on_refresh_token
 60        self._path_prefix = path_prefix
 61        self._default_headers = default_headers or {}
 62        self._client = httpx.AsyncClient(timeout=30.0)
 63        self._refresh_task: asyncio.Task[str] | None = None
 64        self._refresh_only = refresh_only
 65
 66    def _get_token(self) -> str | None:
 67        if self._get_access_token:
 68            return self._get_access_token()
 69        return self._access_token
 70
 71    def _transform_path(self, path: str) -> str:
 72        if self._path_prefix is None:
 73            return path
 74        if path.startswith(DEFAULT_API_PREFIX):
 75            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
 76        return path
 77
 78    def set_access_token(self, token: str) -> None:
 79        self._access_token = token
 80
 81    def set_refresh_handler(self, handler: Callable[[], Coroutine[Any, Any, str]]) -> None:
 82        self._on_refresh_token = handler
 83
 84    async def _do_fetch(
 85        self,
 86        path: str,
 87        *,
 88        method: str = "GET",
 89        body: Any = None,
 90        headers: dict[str, str] | None = None,
 91        query: dict[str, Any] | None = None,
 92    ) -> httpx.Response:
 93        token = self._get_token()
 94        url = f"{self._base_url}{self._transform_path(path)}"
 95
 96        req_headers = {
 97            **self._default_headers,
 98            "Content-Type": "application/json",
 99        }
100        if token:
101            req_headers["Authorization"] = f"Bearer {token}"
102        if headers:
103            req_headers.update(headers)
104
105        params = None
106        if query:
107            params = {k: v for k, v in query.items() if v is not None}
108
109        return await self._client.request(
110            method,
111            url,
112            json=body if body is not None and method not in ("GET", "HEAD") else None,
113            headers=req_headers,
114            params=params,
115        )
116
117    async def _execute(
118        self,
119        path: str,
120        *,
121        method: str = "GET",
122        body: Any = None,
123        headers: dict[str, str] | None = None,
124        query: dict[str, Any] | None = None,
125    ) -> httpx.Response:
126        """Fetch with auth gate, 401 auto-refresh, and error handling.
127
128        Returns the successful response for callers to interpret (JSON, raw bytes, etc.).
129        """
130        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
131        if self._refresh_only and not path.startswith(auth_prefix):
132            raise RuntimeError(
133                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
134            )
135
136        response = await self._do_fetch(
137            path, method=method, body=body, headers=headers, query=query
138        )
139
140        # Auto-refresh: on 401, attempt one token refresh and retry.
141        # The refresh handler runs on a separate HttpClient (refresh_only),
142        # so it cannot re-enter this block. Concurrent 401s piggyback on
143        # the same _refresh_task.
144        if (
145            response.status_code == 401
146            and self._on_refresh_token
147            and not path.startswith(auth_prefix)
148        ):
149            if self._refresh_task is None:
150
151                async def _do_refresh() -> str:
152                    try:
153                        return await self._on_refresh_token()  # type: ignore[misc]
154                    finally:
155                        self._refresh_task = None
156
157                self._refresh_task = asyncio.create_task(_do_refresh())
158            try:
159                new_token = await self._refresh_task
160            except Exception:
161                pass  # refresh failed — fall through to throw original 401
162            else:
163                self._access_token = new_token
164                response = await self._do_fetch(
165                    path, method=method, body=body, headers=headers, query=query
166                )
167
168        if response.status_code >= 400:
169            raw_data: dict[str, Any] = {}
170            try:
171                raw_data = response.json()
172            except Exception:
173                pass
174            error_code, message = _parse_error(raw_data, response.status_code)
175            raise ApiError(response.status_code, error_code, message, raw_data)
176
177        return response
178
179    @overload
180    async def request(
181        self,
182        path: str,
183        *,
184        method: str = "GET",
185        body: Any = None,
186        headers: dict[str, str] | None = None,
187        query: dict[str, Any] | None = None,
188        response_type: type[T],
189    ) -> T: ...
190
191    @overload
192    async def request(
193        self,
194        path: str,
195        *,
196        method: str = "GET",
197        body: Any = None,
198        headers: dict[str, str] | None = None,
199        query: dict[str, Any] | None = None,
200        response_type: None = None,
201    ) -> Any: ...
202
203    async def request(
204        self,
205        path: str,
206        *,
207        method: str = "GET",
208        body: Any = None,
209        headers: dict[str, str] | None = None,
210        query: dict[str, Any] | None = None,
211        response_type: type[T] | None = None,
212    ) -> T | Any:
213        """Issue a request and return the JSON body.
214
215        With `response_type`, the body is validated into that type (a Pydantic
216        model or a generic alias like list[Model]); a bodyless 204 then raises
217        ValidationError, since the operation promised a typed body. Without
218        `response_type`, the raw parsed JSON is returned, or None on 204.
219        """
220        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
221
222        if response.status_code == 204:
223            if response_type is None:
224                return None
225            # A 204 on an operation that promises a typed body is a server
226            # contract violation; validating None fails loudly here instead
227            # of surfacing later as an AttributeError far from the call.
228            return _type_adapter(response_type).validate_python(None)
229
230        raw = response.json()
231        if response_type is None:
232            return raw
233        return _type_adapter(response_type).validate_python(raw)
234
235    async def request_raw(
236        self,
237        path: str,
238        *,
239        method: str = "GET",
240        body: Any = None,
241        headers: dict[str, str] | None = None,
242        query: dict[str, Any] | None = None,
243    ) -> dict[str, Any]:
244        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
245
246        return {
247            "content": response.content,
248            "mime_type": response.headers.get("content-type", "text/plain"),
249        }
250
251    async def stream_sse(
252        self,
253        path: str,
254        *,
255        method: str = "GET",
256        body: Any = None,
257        headers: dict[str, str] | None = None,
258        query: dict[str, Any] | None = None,
259    ) -> AsyncIterator[dict[str, Any]]:
260        """Open a Server-Sent Events stream, yielding parsed ``{"event", "data"}``.
261
262        Backs the generated async ``stream()`` methods for ``x-sdk-streaming``
263        endpoints. Raises :class:`ApiError` on a non-2xx response before the
264        stream opens. (Token refresh is not retried mid-stream.)
265        """
266        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
267        if self._refresh_only and not path.startswith(auth_prefix):
268            raise RuntimeError(
269                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
270            )
271
272        url = f"{self._base_url}{self._transform_path(path)}"
273        sends_body = body is not None and method not in ("GET", "HEAD")
274        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
275        if sends_body:
276            req_headers["Content-Type"] = "application/json"
277        token = self._get_token()
278        if token:
279            req_headers["Authorization"] = f"Bearer {token}"
280        if headers:
281            req_headers.update(headers)
282        params = {k: v for k, v in (query or {}).items() if v is not None} or None
283
284        async with self._client.stream(
285            method,
286            url,
287            json=body if sends_body else None,
288            headers=req_headers,
289            params=params,
290        ) as response:
291            if response.status_code >= 400:
292                await response.aread()
293                raw: dict[str, Any] = {}
294                try:
295                    raw = response.json()
296                except Exception:
297                    pass
298                code, message = _parse_error(raw, response.status_code)
299                raise ApiError(response.status_code, code, message, raw)
300
301            event: str | None = None
302            data_lines: list[str] = []
303            async for line in response.aiter_lines():
304                if line == "":
305                    parsed = _build_sse_event(event, data_lines)
306                    if parsed is not None:
307                        yield parsed
308                    event, data_lines = None, []
309                elif line.startswith("event:"):
310                    event = line[6:].strip()
311                elif line.startswith("data:"):
312                    data_lines.append(line[5:].strip())
313            parsed = _build_sse_event(event, data_lines)
314            if parsed is not None:
315                yield parsed
316
317    async def close(self) -> None:
318        await self._client.aclose()
HttpClient( *, base_url: str, access_token: str | None = None, get_access_token: Callable[[], str | None] | None = None, on_refresh_token: Callable[[], Coroutine[typing.Any, typing.Any, str]] | None = None, path_prefix: str | None = None, default_headers: dict[str, str] | None = None, refresh_only: bool = False)
45    def __init__(
46        self,
47        *,
48        base_url: str,
49        access_token: str | None = None,
50        get_access_token: Callable[[], str | None] | None = None,
51        on_refresh_token: Callable[[], Coroutine[Any, Any, str]] | None = None,
52        path_prefix: str | None = None,
53        default_headers: dict[str, str] | None = None,
54        refresh_only: bool = False,
55    ):
56        self._base_url = base_url.rstrip("/")
57        self._access_token = access_token
58        self._get_access_token = get_access_token
59        self._on_refresh_token = on_refresh_token
60        self._path_prefix = path_prefix
61        self._default_headers = default_headers or {}
62        self._client = httpx.AsyncClient(timeout=30.0)
63        self._refresh_task: asyncio.Task[str] | None = None
64        self._refresh_only = refresh_only
def set_access_token(self, token: str) -> None:
78    def set_access_token(self, token: str) -> None:
79        self._access_token = token
def set_refresh_handler( self, handler: Callable[[], Coroutine[typing.Any, typing.Any, str]]) -> None:
81    def set_refresh_handler(self, handler: Callable[[], Coroutine[Any, Any, str]]) -> None:
82        self._on_refresh_token = handler
async def request( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None, response_type: type[~T] | None = None) -> Union[~T, Any]:
203    async def request(
204        self,
205        path: str,
206        *,
207        method: str = "GET",
208        body: Any = None,
209        headers: dict[str, str] | None = None,
210        query: dict[str, Any] | None = None,
211        response_type: type[T] | None = None,
212    ) -> T | Any:
213        """Issue a request and return the JSON body.
214
215        With `response_type`, the body is validated into that type (a Pydantic
216        model or a generic alias like list[Model]); a bodyless 204 then raises
217        ValidationError, since the operation promised a typed body. Without
218        `response_type`, the raw parsed JSON is returned, or None on 204.
219        """
220        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
221
222        if response.status_code == 204:
223            if response_type is None:
224                return None
225            # A 204 on an operation that promises a typed body is a server
226            # contract violation; validating None fails loudly here instead
227            # of surfacing later as an AttributeError far from the call.
228            return _type_adapter(response_type).validate_python(None)
229
230        raw = response.json()
231        if response_type is None:
232            return raw
233        return _type_adapter(response_type).validate_python(raw)

Issue a request and return the JSON body.

With response_type, the body is validated into that type (a Pydantic model or a generic alias like list[Model]); a bodyless 204 then raises ValidationError, since the operation promised a typed body. Without response_type, the raw parsed JSON is returned, or None on 204.

async def request_raw( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None) -> dict[str, typing.Any]:
235    async def request_raw(
236        self,
237        path: str,
238        *,
239        method: str = "GET",
240        body: Any = None,
241        headers: dict[str, str] | None = None,
242        query: dict[str, Any] | None = None,
243    ) -> dict[str, Any]:
244        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
245
246        return {
247            "content": response.content,
248            "mime_type": response.headers.get("content-type", "text/plain"),
249        }
async def stream_sse( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None) -> AsyncIterator[dict[str, typing.Any]]:
251    async def stream_sse(
252        self,
253        path: str,
254        *,
255        method: str = "GET",
256        body: Any = None,
257        headers: dict[str, str] | None = None,
258        query: dict[str, Any] | None = None,
259    ) -> AsyncIterator[dict[str, Any]]:
260        """Open a Server-Sent Events stream, yielding parsed ``{"event", "data"}``.
261
262        Backs the generated async ``stream()`` methods for ``x-sdk-streaming``
263        endpoints. Raises :class:`ApiError` on a non-2xx response before the
264        stream opens. (Token refresh is not retried mid-stream.)
265        """
266        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
267        if self._refresh_only and not path.startswith(auth_prefix):
268            raise RuntimeError(
269                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
270            )
271
272        url = f"{self._base_url}{self._transform_path(path)}"
273        sends_body = body is not None and method not in ("GET", "HEAD")
274        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
275        if sends_body:
276            req_headers["Content-Type"] = "application/json"
277        token = self._get_token()
278        if token:
279            req_headers["Authorization"] = f"Bearer {token}"
280        if headers:
281            req_headers.update(headers)
282        params = {k: v for k, v in (query or {}).items() if v is not None} or None
283
284        async with self._client.stream(
285            method,
286            url,
287            json=body if sends_body else None,
288            headers=req_headers,
289            params=params,
290        ) as response:
291            if response.status_code >= 400:
292                await response.aread()
293                raw: dict[str, Any] = {}
294                try:
295                    raw = response.json()
296                except Exception:
297                    pass
298                code, message = _parse_error(raw, response.status_code)
299                raise ApiError(response.status_code, code, message, raw)
300
301            event: str | None = None
302            data_lines: list[str] = []
303            async for line in response.aiter_lines():
304                if line == "":
305                    parsed = _build_sse_event(event, data_lines)
306                    if parsed is not None:
307                        yield parsed
308                    event, data_lines = None, []
309                elif line.startswith("event:"):
310                    event = line[6:].strip()
311                elif line.startswith("data:"):
312                    data_lines.append(line[5:].strip())
313            parsed = _build_sse_event(event, data_lines)
314            if parsed is not None:
315                yield parsed

Open a Server-Sent Events stream, yielding parsed {"event", "data"}.

Backs the generated async stream() methods for x-sdk-streaming endpoints. Raises ApiError on a non-2xx response before the stream opens. (Token refresh is not retried mid-stream.)

async def close(self) -> None:
317    async def close(self) -> None:
318        await self._client.aclose()
class SyncHttpClient:
321class SyncHttpClient:
322    def __init__(
323        self,
324        *,
325        base_url: str,
326        access_token: str | None = None,
327        get_access_token: Callable[[], str | None] | None = None,
328        on_refresh_token: Callable[[], str] | None = None,
329        path_prefix: str | None = None,
330        default_headers: dict[str, str] | None = None,
331        refresh_only: bool = False,
332    ):
333        self._base_url = base_url.rstrip("/")
334        self._access_token = access_token
335        self._get_access_token = get_access_token
336        self._on_refresh_token = on_refresh_token
337        self._path_prefix = path_prefix
338        self._default_headers = default_headers or {}
339        self._client = httpx.Client(timeout=30.0)
340        self._refresh_only = refresh_only
341        self._refresh_lock = threading.Lock()
342
343    def _get_token(self) -> str | None:
344        if self._get_access_token:
345            return self._get_access_token()
346        return self._access_token
347
348    def _transform_path(self, path: str) -> str:
349        if self._path_prefix is None:
350            return path
351        if path.startswith(DEFAULT_API_PREFIX):
352            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
353        return path
354
355    def set_access_token(self, token: str) -> None:
356        self._access_token = token
357
358    def set_refresh_handler(self, handler: Callable[[], str]) -> None:
359        self._on_refresh_token = handler
360
361    def _do_fetch(
362        self,
363        path: str,
364        *,
365        method: str = "GET",
366        body: Any = None,
367        headers: dict[str, str] | None = None,
368        query: dict[str, Any] | None = None,
369    ) -> httpx.Response:
370        token = self._get_token()
371        url = f"{self._base_url}{self._transform_path(path)}"
372
373        req_headers = {
374            **self._default_headers,
375            "Content-Type": "application/json",
376        }
377        if token:
378            req_headers["Authorization"] = f"Bearer {token}"
379        if headers:
380            req_headers.update(headers)
381
382        params = None
383        if query:
384            params = {k: v for k, v in query.items() if v is not None}
385
386        return self._client.request(
387            method,
388            url,
389            json=body if body is not None and method not in ("GET", "HEAD") else None,
390            headers=req_headers,
391            params=params,
392        )
393
394    def _execute(
395        self,
396        path: str,
397        *,
398        method: str = "GET",
399        body: Any = None,
400        headers: dict[str, str] | None = None,
401        query: dict[str, Any] | None = None,
402    ) -> httpx.Response:
403        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
404        if self._refresh_only and not path.startswith(auth_prefix):
405            raise RuntimeError(
406                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
407            )
408
409        original_token = self._get_token()
410        response = self._do_fetch(path, method=method, body=body, headers=headers, query=query)
411
412        if (
413            response.status_code == 401
414            and self._on_refresh_token
415            and not path.startswith(auth_prefix)
416        ):
417            try:
418                with self._refresh_lock:
419                    if self._get_token() == original_token:
420                        self._access_token = self._on_refresh_token()
421            except Exception:
422                pass
423            else:
424                response = self._do_fetch(
425                    path, method=method, body=body, headers=headers, query=query
426                )
427
428        if response.status_code >= 400:
429            raw_data: dict[str, Any] = {}
430            try:
431                raw_data = response.json()
432            except Exception:
433                pass
434            error_code, message = _parse_error(raw_data, response.status_code)
435            raise ApiError(response.status_code, error_code, message, raw_data)
436
437        return response
438
439    @overload
440    def request(
441        self,
442        path: str,
443        *,
444        method: str = "GET",
445        body: Any = None,
446        headers: dict[str, str] | None = None,
447        query: dict[str, Any] | None = None,
448        response_type: type[T],
449    ) -> T: ...
450
451    @overload
452    def request(
453        self,
454        path: str,
455        *,
456        method: str = "GET",
457        body: Any = None,
458        headers: dict[str, str] | None = None,
459        query: dict[str, Any] | None = None,
460        response_type: None = None,
461    ) -> Any: ...
462
463    def request(
464        self,
465        path: str,
466        *,
467        method: str = "GET",
468        body: Any = None,
469        headers: dict[str, str] | None = None,
470        query: dict[str, Any] | None = None,
471        response_type: type[T] | None = None,
472    ) -> T | Any:
473        """Issue a request and return the JSON body.
474
475        With `response_type`, the body is validated into that type (a Pydantic
476        model or a generic alias like list[Model]); a bodyless 204 then raises
477        ValidationError, since the operation promised a typed body. Without
478        `response_type`, the raw parsed JSON is returned, or None on 204.
479        """
480        response = self._execute(path, method=method, body=body, headers=headers, query=query)
481
482        if response.status_code == 204:
483            if response_type is None:
484                return None
485            # A 204 on an operation that promises a typed body is a server
486            # contract violation; validating None fails loudly here instead
487            # of surfacing later as an AttributeError far from the call.
488            return _type_adapter(response_type).validate_python(None)
489
490        raw = response.json()
491        if response_type is None:
492            return raw
493        return _type_adapter(response_type).validate_python(raw)
494
495    def request_raw(
496        self,
497        path: str,
498        *,
499        method: str = "GET",
500        body: Any = None,
501        headers: dict[str, str] | None = None,
502        query: dict[str, Any] | None = None,
503    ) -> dict[str, Any]:
504        response = self._execute(path, method=method, body=body, headers=headers, query=query)
505
506        return {
507            "content": response.content,
508            "mime_type": response.headers.get("content-type", "text/plain"),
509        }
510
511    def stream_sse_sync(
512        self,
513        path: str,
514        *,
515        method: str = "GET",
516        body: Any = None,
517        headers: dict[str, str] | None = None,
518        query: dict[str, Any] | None = None,
519    ) -> Iterator[dict[str, Any]]:
520        """Synchronous counterpart to :meth:`HttpClient.stream_sse`.
521
522        Backs the generated sync ``stream()`` methods. Raises :class:`ApiError`
523        on a non-2xx response before the stream opens.
524        """
525        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
526        if self._refresh_only and not path.startswith(auth_prefix):
527            raise RuntimeError(
528                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
529            )
530
531        url = f"{self._base_url}{self._transform_path(path)}"
532        sends_body = body is not None and method not in ("GET", "HEAD")
533        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
534        if sends_body:
535            req_headers["Content-Type"] = "application/json"
536        token = self._get_token()
537        if token:
538            req_headers["Authorization"] = f"Bearer {token}"
539        if headers:
540            req_headers.update(headers)
541        params = {k: v for k, v in (query or {}).items() if v is not None} or None
542
543        with self._client.stream(
544            method,
545            url,
546            json=body if sends_body else None,
547            headers=req_headers,
548            params=params,
549        ) as response:
550            if response.status_code >= 400:
551                response.read()
552                raw: dict[str, Any] = {}
553                try:
554                    raw = response.json()
555                except Exception:
556                    pass
557                code, message = _parse_error(raw, response.status_code)
558                raise ApiError(response.status_code, code, message, raw)
559
560            event: str | None = None
561            data_lines: list[str] = []
562            for line in response.iter_lines():
563                if line == "":
564                    parsed = _build_sse_event(event, data_lines)
565                    if parsed is not None:
566                        yield parsed
567                    event, data_lines = None, []
568                elif line.startswith("event:"):
569                    event = line[6:].strip()
570                elif line.startswith("data:"):
571                    data_lines.append(line[5:].strip())
572            parsed = _build_sse_event(event, data_lines)
573            if parsed is not None:
574                yield parsed
575
576    def close(self) -> None:
577        self._client.close()
SyncHttpClient( *, base_url: str, access_token: str | None = None, get_access_token: Callable[[], str | None] | None = None, on_refresh_token: Callable[[], str] | None = None, path_prefix: str | None = None, default_headers: dict[str, str] | None = None, refresh_only: bool = False)
322    def __init__(
323        self,
324        *,
325        base_url: str,
326        access_token: str | None = None,
327        get_access_token: Callable[[], str | None] | None = None,
328        on_refresh_token: Callable[[], str] | None = None,
329        path_prefix: str | None = None,
330        default_headers: dict[str, str] | None = None,
331        refresh_only: bool = False,
332    ):
333        self._base_url = base_url.rstrip("/")
334        self._access_token = access_token
335        self._get_access_token = get_access_token
336        self._on_refresh_token = on_refresh_token
337        self._path_prefix = path_prefix
338        self._default_headers = default_headers or {}
339        self._client = httpx.Client(timeout=30.0)
340        self._refresh_only = refresh_only
341        self._refresh_lock = threading.Lock()
def set_access_token(self, token: str) -> None:
355    def set_access_token(self, token: str) -> None:
356        self._access_token = token
def set_refresh_handler(self, handler: Callable[[], str]) -> None:
358    def set_refresh_handler(self, handler: Callable[[], str]) -> None:
359        self._on_refresh_token = handler
def request( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None, response_type: type[~T] | None = None) -> Union[~T, Any]:
463    def request(
464        self,
465        path: str,
466        *,
467        method: str = "GET",
468        body: Any = None,
469        headers: dict[str, str] | None = None,
470        query: dict[str, Any] | None = None,
471        response_type: type[T] | None = None,
472    ) -> T | Any:
473        """Issue a request and return the JSON body.
474
475        With `response_type`, the body is validated into that type (a Pydantic
476        model or a generic alias like list[Model]); a bodyless 204 then raises
477        ValidationError, since the operation promised a typed body. Without
478        `response_type`, the raw parsed JSON is returned, or None on 204.
479        """
480        response = self._execute(path, method=method, body=body, headers=headers, query=query)
481
482        if response.status_code == 204:
483            if response_type is None:
484                return None
485            # A 204 on an operation that promises a typed body is a server
486            # contract violation; validating None fails loudly here instead
487            # of surfacing later as an AttributeError far from the call.
488            return _type_adapter(response_type).validate_python(None)
489
490        raw = response.json()
491        if response_type is None:
492            return raw
493        return _type_adapter(response_type).validate_python(raw)

Issue a request and return the JSON body.

With response_type, the body is validated into that type (a Pydantic model or a generic alias like list[Model]); a bodyless 204 then raises ValidationError, since the operation promised a typed body. Without response_type, the raw parsed JSON is returned, or None on 204.

def request_raw( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None) -> dict[str, typing.Any]:
495    def request_raw(
496        self,
497        path: str,
498        *,
499        method: str = "GET",
500        body: Any = None,
501        headers: dict[str, str] | None = None,
502        query: dict[str, Any] | None = None,
503    ) -> dict[str, Any]:
504        response = self._execute(path, method=method, body=body, headers=headers, query=query)
505
506        return {
507            "content": response.content,
508            "mime_type": response.headers.get("content-type", "text/plain"),
509        }
def stream_sse_sync( self, path: str, *, method: str = 'GET', body: Any = None, headers: dict[str, str] | None = None, query: dict[str, typing.Any] | None = None) -> Iterator[dict[str, typing.Any]]:
511    def stream_sse_sync(
512        self,
513        path: str,
514        *,
515        method: str = "GET",
516        body: Any = None,
517        headers: dict[str, str] | None = None,
518        query: dict[str, Any] | None = None,
519    ) -> Iterator[dict[str, Any]]:
520        """Synchronous counterpart to :meth:`HttpClient.stream_sse`.
521
522        Backs the generated sync ``stream()`` methods. Raises :class:`ApiError`
523        on a non-2xx response before the stream opens.
524        """
525        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
526        if self._refresh_only and not path.startswith(auth_prefix):
527            raise RuntimeError(
528                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
529            )
530
531        url = f"{self._base_url}{self._transform_path(path)}"
532        sends_body = body is not None and method not in ("GET", "HEAD")
533        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
534        if sends_body:
535            req_headers["Content-Type"] = "application/json"
536        token = self._get_token()
537        if token:
538            req_headers["Authorization"] = f"Bearer {token}"
539        if headers:
540            req_headers.update(headers)
541        params = {k: v for k, v in (query or {}).items() if v is not None} or None
542
543        with self._client.stream(
544            method,
545            url,
546            json=body if sends_body else None,
547            headers=req_headers,
548            params=params,
549        ) as response:
550            if response.status_code >= 400:
551                response.read()
552                raw: dict[str, Any] = {}
553                try:
554                    raw = response.json()
555                except Exception:
556                    pass
557                code, message = _parse_error(raw, response.status_code)
558                raise ApiError(response.status_code, code, message, raw)
559
560            event: str | None = None
561            data_lines: list[str] = []
562            for line in response.iter_lines():
563                if line == "":
564                    parsed = _build_sse_event(event, data_lines)
565                    if parsed is not None:
566                        yield parsed
567                    event, data_lines = None, []
568                elif line.startswith("event:"):
569                    event = line[6:].strip()
570                elif line.startswith("data:"):
571                    data_lines.append(line[5:].strip())
572            parsed = _build_sse_event(event, data_lines)
573            if parsed is not None:
574                yield parsed

Synchronous counterpart to HttpClient.stream_sse().

Backs the generated sync stream() methods. Raises ApiError on a non-2xx response before the stream opens.

def close(self) -> None:
576    def close(self) -> None:
577        self._client.close()