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

Override the timeout of every request issued inside this block.

Applies to all SDK clients in the current context (a thread, or an asyncio task and anything that copies its context such as asyncio.to_thread), including requests made by generated resource methods, which take no timeout argument of their own. The value is read when a request is sent; a stream reads it when iteration starts, so start reading inside the block. It is the httpx timeout for each HTTP request, not a total for the block: after a 401, the token refresh request and the retry each get the same value again, so one call can spend up to three times it. Blocks nest; the innermost wins.

A value that is not positive (including NaN) raises TimeoutError before any request is sent, so a caller passing down the remainder of an overall deadline fails fast once that deadline has passed.

class ApiError(builtins.Exception):
 91class ApiError(Exception):
 92    """Structured API error with status code, error code, and message."""
 93
 94    def __init__(
 95        self,
 96        status: int,
 97        error_code: str,
 98        message: str,
 99        body: dict[str, Any] | None = None,
100    ):
101        super().__init__(message)
102        self.status = status
103        self.error_code = error_code
104        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)
 94    def __init__(
 95        self,
 96        status: int,
 97        error_code: str,
 98        message: str,
 99        body: dict[str, Any] | None = None,
100    ):
101        super().__init__(message)
102        self.status = status
103        self.error_code = error_code
104        self.body = body
status
error_code
body
class HttpClient:
107class HttpClient:
108    def __init__(
109        self,
110        *,
111        base_url: str,
112        access_token: str | None = None,
113        get_access_token: Callable[[], str | None] | None = None,
114        on_refresh_token: Callable[[], Coroutine[Any, Any, str]] | None = None,
115        path_prefix: str | None = None,
116        default_headers: dict[str, str] | None = None,
117        refresh_only: bool = False,
118        timeout: float = DEFAULT_TIMEOUT_S,
119    ):
120        self._base_url = base_url.rstrip("/")
121        self._access_token = access_token
122        self._get_access_token = get_access_token
123        self._on_refresh_token = on_refresh_token
124        self._path_prefix = path_prefix
125        self._default_headers = default_headers or {}
126        self._timeout = timeout
127        self._client = httpx.AsyncClient(timeout=timeout)
128        self._refresh_task: asyncio.Task[str] | None = None
129        self._refresh_only = refresh_only
130
131    def _get_token(self) -> str | None:
132        if self._get_access_token:
133            return self._get_access_token()
134        return self._access_token
135
136    def _transform_path(self, path: str) -> str:
137        if self._path_prefix is None:
138            return path
139        if path.startswith(DEFAULT_API_PREFIX):
140            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
141        return path
142
143    def set_access_token(self, token: str) -> None:
144        self._access_token = token
145
146    def set_refresh_handler(self, handler: Callable[[], Coroutine[Any, Any, str]]) -> None:
147        self._on_refresh_token = handler
148
149    async def _do_fetch(
150        self,
151        path: str,
152        *,
153        method: str = "GET",
154        body: Any = None,
155        headers: dict[str, str] | None = None,
156        query: dict[str, Any] | None = None,
157    ) -> httpx.Response:
158        timeout = _resolve_timeout(method, path)
159        token = self._get_token()
160        url = f"{self._base_url}{self._transform_path(path)}"
161
162        req_headers = {
163            **self._default_headers,
164            "Content-Type": "application/json",
165        }
166        if token:
167            req_headers["Authorization"] = f"Bearer {token}"
168        if headers:
169            req_headers.update(headers)
170
171        params = _encode_query(query)
172
173        return await self._client.request(
174            method,
175            url,
176            json=body if body is not None and method not in ("GET", "HEAD") else None,
177            headers=req_headers,
178            params=params,
179            timeout=timeout,
180        )
181
182    async def _execute(
183        self,
184        path: str,
185        *,
186        method: str = "GET",
187        body: Any = None,
188        headers: dict[str, str] | None = None,
189        query: dict[str, Any] | None = None,
190    ) -> httpx.Response:
191        """Fetch with auth gate, 401 auto-refresh, and error handling.
192
193        Returns the successful response for callers to interpret (JSON, raw bytes, etc.).
194        """
195        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
196        if self._refresh_only and not path.startswith(auth_prefix):
197            raise RuntimeError(
198                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
199            )
200
201        response = await self._do_fetch(
202            path, method=method, body=body, headers=headers, query=query
203        )
204
205        # Auto-refresh: on 401, attempt one token refresh and retry.
206        # The refresh handler runs on a separate HttpClient (refresh_only),
207        # so it cannot re-enter this block. Concurrent 401s piggyback on
208        # the same _refresh_task.
209        if (
210            response.status_code == 401
211            and self._on_refresh_token
212            and not path.startswith(auth_prefix)
213        ):
214            if self._refresh_task is None:
215
216                async def _do_refresh() -> str:
217                    try:
218                        return await self._on_refresh_token()  # type: ignore[misc]
219                    finally:
220                        self._refresh_task = None
221
222                self._refresh_task = asyncio.create_task(_do_refresh())
223            try:
224                new_token = await self._refresh_task
225            except Exception:
226                pass  # refresh failed — fall through to throw original 401
227            else:
228                self._access_token = new_token
229                response = await self._do_fetch(
230                    path, method=method, body=body, headers=headers, query=query
231                )
232
233        if response.status_code >= 400:
234            raw_data: dict[str, Any] = {}
235            try:
236                raw_data = response.json()
237            except Exception:
238                pass
239            error_code, message = _parse_error(raw_data, response.status_code)
240            raise ApiError(response.status_code, error_code, message, raw_data)
241
242        return response
243
244    @overload
245    async def request(
246        self,
247        path: str,
248        *,
249        method: str = "GET",
250        body: Any = None,
251        headers: dict[str, str] | None = None,
252        query: dict[str, Any] | None = None,
253        response_type: type[T],
254    ) -> T: ...
255
256    @overload
257    async def request(
258        self,
259        path: str,
260        *,
261        method: str = "GET",
262        body: Any = None,
263        headers: dict[str, str] | None = None,
264        query: dict[str, Any] | None = None,
265        response_type: None = None,
266    ) -> Any: ...
267
268    async def request(
269        self,
270        path: str,
271        *,
272        method: str = "GET",
273        body: Any = None,
274        headers: dict[str, str] | None = None,
275        query: dict[str, Any] | None = None,
276        response_type: type[T] | None = None,
277    ) -> T | Any:
278        """Issue a request and return the JSON body.
279
280        With `response_type`, the body is validated into that type (a Pydantic
281        model or a generic alias like list[Model]); a bodyless 204 then raises
282        ValidationError, since the operation promised a typed body. Without
283        `response_type`, the raw parsed JSON is returned, or None on 204.
284        """
285        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
286
287        if response.status_code == 204:
288            if response_type is None:
289                return None
290            # A 204 on an operation that promises a typed body is a server
291            # contract violation; validating None fails loudly here instead
292            # of surfacing later as an AttributeError far from the call.
293            return _type_adapter(response_type).validate_python(None)
294
295        raw = response.json()
296        if response_type is None:
297            return raw
298        return _type_adapter(response_type).validate_python(raw)
299
300    async def request_raw(
301        self,
302        path: str,
303        *,
304        method: str = "GET",
305        body: Any = None,
306        headers: dict[str, str] | None = None,
307        query: dict[str, Any] | None = None,
308    ) -> dict[str, Any]:
309        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
310
311        return {
312            "content": response.content,
313            "mime_type": response.headers.get("content-type", "text/plain"),
314        }
315
316    async def stream_sse(
317        self,
318        path: str,
319        *,
320        method: str = "GET",
321        body: Any = None,
322        headers: dict[str, str] | None = None,
323        query: dict[str, Any] | None = None,
324    ) -> AsyncIterator[dict[str, Any]]:
325        """Open a Server-Sent Events stream, yielding parsed ``{"event", "data"}``.
326
327        Backs the generated async ``stream()`` methods for ``x-sdk-streaming``
328        endpoints. Raises :class:`ApiError` on a non-2xx response before the
329        stream opens. (Token refresh is not retried mid-stream.)
330        """
331        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
332        if self._refresh_only and not path.startswith(auth_prefix):
333            raise RuntimeError(
334                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
335            )
336
337        timeout = _resolve_timeout(method, path)
338        url = f"{self._base_url}{self._transform_path(path)}"
339        sends_body = body is not None and method not in ("GET", "HEAD")
340        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
341        if sends_body:
342            req_headers["Content-Type"] = "application/json"
343        token = self._get_token()
344        if token:
345            req_headers["Authorization"] = f"Bearer {token}"
346        if headers:
347            req_headers.update(headers)
348        params = _encode_query(query)
349
350        async with self._client.stream(
351            method,
352            url,
353            json=body if sends_body else None,
354            headers=req_headers,
355            params=params,
356            timeout=timeout,
357        ) as response:
358            if response.status_code >= 400:
359                await response.aread()
360                raw: dict[str, Any] = {}
361                try:
362                    raw = response.json()
363                except Exception:
364                    pass
365                code, message = _parse_error(raw, response.status_code)
366                raise ApiError(response.status_code, code, message, raw)
367
368            event: str | None = None
369            data_lines: list[str] = []
370            async for line in response.aiter_lines():
371                if line == "":
372                    parsed = _build_sse_event(event, data_lines)
373                    if parsed is not None:
374                        yield parsed
375                    event, data_lines = None, []
376                elif line.startswith("event:"):
377                    event = line[6:].strip()
378                elif line.startswith("data:"):
379                    data_lines.append(line[5:].strip())
380            parsed = _build_sse_event(event, data_lines)
381            if parsed is not None:
382                yield parsed
383
384    async def close(self) -> None:
385        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, timeout: float = 30.0)
108    def __init__(
109        self,
110        *,
111        base_url: str,
112        access_token: str | None = None,
113        get_access_token: Callable[[], str | None] | None = None,
114        on_refresh_token: Callable[[], Coroutine[Any, Any, str]] | None = None,
115        path_prefix: str | None = None,
116        default_headers: dict[str, str] | None = None,
117        refresh_only: bool = False,
118        timeout: float = DEFAULT_TIMEOUT_S,
119    ):
120        self._base_url = base_url.rstrip("/")
121        self._access_token = access_token
122        self._get_access_token = get_access_token
123        self._on_refresh_token = on_refresh_token
124        self._path_prefix = path_prefix
125        self._default_headers = default_headers or {}
126        self._timeout = timeout
127        self._client = httpx.AsyncClient(timeout=timeout)
128        self._refresh_task: asyncio.Task[str] | None = None
129        self._refresh_only = refresh_only
def set_access_token(self, token: str) -> None:
143    def set_access_token(self, token: str) -> None:
144        self._access_token = token
def set_refresh_handler( self, handler: Callable[[], Coroutine[typing.Any, typing.Any, str]]) -> None:
146    def set_refresh_handler(self, handler: Callable[[], Coroutine[Any, Any, str]]) -> None:
147        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]:
268    async def request(
269        self,
270        path: str,
271        *,
272        method: str = "GET",
273        body: Any = None,
274        headers: dict[str, str] | None = None,
275        query: dict[str, Any] | None = None,
276        response_type: type[T] | None = None,
277    ) -> T | Any:
278        """Issue a request and return the JSON body.
279
280        With `response_type`, the body is validated into that type (a Pydantic
281        model or a generic alias like list[Model]); a bodyless 204 then raises
282        ValidationError, since the operation promised a typed body. Without
283        `response_type`, the raw parsed JSON is returned, or None on 204.
284        """
285        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
286
287        if response.status_code == 204:
288            if response_type is None:
289                return None
290            # A 204 on an operation that promises a typed body is a server
291            # contract violation; validating None fails loudly here instead
292            # of surfacing later as an AttributeError far from the call.
293            return _type_adapter(response_type).validate_python(None)
294
295        raw = response.json()
296        if response_type is None:
297            return raw
298        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]:
300    async def request_raw(
301        self,
302        path: str,
303        *,
304        method: str = "GET",
305        body: Any = None,
306        headers: dict[str, str] | None = None,
307        query: dict[str, Any] | None = None,
308    ) -> dict[str, Any]:
309        response = await self._execute(path, method=method, body=body, headers=headers, query=query)
310
311        return {
312            "content": response.content,
313            "mime_type": response.headers.get("content-type", "text/plain"),
314        }
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]]:
316    async def stream_sse(
317        self,
318        path: str,
319        *,
320        method: str = "GET",
321        body: Any = None,
322        headers: dict[str, str] | None = None,
323        query: dict[str, Any] | None = None,
324    ) -> AsyncIterator[dict[str, Any]]:
325        """Open a Server-Sent Events stream, yielding parsed ``{"event", "data"}``.
326
327        Backs the generated async ``stream()`` methods for ``x-sdk-streaming``
328        endpoints. Raises :class:`ApiError` on a non-2xx response before the
329        stream opens. (Token refresh is not retried mid-stream.)
330        """
331        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
332        if self._refresh_only and not path.startswith(auth_prefix):
333            raise RuntimeError(
334                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
335            )
336
337        timeout = _resolve_timeout(method, path)
338        url = f"{self._base_url}{self._transform_path(path)}"
339        sends_body = body is not None and method not in ("GET", "HEAD")
340        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
341        if sends_body:
342            req_headers["Content-Type"] = "application/json"
343        token = self._get_token()
344        if token:
345            req_headers["Authorization"] = f"Bearer {token}"
346        if headers:
347            req_headers.update(headers)
348        params = _encode_query(query)
349
350        async with self._client.stream(
351            method,
352            url,
353            json=body if sends_body else None,
354            headers=req_headers,
355            params=params,
356            timeout=timeout,
357        ) as response:
358            if response.status_code >= 400:
359                await response.aread()
360                raw: dict[str, Any] = {}
361                try:
362                    raw = response.json()
363                except Exception:
364                    pass
365                code, message = _parse_error(raw, response.status_code)
366                raise ApiError(response.status_code, code, message, raw)
367
368            event: str | None = None
369            data_lines: list[str] = []
370            async for line in response.aiter_lines():
371                if line == "":
372                    parsed = _build_sse_event(event, data_lines)
373                    if parsed is not None:
374                        yield parsed
375                    event, data_lines = None, []
376                elif line.startswith("event:"):
377                    event = line[6:].strip()
378                elif line.startswith("data:"):
379                    data_lines.append(line[5:].strip())
380            parsed = _build_sse_event(event, data_lines)
381            if parsed is not None:
382                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:
384    async def close(self) -> None:
385        await self._client.aclose()
class SyncHttpClient:
388class SyncHttpClient:
389    def __init__(
390        self,
391        *,
392        base_url: str,
393        access_token: str | None = None,
394        get_access_token: Callable[[], str | None] | None = None,
395        on_refresh_token: Callable[[], str] | None = None,
396        path_prefix: str | None = None,
397        default_headers: dict[str, str] | None = None,
398        refresh_only: bool = False,
399        timeout: float = DEFAULT_TIMEOUT_S,
400    ):
401        self._base_url = base_url.rstrip("/")
402        self._access_token = access_token
403        self._get_access_token = get_access_token
404        self._on_refresh_token = on_refresh_token
405        self._path_prefix = path_prefix
406        self._default_headers = default_headers or {}
407        self._timeout = timeout
408        self._client = httpx.Client(timeout=timeout)
409        self._refresh_only = refresh_only
410        self._refresh_lock = threading.Lock()
411
412    def _get_token(self) -> str | None:
413        if self._get_access_token:
414            return self._get_access_token()
415        return self._access_token
416
417    def _transform_path(self, path: str) -> str:
418        if self._path_prefix is None:
419            return path
420        if path.startswith(DEFAULT_API_PREFIX):
421            return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
422        return path
423
424    def set_access_token(self, token: str) -> None:
425        self._access_token = token
426
427    def set_refresh_handler(self, handler: Callable[[], str]) -> None:
428        self._on_refresh_token = handler
429
430    def _do_fetch(
431        self,
432        path: str,
433        *,
434        method: str = "GET",
435        body: Any = None,
436        headers: dict[str, str] | None = None,
437        query: dict[str, Any] | None = None,
438    ) -> httpx.Response:
439        timeout = _resolve_timeout(method, path)
440        token = self._get_token()
441        url = f"{self._base_url}{self._transform_path(path)}"
442
443        req_headers = {
444            **self._default_headers,
445            "Content-Type": "application/json",
446        }
447        if token:
448            req_headers["Authorization"] = f"Bearer {token}"
449        if headers:
450            req_headers.update(headers)
451
452        params = _encode_query(query)
453
454        return self._client.request(
455            method,
456            url,
457            json=body if body is not None and method not in ("GET", "HEAD") else None,
458            headers=req_headers,
459            params=params,
460            timeout=timeout,
461        )
462
463    def _execute(
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    ) -> httpx.Response:
472        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
473        if self._refresh_only and not path.startswith(auth_prefix):
474            raise RuntimeError(
475                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
476            )
477
478        original_token = self._get_token()
479        response = self._do_fetch(path, method=method, body=body, headers=headers, query=query)
480
481        if (
482            response.status_code == 401
483            and self._on_refresh_token
484            and not path.startswith(auth_prefix)
485        ):
486            try:
487                with self._refresh_lock:
488                    if self._get_token() == original_token:
489                        self._access_token = self._on_refresh_token()
490            except Exception:
491                pass
492            else:
493                response = self._do_fetch(
494                    path, method=method, body=body, headers=headers, query=query
495                )
496
497        if response.status_code >= 400:
498            raw_data: dict[str, Any] = {}
499            try:
500                raw_data = response.json()
501            except Exception:
502                pass
503            error_code, message = _parse_error(raw_data, response.status_code)
504            raise ApiError(response.status_code, error_code, message, raw_data)
505
506        return response
507
508    @overload
509    def request(
510        self,
511        path: str,
512        *,
513        method: str = "GET",
514        body: Any = None,
515        headers: dict[str, str] | None = None,
516        query: dict[str, Any] | None = None,
517        response_type: type[T],
518    ) -> T: ...
519
520    @overload
521    def request(
522        self,
523        path: str,
524        *,
525        method: str = "GET",
526        body: Any = None,
527        headers: dict[str, str] | None = None,
528        query: dict[str, Any] | None = None,
529        response_type: None = None,
530    ) -> Any: ...
531
532    def request(
533        self,
534        path: str,
535        *,
536        method: str = "GET",
537        body: Any = None,
538        headers: dict[str, str] | None = None,
539        query: dict[str, Any] | None = None,
540        response_type: type[T] | None = None,
541    ) -> T | Any:
542        """Issue a request and return the JSON body.
543
544        With `response_type`, the body is validated into that type (a Pydantic
545        model or a generic alias like list[Model]); a bodyless 204 then raises
546        ValidationError, since the operation promised a typed body. Without
547        `response_type`, the raw parsed JSON is returned, or None on 204.
548        """
549        response = self._execute(path, method=method, body=body, headers=headers, query=query)
550
551        if response.status_code == 204:
552            if response_type is None:
553                return None
554            # A 204 on an operation that promises a typed body is a server
555            # contract violation; validating None fails loudly here instead
556            # of surfacing later as an AttributeError far from the call.
557            return _type_adapter(response_type).validate_python(None)
558
559        raw = response.json()
560        if response_type is None:
561            return raw
562        return _type_adapter(response_type).validate_python(raw)
563
564    def request_raw(
565        self,
566        path: str,
567        *,
568        method: str = "GET",
569        body: Any = None,
570        headers: dict[str, str] | None = None,
571        query: dict[str, Any] | None = None,
572    ) -> dict[str, Any]:
573        response = self._execute(path, method=method, body=body, headers=headers, query=query)
574
575        return {
576            "content": response.content,
577            "mime_type": response.headers.get("content-type", "text/plain"),
578        }
579
580    def stream_sse_sync(
581        self,
582        path: str,
583        *,
584        method: str = "GET",
585        body: Any = None,
586        headers: dict[str, str] | None = None,
587        query: dict[str, Any] | None = None,
588    ) -> Iterator[dict[str, Any]]:
589        """Synchronous counterpart to :meth:`HttpClient.stream_sse`.
590
591        Backs the generated sync ``stream()`` methods. Raises :class:`ApiError`
592        on a non-2xx response before the stream opens.
593        """
594        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
595        if self._refresh_only and not path.startswith(auth_prefix):
596            raise RuntimeError(
597                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
598            )
599
600        timeout = _resolve_timeout(method, path)
601        url = f"{self._base_url}{self._transform_path(path)}"
602        sends_body = body is not None and method not in ("GET", "HEAD")
603        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
604        if sends_body:
605            req_headers["Content-Type"] = "application/json"
606        token = self._get_token()
607        if token:
608            req_headers["Authorization"] = f"Bearer {token}"
609        if headers:
610            req_headers.update(headers)
611        params = _encode_query(query)
612
613        with self._client.stream(
614            method,
615            url,
616            json=body if sends_body else None,
617            headers=req_headers,
618            params=params,
619            timeout=timeout,
620        ) as response:
621            if response.status_code >= 400:
622                response.read()
623                raw: dict[str, Any] = {}
624                try:
625                    raw = response.json()
626                except Exception:
627                    pass
628                code, message = _parse_error(raw, response.status_code)
629                raise ApiError(response.status_code, code, message, raw)
630
631            event: str | None = None
632            data_lines: list[str] = []
633            for line in response.iter_lines():
634                if line == "":
635                    parsed = _build_sse_event(event, data_lines)
636                    if parsed is not None:
637                        yield parsed
638                    event, data_lines = None, []
639                elif line.startswith("event:"):
640                    event = line[6:].strip()
641                elif line.startswith("data:"):
642                    data_lines.append(line[5:].strip())
643            parsed = _build_sse_event(event, data_lines)
644            if parsed is not None:
645                yield parsed
646
647    def close(self) -> None:
648        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, timeout: float = 30.0)
389    def __init__(
390        self,
391        *,
392        base_url: str,
393        access_token: str | None = None,
394        get_access_token: Callable[[], str | None] | None = None,
395        on_refresh_token: Callable[[], str] | None = None,
396        path_prefix: str | None = None,
397        default_headers: dict[str, str] | None = None,
398        refresh_only: bool = False,
399        timeout: float = DEFAULT_TIMEOUT_S,
400    ):
401        self._base_url = base_url.rstrip("/")
402        self._access_token = access_token
403        self._get_access_token = get_access_token
404        self._on_refresh_token = on_refresh_token
405        self._path_prefix = path_prefix
406        self._default_headers = default_headers or {}
407        self._timeout = timeout
408        self._client = httpx.Client(timeout=timeout)
409        self._refresh_only = refresh_only
410        self._refresh_lock = threading.Lock()
def set_access_token(self, token: str) -> None:
424    def set_access_token(self, token: str) -> None:
425        self._access_token = token
def set_refresh_handler(self, handler: Callable[[], str]) -> None:
427    def set_refresh_handler(self, handler: Callable[[], str]) -> None:
428        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]:
532    def request(
533        self,
534        path: str,
535        *,
536        method: str = "GET",
537        body: Any = None,
538        headers: dict[str, str] | None = None,
539        query: dict[str, Any] | None = None,
540        response_type: type[T] | None = None,
541    ) -> T | Any:
542        """Issue a request and return the JSON body.
543
544        With `response_type`, the body is validated into that type (a Pydantic
545        model or a generic alias like list[Model]); a bodyless 204 then raises
546        ValidationError, since the operation promised a typed body. Without
547        `response_type`, the raw parsed JSON is returned, or None on 204.
548        """
549        response = self._execute(path, method=method, body=body, headers=headers, query=query)
550
551        if response.status_code == 204:
552            if response_type is None:
553                return None
554            # A 204 on an operation that promises a typed body is a server
555            # contract violation; validating None fails loudly here instead
556            # of surfacing later as an AttributeError far from the call.
557            return _type_adapter(response_type).validate_python(None)
558
559        raw = response.json()
560        if response_type is None:
561            return raw
562        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]:
564    def request_raw(
565        self,
566        path: str,
567        *,
568        method: str = "GET",
569        body: Any = None,
570        headers: dict[str, str] | None = None,
571        query: dict[str, Any] | None = None,
572    ) -> dict[str, Any]:
573        response = self._execute(path, method=method, body=body, headers=headers, query=query)
574
575        return {
576            "content": response.content,
577            "mime_type": response.headers.get("content-type", "text/plain"),
578        }
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]]:
580    def stream_sse_sync(
581        self,
582        path: str,
583        *,
584        method: str = "GET",
585        body: Any = None,
586        headers: dict[str, str] | None = None,
587        query: dict[str, Any] | None = None,
588    ) -> Iterator[dict[str, Any]]:
589        """Synchronous counterpart to :meth:`HttpClient.stream_sse`.
590
591        Backs the generated sync ``stream()`` methods. Raises :class:`ApiError`
592        on a non-2xx response before the stream opens.
593        """
594        auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
595        if self._refresh_only and not path.startswith(auth_prefix):
596            raise RuntimeError(
597                f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
598            )
599
600        timeout = _resolve_timeout(method, path)
601        url = f"{self._base_url}{self._transform_path(path)}"
602        sends_body = body is not None and method not in ("GET", "HEAD")
603        req_headers = {**self._default_headers, "Accept": "text/event-stream"}
604        if sends_body:
605            req_headers["Content-Type"] = "application/json"
606        token = self._get_token()
607        if token:
608            req_headers["Authorization"] = f"Bearer {token}"
609        if headers:
610            req_headers.update(headers)
611        params = _encode_query(query)
612
613        with self._client.stream(
614            method,
615            url,
616            json=body if sends_body else None,
617            headers=req_headers,
618            params=params,
619            timeout=timeout,
620        ) as response:
621            if response.status_code >= 400:
622                response.read()
623                raw: dict[str, Any] = {}
624                try:
625                    raw = response.json()
626                except Exception:
627                    pass
628                code, message = _parse_error(raw, response.status_code)
629                raise ApiError(response.status_code, code, message, raw)
630
631            event: str | None = None
632            data_lines: list[str] = []
633            for line in response.iter_lines():
634                if line == "":
635                    parsed = _build_sse_event(event, data_lines)
636                    if parsed is not None:
637                        yield parsed
638                    event, data_lines = None, []
639                elif line.startswith("event:"):
640                    event = line[6:].strip()
641                elif line.startswith("data:"):
642                    data_lines.append(line[5:].strip())
643            parsed = _build_sse_event(event, data_lines)
644            if parsed is not None:
645                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:
647    def close(self) -> None:
648        self._client.close()