isaacus 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- isaacus/__init__.py +84 -0
- isaacus/_base_client.py +1969 -0
- isaacus/_client.py +397 -0
- isaacus/_compat.py +219 -0
- isaacus/_constants.py +14 -0
- isaacus/_exceptions.py +108 -0
- isaacus/_files.py +123 -0
- isaacus/_models.py +801 -0
- isaacus/_qs.py +150 -0
- isaacus/_resource.py +43 -0
- isaacus/_response.py +830 -0
- isaacus/_streaming.py +333 -0
- isaacus/_types.py +217 -0
- isaacus/_utils/__init__.py +57 -0
- isaacus/_utils/_logs.py +25 -0
- isaacus/_utils/_proxy.py +62 -0
- isaacus/_utils/_reflection.py +42 -0
- isaacus/_utils/_streams.py +12 -0
- isaacus/_utils/_sync.py +86 -0
- isaacus/_utils/_transform.py +402 -0
- isaacus/_utils/_typing.py +149 -0
- isaacus/_utils/_utils.py +414 -0
- isaacus/_version.py +4 -0
- isaacus/lib/.keep +4 -0
- isaacus/py.typed +0 -0
- isaacus/resources/__init__.py +19 -0
- isaacus/resources/classifications/__init__.py +33 -0
- isaacus/resources/classifications/classifications.py +102 -0
- isaacus/resources/classifications/universal.py +263 -0
- isaacus/types/__init__.py +3 -0
- isaacus/types/classifications/__init__.py +6 -0
- isaacus/types/classifications/universal_classification.py +55 -0
- isaacus/types/classifications/universal_create_params.py +64 -0
- isaacus-0.1.0a1.dist-info/METADATA +391 -0
- isaacus-0.1.0a1.dist-info/RECORD +37 -0
- isaacus-0.1.0a1.dist-info/WHEEL +4 -0
- isaacus-0.1.0a1.dist-info/licenses/LICENSE +201 -0
isaacus/_base_client.py
ADDED
@@ -0,0 +1,1969 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import sys
|
4
|
+
import json
|
5
|
+
import time
|
6
|
+
import uuid
|
7
|
+
import email
|
8
|
+
import asyncio
|
9
|
+
import inspect
|
10
|
+
import logging
|
11
|
+
import platform
|
12
|
+
import email.utils
|
13
|
+
from types import TracebackType
|
14
|
+
from random import random
|
15
|
+
from typing import (
|
16
|
+
TYPE_CHECKING,
|
17
|
+
Any,
|
18
|
+
Dict,
|
19
|
+
Type,
|
20
|
+
Union,
|
21
|
+
Generic,
|
22
|
+
Mapping,
|
23
|
+
TypeVar,
|
24
|
+
Iterable,
|
25
|
+
Iterator,
|
26
|
+
Optional,
|
27
|
+
Generator,
|
28
|
+
AsyncIterator,
|
29
|
+
cast,
|
30
|
+
overload,
|
31
|
+
)
|
32
|
+
from typing_extensions import Literal, override, get_origin
|
33
|
+
|
34
|
+
import anyio
|
35
|
+
import httpx
|
36
|
+
import distro
|
37
|
+
import pydantic
|
38
|
+
from httpx import URL
|
39
|
+
from pydantic import PrivateAttr
|
40
|
+
|
41
|
+
from . import _exceptions
|
42
|
+
from ._qs import Querystring
|
43
|
+
from ._files import to_httpx_files, async_to_httpx_files
|
44
|
+
from ._types import (
|
45
|
+
NOT_GIVEN,
|
46
|
+
Body,
|
47
|
+
Omit,
|
48
|
+
Query,
|
49
|
+
Headers,
|
50
|
+
Timeout,
|
51
|
+
NotGiven,
|
52
|
+
ResponseT,
|
53
|
+
AnyMapping,
|
54
|
+
PostParser,
|
55
|
+
RequestFiles,
|
56
|
+
HttpxSendArgs,
|
57
|
+
RequestOptions,
|
58
|
+
HttpxRequestFiles,
|
59
|
+
ModelBuilderProtocol,
|
60
|
+
)
|
61
|
+
from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
|
62
|
+
from ._compat import PYDANTIC_V2, model_copy, model_dump
|
63
|
+
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
|
64
|
+
from ._response import (
|
65
|
+
APIResponse,
|
66
|
+
BaseAPIResponse,
|
67
|
+
AsyncAPIResponse,
|
68
|
+
extract_response_type,
|
69
|
+
)
|
70
|
+
from ._constants import (
|
71
|
+
DEFAULT_TIMEOUT,
|
72
|
+
MAX_RETRY_DELAY,
|
73
|
+
DEFAULT_MAX_RETRIES,
|
74
|
+
INITIAL_RETRY_DELAY,
|
75
|
+
RAW_RESPONSE_HEADER,
|
76
|
+
OVERRIDE_CAST_TO_HEADER,
|
77
|
+
DEFAULT_CONNECTION_LIMITS,
|
78
|
+
)
|
79
|
+
from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder
|
80
|
+
from ._exceptions import (
|
81
|
+
APIStatusError,
|
82
|
+
APITimeoutError,
|
83
|
+
APIConnectionError,
|
84
|
+
APIResponseValidationError,
|
85
|
+
)
|
86
|
+
|
87
|
+
log: logging.Logger = logging.getLogger(__name__)
|
88
|
+
|
89
|
+
# TODO: make base page type vars covariant
|
90
|
+
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
|
91
|
+
AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]")
|
92
|
+
|
93
|
+
|
94
|
+
_T = TypeVar("_T")
|
95
|
+
_T_co = TypeVar("_T_co", covariant=True)
|
96
|
+
|
97
|
+
_StreamT = TypeVar("_StreamT", bound=Stream[Any])
|
98
|
+
_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any])
|
99
|
+
|
100
|
+
if TYPE_CHECKING:
|
101
|
+
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
|
102
|
+
else:
|
103
|
+
try:
|
104
|
+
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
|
105
|
+
except ImportError:
|
106
|
+
# taken from https://github.com/encode/httpx/blob/3ba5fe0d7ac70222590e759c31442b1cab263791/httpx/_config.py#L366
|
107
|
+
HTTPX_DEFAULT_TIMEOUT = Timeout(5.0)
|
108
|
+
|
109
|
+
|
110
|
+
class PageInfo:
|
111
|
+
"""Stores the necessary information to build the request to retrieve the next page.
|
112
|
+
|
113
|
+
Either `url` or `params` must be set.
|
114
|
+
"""
|
115
|
+
|
116
|
+
url: URL | NotGiven
|
117
|
+
params: Query | NotGiven
|
118
|
+
|
119
|
+
@overload
|
120
|
+
def __init__(
|
121
|
+
self,
|
122
|
+
*,
|
123
|
+
url: URL,
|
124
|
+
) -> None: ...
|
125
|
+
|
126
|
+
@overload
|
127
|
+
def __init__(
|
128
|
+
self,
|
129
|
+
*,
|
130
|
+
params: Query,
|
131
|
+
) -> None: ...
|
132
|
+
|
133
|
+
def __init__(
|
134
|
+
self,
|
135
|
+
*,
|
136
|
+
url: URL | NotGiven = NOT_GIVEN,
|
137
|
+
params: Query | NotGiven = NOT_GIVEN,
|
138
|
+
) -> None:
|
139
|
+
self.url = url
|
140
|
+
self.params = params
|
141
|
+
|
142
|
+
@override
|
143
|
+
def __repr__(self) -> str:
|
144
|
+
if self.url:
|
145
|
+
return f"{self.__class__.__name__}(url={self.url})"
|
146
|
+
return f"{self.__class__.__name__}(params={self.params})"
|
147
|
+
|
148
|
+
|
149
|
+
class BasePage(GenericModel, Generic[_T]):
|
150
|
+
"""
|
151
|
+
Defines the core interface for pagination.
|
152
|
+
|
153
|
+
Type Args:
|
154
|
+
ModelT: The pydantic model that represents an item in the response.
|
155
|
+
|
156
|
+
Methods:
|
157
|
+
has_next_page(): Check if there is another page available
|
158
|
+
next_page_info(): Get the necessary information to make a request for the next page
|
159
|
+
"""
|
160
|
+
|
161
|
+
_options: FinalRequestOptions = PrivateAttr()
|
162
|
+
_model: Type[_T] = PrivateAttr()
|
163
|
+
|
164
|
+
def has_next_page(self) -> bool:
|
165
|
+
items = self._get_page_items()
|
166
|
+
if not items:
|
167
|
+
return False
|
168
|
+
return self.next_page_info() is not None
|
169
|
+
|
170
|
+
def next_page_info(self) -> Optional[PageInfo]: ...
|
171
|
+
|
172
|
+
def _get_page_items(self) -> Iterable[_T]: # type: ignore[empty-body]
|
173
|
+
...
|
174
|
+
|
175
|
+
def _params_from_url(self, url: URL) -> httpx.QueryParams:
|
176
|
+
# TODO: do we have to preprocess params here?
|
177
|
+
return httpx.QueryParams(cast(Any, self._options.params)).merge(url.params)
|
178
|
+
|
179
|
+
def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
|
180
|
+
options = model_copy(self._options)
|
181
|
+
options._strip_raw_response_header()
|
182
|
+
|
183
|
+
if not isinstance(info.params, NotGiven):
|
184
|
+
options.params = {**options.params, **info.params}
|
185
|
+
return options
|
186
|
+
|
187
|
+
if not isinstance(info.url, NotGiven):
|
188
|
+
params = self._params_from_url(info.url)
|
189
|
+
url = info.url.copy_with(params=params)
|
190
|
+
options.params = dict(url.params)
|
191
|
+
options.url = str(url)
|
192
|
+
return options
|
193
|
+
|
194
|
+
raise ValueError("Unexpected PageInfo state")
|
195
|
+
|
196
|
+
|
197
|
+
class BaseSyncPage(BasePage[_T], Generic[_T]):
|
198
|
+
_client: SyncAPIClient = pydantic.PrivateAttr()
|
199
|
+
|
200
|
+
def _set_private_attributes(
|
201
|
+
self,
|
202
|
+
client: SyncAPIClient,
|
203
|
+
model: Type[_T],
|
204
|
+
options: FinalRequestOptions,
|
205
|
+
) -> None:
|
206
|
+
if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None:
|
207
|
+
self.__pydantic_private__ = {}
|
208
|
+
|
209
|
+
self._model = model
|
210
|
+
self._client = client
|
211
|
+
self._options = options
|
212
|
+
|
213
|
+
# Pydantic uses a custom `__iter__` method to support casting BaseModels
|
214
|
+
# to dictionaries. e.g. dict(model).
|
215
|
+
# As we want to support `for item in page`, this is inherently incompatible
|
216
|
+
# with the default pydantic behaviour. It is not possible to support both
|
217
|
+
# use cases at once. Fortunately, this is not a big deal as all other pydantic
|
218
|
+
# methods should continue to work as expected as there is an alternative method
|
219
|
+
# to cast a model to a dictionary, model.dict(), which is used internally
|
220
|
+
# by pydantic.
|
221
|
+
def __iter__(self) -> Iterator[_T]: # type: ignore
|
222
|
+
for page in self.iter_pages():
|
223
|
+
for item in page._get_page_items():
|
224
|
+
yield item
|
225
|
+
|
226
|
+
def iter_pages(self: SyncPageT) -> Iterator[SyncPageT]:
|
227
|
+
page = self
|
228
|
+
while True:
|
229
|
+
yield page
|
230
|
+
if page.has_next_page():
|
231
|
+
page = page.get_next_page()
|
232
|
+
else:
|
233
|
+
return
|
234
|
+
|
235
|
+
def get_next_page(self: SyncPageT) -> SyncPageT:
|
236
|
+
info = self.next_page_info()
|
237
|
+
if not info:
|
238
|
+
raise RuntimeError(
|
239
|
+
"No next page expected; please check `.has_next_page()` before calling `.get_next_page()`."
|
240
|
+
)
|
241
|
+
|
242
|
+
options = self._info_to_options(info)
|
243
|
+
return self._client._request_api_list(self._model, page=self.__class__, options=options)
|
244
|
+
|
245
|
+
|
246
|
+
class AsyncPaginator(Generic[_T, AsyncPageT]):
|
247
|
+
def __init__(
|
248
|
+
self,
|
249
|
+
client: AsyncAPIClient,
|
250
|
+
options: FinalRequestOptions,
|
251
|
+
page_cls: Type[AsyncPageT],
|
252
|
+
model: Type[_T],
|
253
|
+
) -> None:
|
254
|
+
self._model = model
|
255
|
+
self._client = client
|
256
|
+
self._options = options
|
257
|
+
self._page_cls = page_cls
|
258
|
+
|
259
|
+
def __await__(self) -> Generator[Any, None, AsyncPageT]:
|
260
|
+
return self._get_page().__await__()
|
261
|
+
|
262
|
+
async def _get_page(self) -> AsyncPageT:
|
263
|
+
def _parser(resp: AsyncPageT) -> AsyncPageT:
|
264
|
+
resp._set_private_attributes(
|
265
|
+
model=self._model,
|
266
|
+
options=self._options,
|
267
|
+
client=self._client,
|
268
|
+
)
|
269
|
+
return resp
|
270
|
+
|
271
|
+
self._options.post_parser = _parser
|
272
|
+
|
273
|
+
return await self._client.request(self._page_cls, self._options)
|
274
|
+
|
275
|
+
async def __aiter__(self) -> AsyncIterator[_T]:
|
276
|
+
# https://github.com/microsoft/pyright/issues/3464
|
277
|
+
page = cast(
|
278
|
+
AsyncPageT,
|
279
|
+
await self, # type: ignore
|
280
|
+
)
|
281
|
+
async for item in page:
|
282
|
+
yield item
|
283
|
+
|
284
|
+
|
285
|
+
class BaseAsyncPage(BasePage[_T], Generic[_T]):
|
286
|
+
_client: AsyncAPIClient = pydantic.PrivateAttr()
|
287
|
+
|
288
|
+
def _set_private_attributes(
|
289
|
+
self,
|
290
|
+
model: Type[_T],
|
291
|
+
client: AsyncAPIClient,
|
292
|
+
options: FinalRequestOptions,
|
293
|
+
) -> None:
|
294
|
+
if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None:
|
295
|
+
self.__pydantic_private__ = {}
|
296
|
+
|
297
|
+
self._model = model
|
298
|
+
self._client = client
|
299
|
+
self._options = options
|
300
|
+
|
301
|
+
async def __aiter__(self) -> AsyncIterator[_T]:
|
302
|
+
async for page in self.iter_pages():
|
303
|
+
for item in page._get_page_items():
|
304
|
+
yield item
|
305
|
+
|
306
|
+
async def iter_pages(self: AsyncPageT) -> AsyncIterator[AsyncPageT]:
|
307
|
+
page = self
|
308
|
+
while True:
|
309
|
+
yield page
|
310
|
+
if page.has_next_page():
|
311
|
+
page = await page.get_next_page()
|
312
|
+
else:
|
313
|
+
return
|
314
|
+
|
315
|
+
async def get_next_page(self: AsyncPageT) -> AsyncPageT:
|
316
|
+
info = self.next_page_info()
|
317
|
+
if not info:
|
318
|
+
raise RuntimeError(
|
319
|
+
"No next page expected; please check `.has_next_page()` before calling `.get_next_page()`."
|
320
|
+
)
|
321
|
+
|
322
|
+
options = self._info_to_options(info)
|
323
|
+
return await self._client._request_api_list(self._model, page=self.__class__, options=options)
|
324
|
+
|
325
|
+
|
326
|
+
_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient])
|
327
|
+
_DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]])
|
328
|
+
|
329
|
+
|
330
|
+
class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]):
|
331
|
+
_client: _HttpxClientT
|
332
|
+
_version: str
|
333
|
+
_base_url: URL
|
334
|
+
max_retries: int
|
335
|
+
timeout: Union[float, Timeout, None]
|
336
|
+
_strict_response_validation: bool
|
337
|
+
_idempotency_header: str | None
|
338
|
+
_default_stream_cls: type[_DefaultStreamT] | None = None
|
339
|
+
|
340
|
+
def __init__(
|
341
|
+
self,
|
342
|
+
*,
|
343
|
+
version: str,
|
344
|
+
base_url: str | URL,
|
345
|
+
_strict_response_validation: bool,
|
346
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
347
|
+
timeout: float | Timeout | None = DEFAULT_TIMEOUT,
|
348
|
+
custom_headers: Mapping[str, str] | None = None,
|
349
|
+
custom_query: Mapping[str, object] | None = None,
|
350
|
+
) -> None:
|
351
|
+
self._version = version
|
352
|
+
self._base_url = self._enforce_trailing_slash(URL(base_url))
|
353
|
+
self.max_retries = max_retries
|
354
|
+
self.timeout = timeout
|
355
|
+
self._custom_headers = custom_headers or {}
|
356
|
+
self._custom_query = custom_query or {}
|
357
|
+
self._strict_response_validation = _strict_response_validation
|
358
|
+
self._idempotency_header = None
|
359
|
+
self._platform: Platform | None = None
|
360
|
+
|
361
|
+
if max_retries is None: # pyright: ignore[reportUnnecessaryComparison]
|
362
|
+
raise TypeError(
|
363
|
+
"max_retries cannot be None. If you want to disable retries, pass `0`; if you want unlimited retries, pass `math.inf` or a very high number; if you want the default behavior, pass `isaacus.DEFAULT_MAX_RETRIES`"
|
364
|
+
)
|
365
|
+
|
366
|
+
def _enforce_trailing_slash(self, url: URL) -> URL:
|
367
|
+
if url.raw_path.endswith(b"/"):
|
368
|
+
return url
|
369
|
+
return url.copy_with(raw_path=url.raw_path + b"/")
|
370
|
+
|
371
|
+
def _make_status_error_from_response(
|
372
|
+
self,
|
373
|
+
response: httpx.Response,
|
374
|
+
) -> APIStatusError:
|
375
|
+
if response.is_closed and not response.is_stream_consumed:
|
376
|
+
# We can't read the response body as it has been closed
|
377
|
+
# before it was read. This can happen if an event hook
|
378
|
+
# raises a status error.
|
379
|
+
body = None
|
380
|
+
err_msg = f"Error code: {response.status_code}"
|
381
|
+
else:
|
382
|
+
err_text = response.text.strip()
|
383
|
+
body = err_text
|
384
|
+
|
385
|
+
try:
|
386
|
+
body = json.loads(err_text)
|
387
|
+
err_msg = f"Error code: {response.status_code} - {body}"
|
388
|
+
except Exception:
|
389
|
+
err_msg = err_text or f"Error code: {response.status_code}"
|
390
|
+
|
391
|
+
return self._make_status_error(err_msg, body=body, response=response)
|
392
|
+
|
393
|
+
def _make_status_error(
|
394
|
+
self,
|
395
|
+
err_msg: str,
|
396
|
+
*,
|
397
|
+
body: object,
|
398
|
+
response: httpx.Response,
|
399
|
+
) -> _exceptions.APIStatusError:
|
400
|
+
raise NotImplementedError()
|
401
|
+
|
402
|
+
def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers:
|
403
|
+
custom_headers = options.headers or {}
|
404
|
+
headers_dict = _merge_mappings(self.default_headers, custom_headers)
|
405
|
+
self._validate_headers(headers_dict, custom_headers)
|
406
|
+
|
407
|
+
# headers are case-insensitive while dictionaries are not.
|
408
|
+
headers = httpx.Headers(headers_dict)
|
409
|
+
|
410
|
+
idempotency_header = self._idempotency_header
|
411
|
+
if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers:
|
412
|
+
headers[idempotency_header] = options.idempotency_key or self._idempotency_key()
|
413
|
+
|
414
|
+
# Don't set these headers if they were already set or removed by the caller. We check
|
415
|
+
# `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case.
|
416
|
+
lower_custom_headers = [header.lower() for header in custom_headers]
|
417
|
+
if "x-stainless-retry-count" not in lower_custom_headers:
|
418
|
+
headers["x-stainless-retry-count"] = str(retries_taken)
|
419
|
+
if "x-stainless-read-timeout" not in lower_custom_headers:
|
420
|
+
timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
|
421
|
+
if isinstance(timeout, Timeout):
|
422
|
+
timeout = timeout.read
|
423
|
+
if timeout is not None:
|
424
|
+
headers["x-stainless-read-timeout"] = str(timeout)
|
425
|
+
|
426
|
+
return headers
|
427
|
+
|
428
|
+
def _prepare_url(self, url: str) -> URL:
|
429
|
+
"""
|
430
|
+
Merge a URL argument together with any 'base_url' on the client,
|
431
|
+
to create the URL used for the outgoing request.
|
432
|
+
"""
|
433
|
+
# Copied from httpx's `_merge_url` method.
|
434
|
+
merge_url = URL(url)
|
435
|
+
if merge_url.is_relative_url:
|
436
|
+
merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/")
|
437
|
+
return self.base_url.copy_with(raw_path=merge_raw_path)
|
438
|
+
|
439
|
+
return merge_url
|
440
|
+
|
441
|
+
def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder:
|
442
|
+
return SSEDecoder()
|
443
|
+
|
444
|
+
def _build_request(
|
445
|
+
self,
|
446
|
+
options: FinalRequestOptions,
|
447
|
+
*,
|
448
|
+
retries_taken: int = 0,
|
449
|
+
) -> httpx.Request:
|
450
|
+
if log.isEnabledFor(logging.DEBUG):
|
451
|
+
log.debug("Request options: %s", model_dump(options, exclude_unset=True))
|
452
|
+
|
453
|
+
kwargs: dict[str, Any] = {}
|
454
|
+
|
455
|
+
json_data = options.json_data
|
456
|
+
if options.extra_json is not None:
|
457
|
+
if json_data is None:
|
458
|
+
json_data = cast(Body, options.extra_json)
|
459
|
+
elif is_mapping(json_data):
|
460
|
+
json_data = _merge_mappings(json_data, options.extra_json)
|
461
|
+
else:
|
462
|
+
raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`")
|
463
|
+
|
464
|
+
headers = self._build_headers(options, retries_taken=retries_taken)
|
465
|
+
params = _merge_mappings(self.default_query, options.params)
|
466
|
+
content_type = headers.get("Content-Type")
|
467
|
+
files = options.files
|
468
|
+
|
469
|
+
# If the given Content-Type header is multipart/form-data then it
|
470
|
+
# has to be removed so that httpx can generate the header with
|
471
|
+
# additional information for us as it has to be in this form
|
472
|
+
# for the server to be able to correctly parse the request:
|
473
|
+
# multipart/form-data; boundary=---abc--
|
474
|
+
if content_type is not None and content_type.startswith("multipart/form-data"):
|
475
|
+
if "boundary" not in content_type:
|
476
|
+
# only remove the header if the boundary hasn't been explicitly set
|
477
|
+
# as the caller doesn't want httpx to come up with their own boundary
|
478
|
+
headers.pop("Content-Type")
|
479
|
+
|
480
|
+
# As we are now sending multipart/form-data instead of application/json
|
481
|
+
# we need to tell httpx to use it, https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
|
482
|
+
if json_data:
|
483
|
+
if not is_dict(json_data):
|
484
|
+
raise TypeError(
|
485
|
+
f"Expected query input to be a dictionary for multipart requests but got {type(json_data)} instead."
|
486
|
+
)
|
487
|
+
kwargs["data"] = self._serialize_multipartform(json_data)
|
488
|
+
|
489
|
+
# httpx determines whether or not to send a "multipart/form-data"
|
490
|
+
# request based on the truthiness of the "files" argument.
|
491
|
+
# This gets around that issue by generating a dict value that
|
492
|
+
# evaluates to true.
|
493
|
+
#
|
494
|
+
# https://github.com/encode/httpx/discussions/2399#discussioncomment-3814186
|
495
|
+
if not files:
|
496
|
+
files = cast(HttpxRequestFiles, ForceMultipartDict())
|
497
|
+
|
498
|
+
prepared_url = self._prepare_url(options.url)
|
499
|
+
if "_" in prepared_url.host:
|
500
|
+
# work around https://github.com/encode/httpx/discussions/2880
|
501
|
+
kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")}
|
502
|
+
|
503
|
+
# TODO: report this error to httpx
|
504
|
+
return self._client.build_request( # pyright: ignore[reportUnknownMemberType]
|
505
|
+
headers=headers,
|
506
|
+
timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout,
|
507
|
+
method=options.method,
|
508
|
+
url=prepared_url,
|
509
|
+
# the `Query` type that we use is incompatible with qs'
|
510
|
+
# `Params` type as it needs to be typed as `Mapping[str, object]`
|
511
|
+
# so that passing a `TypedDict` doesn't cause an error.
|
512
|
+
# https://github.com/microsoft/pyright/issues/3526#event-6715453066
|
513
|
+
params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None,
|
514
|
+
json=json_data if is_given(json_data) else None,
|
515
|
+
files=files,
|
516
|
+
**kwargs,
|
517
|
+
)
|
518
|
+
|
519
|
+
def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]:
|
520
|
+
items = self.qs.stringify_items(
|
521
|
+
# TODO: type ignore is required as stringify_items is well typed but we can't be
|
522
|
+
# well typed without heavy validation.
|
523
|
+
data, # type: ignore
|
524
|
+
array_format="brackets",
|
525
|
+
)
|
526
|
+
serialized: dict[str, object] = {}
|
527
|
+
for key, value in items:
|
528
|
+
existing = serialized.get(key)
|
529
|
+
|
530
|
+
if not existing:
|
531
|
+
serialized[key] = value
|
532
|
+
continue
|
533
|
+
|
534
|
+
# If a value has already been set for this key then that
|
535
|
+
# means we're sending data like `array[]=[1, 2, 3]` and we
|
536
|
+
# need to tell httpx that we want to send multiple values with
|
537
|
+
# the same key which is done by using a list or a tuple.
|
538
|
+
#
|
539
|
+
# Note: 2d arrays should never result in the same key at both
|
540
|
+
# levels so it's safe to assume that if the value is a list,
|
541
|
+
# it was because we changed it to be a list.
|
542
|
+
if is_list(existing):
|
543
|
+
existing.append(value)
|
544
|
+
else:
|
545
|
+
serialized[key] = [existing, value]
|
546
|
+
|
547
|
+
return serialized
|
548
|
+
|
549
|
+
def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]:
|
550
|
+
if not is_given(options.headers):
|
551
|
+
return cast_to
|
552
|
+
|
553
|
+
# make a copy of the headers so we don't mutate user-input
|
554
|
+
headers = dict(options.headers)
|
555
|
+
|
556
|
+
# we internally support defining a temporary header to override the
|
557
|
+
# default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response`
|
558
|
+
# see _response.py for implementation details
|
559
|
+
override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN)
|
560
|
+
if is_given(override_cast_to):
|
561
|
+
options.headers = headers
|
562
|
+
return cast(Type[ResponseT], override_cast_to)
|
563
|
+
|
564
|
+
return cast_to
|
565
|
+
|
566
|
+
def _should_stream_response_body(self, request: httpx.Request) -> bool:
|
567
|
+
return request.headers.get(RAW_RESPONSE_HEADER) == "stream" # type: ignore[no-any-return]
|
568
|
+
|
569
|
+
def _process_response_data(
|
570
|
+
self,
|
571
|
+
*,
|
572
|
+
data: object,
|
573
|
+
cast_to: type[ResponseT],
|
574
|
+
response: httpx.Response,
|
575
|
+
) -> ResponseT:
|
576
|
+
if data is None:
|
577
|
+
return cast(ResponseT, None)
|
578
|
+
|
579
|
+
if cast_to is object:
|
580
|
+
return cast(ResponseT, data)
|
581
|
+
|
582
|
+
try:
|
583
|
+
if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
|
584
|
+
return cast(ResponseT, cast_to.build(response=response, data=data))
|
585
|
+
|
586
|
+
if self._strict_response_validation:
|
587
|
+
return cast(ResponseT, validate_type(type_=cast_to, value=data))
|
588
|
+
|
589
|
+
return cast(ResponseT, construct_type(type_=cast_to, value=data))
|
590
|
+
except pydantic.ValidationError as err:
|
591
|
+
raise APIResponseValidationError(response=response, body=data) from err
|
592
|
+
|
593
|
+
@property
|
594
|
+
def qs(self) -> Querystring:
|
595
|
+
return Querystring()
|
596
|
+
|
597
|
+
@property
|
598
|
+
def custom_auth(self) -> httpx.Auth | None:
|
599
|
+
return None
|
600
|
+
|
601
|
+
@property
|
602
|
+
def auth_headers(self) -> dict[str, str]:
|
603
|
+
return {}
|
604
|
+
|
605
|
+
@property
|
606
|
+
def default_headers(self) -> dict[str, str | Omit]:
|
607
|
+
return {
|
608
|
+
"Accept": "application/json",
|
609
|
+
"Content-Type": "application/json",
|
610
|
+
"User-Agent": self.user_agent,
|
611
|
+
**self.platform_headers(),
|
612
|
+
**self.auth_headers,
|
613
|
+
**self._custom_headers,
|
614
|
+
}
|
615
|
+
|
616
|
+
@property
|
617
|
+
def default_query(self) -> dict[str, object]:
|
618
|
+
return {
|
619
|
+
**self._custom_query,
|
620
|
+
}
|
621
|
+
|
622
|
+
def _validate_headers(
|
623
|
+
self,
|
624
|
+
headers: Headers, # noqa: ARG002
|
625
|
+
custom_headers: Headers, # noqa: ARG002
|
626
|
+
) -> None:
|
627
|
+
"""Validate the given default headers and custom headers.
|
628
|
+
|
629
|
+
Does nothing by default.
|
630
|
+
"""
|
631
|
+
return
|
632
|
+
|
633
|
+
@property
|
634
|
+
def user_agent(self) -> str:
|
635
|
+
return f"{self.__class__.__name__}/Python {self._version}"
|
636
|
+
|
637
|
+
@property
|
638
|
+
def base_url(self) -> URL:
|
639
|
+
return self._base_url
|
640
|
+
|
641
|
+
@base_url.setter
|
642
|
+
def base_url(self, url: URL | str) -> None:
|
643
|
+
self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url))
|
644
|
+
|
645
|
+
def platform_headers(self) -> Dict[str, str]:
|
646
|
+
# the actual implementation is in a separate `lru_cache` decorated
|
647
|
+
# function because adding `lru_cache` to methods will leak memory
|
648
|
+
# https://github.com/python/cpython/issues/88476
|
649
|
+
return platform_headers(self._version, platform=self._platform)
|
650
|
+
|
651
|
+
def _parse_retry_after_header(self, response_headers: Optional[httpx.Headers] = None) -> float | None:
|
652
|
+
"""Returns a float of the number of seconds (not milliseconds) to wait after retrying, or None if unspecified.
|
653
|
+
|
654
|
+
About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
655
|
+
See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#syntax
|
656
|
+
"""
|
657
|
+
if response_headers is None:
|
658
|
+
return None
|
659
|
+
|
660
|
+
# First, try the non-standard `retry-after-ms` header for milliseconds,
|
661
|
+
# which is more precise than integer-seconds `retry-after`
|
662
|
+
try:
|
663
|
+
retry_ms_header = response_headers.get("retry-after-ms", None)
|
664
|
+
return float(retry_ms_header) / 1000
|
665
|
+
except (TypeError, ValueError):
|
666
|
+
pass
|
667
|
+
|
668
|
+
# Next, try parsing `retry-after` header as seconds (allowing nonstandard floats).
|
669
|
+
retry_header = response_headers.get("retry-after")
|
670
|
+
try:
|
671
|
+
# note: the spec indicates that this should only ever be an integer
|
672
|
+
# but if someone sends a float there's no reason for us to not respect it
|
673
|
+
return float(retry_header)
|
674
|
+
except (TypeError, ValueError):
|
675
|
+
pass
|
676
|
+
|
677
|
+
# Last, try parsing `retry-after` as a date.
|
678
|
+
retry_date_tuple = email.utils.parsedate_tz(retry_header)
|
679
|
+
if retry_date_tuple is None:
|
680
|
+
return None
|
681
|
+
|
682
|
+
retry_date = email.utils.mktime_tz(retry_date_tuple)
|
683
|
+
return float(retry_date - time.time())
|
684
|
+
|
685
|
+
def _calculate_retry_timeout(
|
686
|
+
self,
|
687
|
+
remaining_retries: int,
|
688
|
+
options: FinalRequestOptions,
|
689
|
+
response_headers: Optional[httpx.Headers] = None,
|
690
|
+
) -> float:
|
691
|
+
max_retries = options.get_max_retries(self.max_retries)
|
692
|
+
|
693
|
+
# If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says.
|
694
|
+
retry_after = self._parse_retry_after_header(response_headers)
|
695
|
+
if retry_after is not None and 0 < retry_after <= 60:
|
696
|
+
return retry_after
|
697
|
+
|
698
|
+
# Also cap retry count to 1000 to avoid any potential overflows with `pow`
|
699
|
+
nb_retries = min(max_retries - remaining_retries, 1000)
|
700
|
+
|
701
|
+
# Apply exponential backoff, but not more than the max.
|
702
|
+
sleep_seconds = min(INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY)
|
703
|
+
|
704
|
+
# Apply some jitter, plus-or-minus half a second.
|
705
|
+
jitter = 1 - 0.25 * random()
|
706
|
+
timeout = sleep_seconds * jitter
|
707
|
+
return timeout if timeout >= 0 else 0
|
708
|
+
|
709
|
+
def _should_retry(self, response: httpx.Response) -> bool:
|
710
|
+
# Note: this is not a standard header
|
711
|
+
should_retry_header = response.headers.get("x-should-retry")
|
712
|
+
|
713
|
+
# If the server explicitly says whether or not to retry, obey.
|
714
|
+
if should_retry_header == "true":
|
715
|
+
log.debug("Retrying as header `x-should-retry` is set to `true`")
|
716
|
+
return True
|
717
|
+
if should_retry_header == "false":
|
718
|
+
log.debug("Not retrying as header `x-should-retry` is set to `false`")
|
719
|
+
return False
|
720
|
+
|
721
|
+
# Retry on request timeouts.
|
722
|
+
if response.status_code == 408:
|
723
|
+
log.debug("Retrying due to status code %i", response.status_code)
|
724
|
+
return True
|
725
|
+
|
726
|
+
# Retry on lock timeouts.
|
727
|
+
if response.status_code == 409:
|
728
|
+
log.debug("Retrying due to status code %i", response.status_code)
|
729
|
+
return True
|
730
|
+
|
731
|
+
# Retry on rate limits.
|
732
|
+
if response.status_code == 429:
|
733
|
+
log.debug("Retrying due to status code %i", response.status_code)
|
734
|
+
return True
|
735
|
+
|
736
|
+
# Retry internal errors.
|
737
|
+
if response.status_code >= 500:
|
738
|
+
log.debug("Retrying due to status code %i", response.status_code)
|
739
|
+
return True
|
740
|
+
|
741
|
+
log.debug("Not retrying")
|
742
|
+
return False
|
743
|
+
|
744
|
+
def _idempotency_key(self) -> str:
|
745
|
+
return f"stainless-python-retry-{uuid.uuid4()}"
|
746
|
+
|
747
|
+
|
748
|
+
class _DefaultHttpxClient(httpx.Client):
|
749
|
+
def __init__(self, **kwargs: Any) -> None:
|
750
|
+
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
|
751
|
+
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
|
752
|
+
kwargs.setdefault("follow_redirects", True)
|
753
|
+
super().__init__(**kwargs)
|
754
|
+
|
755
|
+
|
756
|
+
if TYPE_CHECKING:
|
757
|
+
DefaultHttpxClient = httpx.Client
|
758
|
+
"""An alias to `httpx.Client` that provides the same defaults that this SDK
|
759
|
+
uses internally.
|
760
|
+
|
761
|
+
This is useful because overriding the `http_client` with your own instance of
|
762
|
+
`httpx.Client` will result in httpx's defaults being used, not ours.
|
763
|
+
"""
|
764
|
+
else:
|
765
|
+
DefaultHttpxClient = _DefaultHttpxClient
|
766
|
+
|
767
|
+
|
768
|
+
class SyncHttpxClientWrapper(DefaultHttpxClient):
|
769
|
+
def __del__(self) -> None:
|
770
|
+
if self.is_closed:
|
771
|
+
return
|
772
|
+
|
773
|
+
try:
|
774
|
+
self.close()
|
775
|
+
except Exception:
|
776
|
+
pass
|
777
|
+
|
778
|
+
|
779
|
+
class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
|
780
|
+
_client: httpx.Client
|
781
|
+
_default_stream_cls: type[Stream[Any]] | None = None
|
782
|
+
|
783
|
+
def __init__(
|
784
|
+
self,
|
785
|
+
*,
|
786
|
+
version: str,
|
787
|
+
base_url: str | URL,
|
788
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
789
|
+
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
|
790
|
+
http_client: httpx.Client | None = None,
|
791
|
+
custom_headers: Mapping[str, str] | None = None,
|
792
|
+
custom_query: Mapping[str, object] | None = None,
|
793
|
+
_strict_response_validation: bool,
|
794
|
+
) -> None:
|
795
|
+
if not is_given(timeout):
|
796
|
+
# if the user passed in a custom http client with a non-default
|
797
|
+
# timeout set then we use that timeout.
|
798
|
+
#
|
799
|
+
# note: there is an edge case here where the user passes in a client
|
800
|
+
# where they've explicitly set the timeout to match the default timeout
|
801
|
+
# as this check is structural, meaning that we'll think they didn't
|
802
|
+
# pass in a timeout and will ignore it
|
803
|
+
if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT:
|
804
|
+
timeout = http_client.timeout
|
805
|
+
else:
|
806
|
+
timeout = DEFAULT_TIMEOUT
|
807
|
+
|
808
|
+
if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance]
|
809
|
+
raise TypeError(
|
810
|
+
f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}"
|
811
|
+
)
|
812
|
+
|
813
|
+
super().__init__(
|
814
|
+
version=version,
|
815
|
+
# cast to a valid type because mypy doesn't understand our type narrowing
|
816
|
+
timeout=cast(Timeout, timeout),
|
817
|
+
base_url=base_url,
|
818
|
+
max_retries=max_retries,
|
819
|
+
custom_query=custom_query,
|
820
|
+
custom_headers=custom_headers,
|
821
|
+
_strict_response_validation=_strict_response_validation,
|
822
|
+
)
|
823
|
+
self._client = http_client or SyncHttpxClientWrapper(
|
824
|
+
base_url=base_url,
|
825
|
+
# cast to a valid type because mypy doesn't understand our type narrowing
|
826
|
+
timeout=cast(Timeout, timeout),
|
827
|
+
)
|
828
|
+
|
829
|
+
def is_closed(self) -> bool:
|
830
|
+
return self._client.is_closed
|
831
|
+
|
832
|
+
def close(self) -> None:
|
833
|
+
"""Close the underlying HTTPX client.
|
834
|
+
|
835
|
+
The client will *not* be usable after this.
|
836
|
+
"""
|
837
|
+
# If an error is thrown while constructing a client, self._client
|
838
|
+
# may not be present
|
839
|
+
if hasattr(self, "_client"):
|
840
|
+
self._client.close()
|
841
|
+
|
842
|
+
def __enter__(self: _T) -> _T:
|
843
|
+
return self
|
844
|
+
|
845
|
+
def __exit__(
|
846
|
+
self,
|
847
|
+
exc_type: type[BaseException] | None,
|
848
|
+
exc: BaseException | None,
|
849
|
+
exc_tb: TracebackType | None,
|
850
|
+
) -> None:
|
851
|
+
self.close()
|
852
|
+
|
853
|
+
def _prepare_options(
|
854
|
+
self,
|
855
|
+
options: FinalRequestOptions, # noqa: ARG002
|
856
|
+
) -> FinalRequestOptions:
|
857
|
+
"""Hook for mutating the given options"""
|
858
|
+
return options
|
859
|
+
|
860
|
+
def _prepare_request(
|
861
|
+
self,
|
862
|
+
request: httpx.Request, # noqa: ARG002
|
863
|
+
) -> None:
|
864
|
+
"""This method is used as a callback for mutating the `Request` object
|
865
|
+
after it has been constructed.
|
866
|
+
This is useful for cases where you want to add certain headers based off of
|
867
|
+
the request properties, e.g. `url`, `method` etc.
|
868
|
+
"""
|
869
|
+
return None
|
870
|
+
|
871
|
+
@overload
|
872
|
+
def request(
|
873
|
+
self,
|
874
|
+
cast_to: Type[ResponseT],
|
875
|
+
options: FinalRequestOptions,
|
876
|
+
remaining_retries: Optional[int] = None,
|
877
|
+
*,
|
878
|
+
stream: Literal[True],
|
879
|
+
stream_cls: Type[_StreamT],
|
880
|
+
) -> _StreamT: ...
|
881
|
+
|
882
|
+
@overload
|
883
|
+
def request(
|
884
|
+
self,
|
885
|
+
cast_to: Type[ResponseT],
|
886
|
+
options: FinalRequestOptions,
|
887
|
+
remaining_retries: Optional[int] = None,
|
888
|
+
*,
|
889
|
+
stream: Literal[False] = False,
|
890
|
+
) -> ResponseT: ...
|
891
|
+
|
892
|
+
@overload
|
893
|
+
def request(
|
894
|
+
self,
|
895
|
+
cast_to: Type[ResponseT],
|
896
|
+
options: FinalRequestOptions,
|
897
|
+
remaining_retries: Optional[int] = None,
|
898
|
+
*,
|
899
|
+
stream: bool = False,
|
900
|
+
stream_cls: Type[_StreamT] | None = None,
|
901
|
+
) -> ResponseT | _StreamT: ...
|
902
|
+
|
903
|
+
def request(
|
904
|
+
self,
|
905
|
+
cast_to: Type[ResponseT],
|
906
|
+
options: FinalRequestOptions,
|
907
|
+
remaining_retries: Optional[int] = None,
|
908
|
+
*,
|
909
|
+
stream: bool = False,
|
910
|
+
stream_cls: type[_StreamT] | None = None,
|
911
|
+
) -> ResponseT | _StreamT:
|
912
|
+
if remaining_retries is not None:
|
913
|
+
retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
|
914
|
+
else:
|
915
|
+
retries_taken = 0
|
916
|
+
|
917
|
+
return self._request(
|
918
|
+
cast_to=cast_to,
|
919
|
+
options=options,
|
920
|
+
stream=stream,
|
921
|
+
stream_cls=stream_cls,
|
922
|
+
retries_taken=retries_taken,
|
923
|
+
)
|
924
|
+
|
925
|
+
def _request(
|
926
|
+
self,
|
927
|
+
*,
|
928
|
+
cast_to: Type[ResponseT],
|
929
|
+
options: FinalRequestOptions,
|
930
|
+
retries_taken: int,
|
931
|
+
stream: bool,
|
932
|
+
stream_cls: type[_StreamT] | None,
|
933
|
+
) -> ResponseT | _StreamT:
|
934
|
+
# create a copy of the options we were given so that if the
|
935
|
+
# options are mutated later & we then retry, the retries are
|
936
|
+
# given the original options
|
937
|
+
input_options = model_copy(options)
|
938
|
+
|
939
|
+
cast_to = self._maybe_override_cast_to(cast_to, options)
|
940
|
+
options = self._prepare_options(options)
|
941
|
+
|
942
|
+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
|
943
|
+
request = self._build_request(options, retries_taken=retries_taken)
|
944
|
+
self._prepare_request(request)
|
945
|
+
|
946
|
+
kwargs: HttpxSendArgs = {}
|
947
|
+
if self.custom_auth is not None:
|
948
|
+
kwargs["auth"] = self.custom_auth
|
949
|
+
|
950
|
+
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
|
951
|
+
|
952
|
+
try:
|
953
|
+
response = self._client.send(
|
954
|
+
request,
|
955
|
+
stream=stream or self._should_stream_response_body(request=request),
|
956
|
+
**kwargs,
|
957
|
+
)
|
958
|
+
except httpx.TimeoutException as err:
|
959
|
+
log.debug("Encountered httpx.TimeoutException", exc_info=True)
|
960
|
+
|
961
|
+
if remaining_retries > 0:
|
962
|
+
return self._retry_request(
|
963
|
+
input_options,
|
964
|
+
cast_to,
|
965
|
+
retries_taken=retries_taken,
|
966
|
+
stream=stream,
|
967
|
+
stream_cls=stream_cls,
|
968
|
+
response_headers=None,
|
969
|
+
)
|
970
|
+
|
971
|
+
log.debug("Raising timeout error")
|
972
|
+
raise APITimeoutError(request=request) from err
|
973
|
+
except Exception as err:
|
974
|
+
log.debug("Encountered Exception", exc_info=True)
|
975
|
+
|
976
|
+
if remaining_retries > 0:
|
977
|
+
return self._retry_request(
|
978
|
+
input_options,
|
979
|
+
cast_to,
|
980
|
+
retries_taken=retries_taken,
|
981
|
+
stream=stream,
|
982
|
+
stream_cls=stream_cls,
|
983
|
+
response_headers=None,
|
984
|
+
)
|
985
|
+
|
986
|
+
log.debug("Raising connection error")
|
987
|
+
raise APIConnectionError(request=request) from err
|
988
|
+
|
989
|
+
log.debug(
|
990
|
+
'HTTP Response: %s %s "%i %s" %s',
|
991
|
+
request.method,
|
992
|
+
request.url,
|
993
|
+
response.status_code,
|
994
|
+
response.reason_phrase,
|
995
|
+
response.headers,
|
996
|
+
)
|
997
|
+
|
998
|
+
try:
|
999
|
+
response.raise_for_status()
|
1000
|
+
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
|
1001
|
+
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
|
1002
|
+
|
1003
|
+
if remaining_retries > 0 and self._should_retry(err.response):
|
1004
|
+
err.response.close()
|
1005
|
+
return self._retry_request(
|
1006
|
+
input_options,
|
1007
|
+
cast_to,
|
1008
|
+
retries_taken=retries_taken,
|
1009
|
+
response_headers=err.response.headers,
|
1010
|
+
stream=stream,
|
1011
|
+
stream_cls=stream_cls,
|
1012
|
+
)
|
1013
|
+
|
1014
|
+
# If the response is streamed then we need to explicitly read the response
|
1015
|
+
# to completion before attempting to access the response text.
|
1016
|
+
if not err.response.is_closed:
|
1017
|
+
err.response.read()
|
1018
|
+
|
1019
|
+
log.debug("Re-raising status error")
|
1020
|
+
raise self._make_status_error_from_response(err.response) from None
|
1021
|
+
|
1022
|
+
return self._process_response(
|
1023
|
+
cast_to=cast_to,
|
1024
|
+
options=options,
|
1025
|
+
response=response,
|
1026
|
+
stream=stream,
|
1027
|
+
stream_cls=stream_cls,
|
1028
|
+
retries_taken=retries_taken,
|
1029
|
+
)
|
1030
|
+
|
1031
|
+
def _retry_request(
|
1032
|
+
self,
|
1033
|
+
options: FinalRequestOptions,
|
1034
|
+
cast_to: Type[ResponseT],
|
1035
|
+
*,
|
1036
|
+
retries_taken: int,
|
1037
|
+
response_headers: httpx.Headers | None,
|
1038
|
+
stream: bool,
|
1039
|
+
stream_cls: type[_StreamT] | None,
|
1040
|
+
) -> ResponseT | _StreamT:
|
1041
|
+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
|
1042
|
+
if remaining_retries == 1:
|
1043
|
+
log.debug("1 retry left")
|
1044
|
+
else:
|
1045
|
+
log.debug("%i retries left", remaining_retries)
|
1046
|
+
|
1047
|
+
timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
|
1048
|
+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
|
1049
|
+
|
1050
|
+
# In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a
|
1051
|
+
# different thread if necessary.
|
1052
|
+
time.sleep(timeout)
|
1053
|
+
|
1054
|
+
return self._request(
|
1055
|
+
options=options,
|
1056
|
+
cast_to=cast_to,
|
1057
|
+
retries_taken=retries_taken + 1,
|
1058
|
+
stream=stream,
|
1059
|
+
stream_cls=stream_cls,
|
1060
|
+
)
|
1061
|
+
|
1062
|
+
def _process_response(
|
1063
|
+
self,
|
1064
|
+
*,
|
1065
|
+
cast_to: Type[ResponseT],
|
1066
|
+
options: FinalRequestOptions,
|
1067
|
+
response: httpx.Response,
|
1068
|
+
stream: bool,
|
1069
|
+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
|
1070
|
+
retries_taken: int = 0,
|
1071
|
+
) -> ResponseT:
|
1072
|
+
origin = get_origin(cast_to) or cast_to
|
1073
|
+
|
1074
|
+
if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse):
|
1075
|
+
if not issubclass(origin, APIResponse):
|
1076
|
+
raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}")
|
1077
|
+
|
1078
|
+
response_cls = cast("type[BaseAPIResponse[Any]]", cast_to)
|
1079
|
+
return cast(
|
1080
|
+
ResponseT,
|
1081
|
+
response_cls(
|
1082
|
+
raw=response,
|
1083
|
+
client=self,
|
1084
|
+
cast_to=extract_response_type(response_cls),
|
1085
|
+
stream=stream,
|
1086
|
+
stream_cls=stream_cls,
|
1087
|
+
options=options,
|
1088
|
+
retries_taken=retries_taken,
|
1089
|
+
),
|
1090
|
+
)
|
1091
|
+
|
1092
|
+
if cast_to == httpx.Response:
|
1093
|
+
return cast(ResponseT, response)
|
1094
|
+
|
1095
|
+
api_response = APIResponse(
|
1096
|
+
raw=response,
|
1097
|
+
client=self,
|
1098
|
+
cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast]
|
1099
|
+
stream=stream,
|
1100
|
+
stream_cls=stream_cls,
|
1101
|
+
options=options,
|
1102
|
+
retries_taken=retries_taken,
|
1103
|
+
)
|
1104
|
+
if bool(response.request.headers.get(RAW_RESPONSE_HEADER)):
|
1105
|
+
return cast(ResponseT, api_response)
|
1106
|
+
|
1107
|
+
return api_response.parse()
|
1108
|
+
|
1109
|
+
def _request_api_list(
|
1110
|
+
self,
|
1111
|
+
model: Type[object],
|
1112
|
+
page: Type[SyncPageT],
|
1113
|
+
options: FinalRequestOptions,
|
1114
|
+
) -> SyncPageT:
|
1115
|
+
def _parser(resp: SyncPageT) -> SyncPageT:
|
1116
|
+
resp._set_private_attributes(
|
1117
|
+
client=self,
|
1118
|
+
model=model,
|
1119
|
+
options=options,
|
1120
|
+
)
|
1121
|
+
return resp
|
1122
|
+
|
1123
|
+
options.post_parser = _parser
|
1124
|
+
|
1125
|
+
return self.request(page, options, stream=False)
|
1126
|
+
|
1127
|
+
@overload
|
1128
|
+
def get(
|
1129
|
+
self,
|
1130
|
+
path: str,
|
1131
|
+
*,
|
1132
|
+
cast_to: Type[ResponseT],
|
1133
|
+
options: RequestOptions = {},
|
1134
|
+
stream: Literal[False] = False,
|
1135
|
+
) -> ResponseT: ...
|
1136
|
+
|
1137
|
+
@overload
|
1138
|
+
def get(
|
1139
|
+
self,
|
1140
|
+
path: str,
|
1141
|
+
*,
|
1142
|
+
cast_to: Type[ResponseT],
|
1143
|
+
options: RequestOptions = {},
|
1144
|
+
stream: Literal[True],
|
1145
|
+
stream_cls: type[_StreamT],
|
1146
|
+
) -> _StreamT: ...
|
1147
|
+
|
1148
|
+
@overload
|
1149
|
+
def get(
|
1150
|
+
self,
|
1151
|
+
path: str,
|
1152
|
+
*,
|
1153
|
+
cast_to: Type[ResponseT],
|
1154
|
+
options: RequestOptions = {},
|
1155
|
+
stream: bool,
|
1156
|
+
stream_cls: type[_StreamT] | None = None,
|
1157
|
+
) -> ResponseT | _StreamT: ...
|
1158
|
+
|
1159
|
+
def get(
|
1160
|
+
self,
|
1161
|
+
path: str,
|
1162
|
+
*,
|
1163
|
+
cast_to: Type[ResponseT],
|
1164
|
+
options: RequestOptions = {},
|
1165
|
+
stream: bool = False,
|
1166
|
+
stream_cls: type[_StreamT] | None = None,
|
1167
|
+
) -> ResponseT | _StreamT:
|
1168
|
+
opts = FinalRequestOptions.construct(method="get", url=path, **options)
|
1169
|
+
# cast is required because mypy complains about returning Any even though
|
1170
|
+
# it understands the type variables
|
1171
|
+
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
|
1172
|
+
|
1173
|
+
@overload
|
1174
|
+
def post(
|
1175
|
+
self,
|
1176
|
+
path: str,
|
1177
|
+
*,
|
1178
|
+
cast_to: Type[ResponseT],
|
1179
|
+
body: Body | None = None,
|
1180
|
+
options: RequestOptions = {},
|
1181
|
+
files: RequestFiles | None = None,
|
1182
|
+
stream: Literal[False] = False,
|
1183
|
+
) -> ResponseT: ...
|
1184
|
+
|
1185
|
+
@overload
|
1186
|
+
def post(
|
1187
|
+
self,
|
1188
|
+
path: str,
|
1189
|
+
*,
|
1190
|
+
cast_to: Type[ResponseT],
|
1191
|
+
body: Body | None = None,
|
1192
|
+
options: RequestOptions = {},
|
1193
|
+
files: RequestFiles | None = None,
|
1194
|
+
stream: Literal[True],
|
1195
|
+
stream_cls: type[_StreamT],
|
1196
|
+
) -> _StreamT: ...
|
1197
|
+
|
1198
|
+
@overload
|
1199
|
+
def post(
|
1200
|
+
self,
|
1201
|
+
path: str,
|
1202
|
+
*,
|
1203
|
+
cast_to: Type[ResponseT],
|
1204
|
+
body: Body | None = None,
|
1205
|
+
options: RequestOptions = {},
|
1206
|
+
files: RequestFiles | None = None,
|
1207
|
+
stream: bool,
|
1208
|
+
stream_cls: type[_StreamT] | None = None,
|
1209
|
+
) -> ResponseT | _StreamT: ...
|
1210
|
+
|
1211
|
+
def post(
|
1212
|
+
self,
|
1213
|
+
path: str,
|
1214
|
+
*,
|
1215
|
+
cast_to: Type[ResponseT],
|
1216
|
+
body: Body | None = None,
|
1217
|
+
options: RequestOptions = {},
|
1218
|
+
files: RequestFiles | None = None,
|
1219
|
+
stream: bool = False,
|
1220
|
+
stream_cls: type[_StreamT] | None = None,
|
1221
|
+
) -> ResponseT | _StreamT:
|
1222
|
+
opts = FinalRequestOptions.construct(
|
1223
|
+
method="post", url=path, json_data=body, files=to_httpx_files(files), **options
|
1224
|
+
)
|
1225
|
+
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
|
1226
|
+
|
1227
|
+
def patch(
|
1228
|
+
self,
|
1229
|
+
path: str,
|
1230
|
+
*,
|
1231
|
+
cast_to: Type[ResponseT],
|
1232
|
+
body: Body | None = None,
|
1233
|
+
options: RequestOptions = {},
|
1234
|
+
) -> ResponseT:
|
1235
|
+
opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
|
1236
|
+
return self.request(cast_to, opts)
|
1237
|
+
|
1238
|
+
def put(
|
1239
|
+
self,
|
1240
|
+
path: str,
|
1241
|
+
*,
|
1242
|
+
cast_to: Type[ResponseT],
|
1243
|
+
body: Body | None = None,
|
1244
|
+
files: RequestFiles | None = None,
|
1245
|
+
options: RequestOptions = {},
|
1246
|
+
) -> ResponseT:
|
1247
|
+
opts = FinalRequestOptions.construct(
|
1248
|
+
method="put", url=path, json_data=body, files=to_httpx_files(files), **options
|
1249
|
+
)
|
1250
|
+
return self.request(cast_to, opts)
|
1251
|
+
|
1252
|
+
def delete(
|
1253
|
+
self,
|
1254
|
+
path: str,
|
1255
|
+
*,
|
1256
|
+
cast_to: Type[ResponseT],
|
1257
|
+
body: Body | None = None,
|
1258
|
+
options: RequestOptions = {},
|
1259
|
+
) -> ResponseT:
|
1260
|
+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
|
1261
|
+
return self.request(cast_to, opts)
|
1262
|
+
|
1263
|
+
def get_api_list(
|
1264
|
+
self,
|
1265
|
+
path: str,
|
1266
|
+
*,
|
1267
|
+
model: Type[object],
|
1268
|
+
page: Type[SyncPageT],
|
1269
|
+
body: Body | None = None,
|
1270
|
+
options: RequestOptions = {},
|
1271
|
+
method: str = "get",
|
1272
|
+
) -> SyncPageT:
|
1273
|
+
opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options)
|
1274
|
+
return self._request_api_list(model, page, opts)
|
1275
|
+
|
1276
|
+
|
1277
|
+
class _DefaultAsyncHttpxClient(httpx.AsyncClient):
|
1278
|
+
def __init__(self, **kwargs: Any) -> None:
|
1279
|
+
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
|
1280
|
+
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
|
1281
|
+
kwargs.setdefault("follow_redirects", True)
|
1282
|
+
super().__init__(**kwargs)
|
1283
|
+
|
1284
|
+
|
1285
|
+
if TYPE_CHECKING:
|
1286
|
+
DefaultAsyncHttpxClient = httpx.AsyncClient
|
1287
|
+
"""An alias to `httpx.AsyncClient` that provides the same defaults that this SDK
|
1288
|
+
uses internally.
|
1289
|
+
|
1290
|
+
This is useful because overriding the `http_client` with your own instance of
|
1291
|
+
`httpx.AsyncClient` will result in httpx's defaults being used, not ours.
|
1292
|
+
"""
|
1293
|
+
else:
|
1294
|
+
DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient
|
1295
|
+
|
1296
|
+
|
1297
|
+
class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient):
|
1298
|
+
def __del__(self) -> None:
|
1299
|
+
if self.is_closed:
|
1300
|
+
return
|
1301
|
+
|
1302
|
+
try:
|
1303
|
+
# TODO(someday): support non asyncio runtimes here
|
1304
|
+
asyncio.get_running_loop().create_task(self.aclose())
|
1305
|
+
except Exception:
|
1306
|
+
pass
|
1307
|
+
|
1308
|
+
|
1309
|
+
class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
|
1310
|
+
_client: httpx.AsyncClient
|
1311
|
+
_default_stream_cls: type[AsyncStream[Any]] | None = None
|
1312
|
+
|
1313
|
+
def __init__(
|
1314
|
+
self,
|
1315
|
+
*,
|
1316
|
+
version: str,
|
1317
|
+
base_url: str | URL,
|
1318
|
+
_strict_response_validation: bool,
|
1319
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
1320
|
+
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
|
1321
|
+
http_client: httpx.AsyncClient | None = None,
|
1322
|
+
custom_headers: Mapping[str, str] | None = None,
|
1323
|
+
custom_query: Mapping[str, object] | None = None,
|
1324
|
+
) -> None:
|
1325
|
+
if not is_given(timeout):
|
1326
|
+
# if the user passed in a custom http client with a non-default
|
1327
|
+
# timeout set then we use that timeout.
|
1328
|
+
#
|
1329
|
+
# note: there is an edge case here where the user passes in a client
|
1330
|
+
# where they've explicitly set the timeout to match the default timeout
|
1331
|
+
# as this check is structural, meaning that we'll think they didn't
|
1332
|
+
# pass in a timeout and will ignore it
|
1333
|
+
if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT:
|
1334
|
+
timeout = http_client.timeout
|
1335
|
+
else:
|
1336
|
+
timeout = DEFAULT_TIMEOUT
|
1337
|
+
|
1338
|
+
if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance]
|
1339
|
+
raise TypeError(
|
1340
|
+
f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}"
|
1341
|
+
)
|
1342
|
+
|
1343
|
+
super().__init__(
|
1344
|
+
version=version,
|
1345
|
+
base_url=base_url,
|
1346
|
+
# cast to a valid type because mypy doesn't understand our type narrowing
|
1347
|
+
timeout=cast(Timeout, timeout),
|
1348
|
+
max_retries=max_retries,
|
1349
|
+
custom_query=custom_query,
|
1350
|
+
custom_headers=custom_headers,
|
1351
|
+
_strict_response_validation=_strict_response_validation,
|
1352
|
+
)
|
1353
|
+
self._client = http_client or AsyncHttpxClientWrapper(
|
1354
|
+
base_url=base_url,
|
1355
|
+
# cast to a valid type because mypy doesn't understand our type narrowing
|
1356
|
+
timeout=cast(Timeout, timeout),
|
1357
|
+
)
|
1358
|
+
|
1359
|
+
def is_closed(self) -> bool:
|
1360
|
+
return self._client.is_closed
|
1361
|
+
|
1362
|
+
async def close(self) -> None:
|
1363
|
+
"""Close the underlying HTTPX client.
|
1364
|
+
|
1365
|
+
The client will *not* be usable after this.
|
1366
|
+
"""
|
1367
|
+
await self._client.aclose()
|
1368
|
+
|
1369
|
+
async def __aenter__(self: _T) -> _T:
|
1370
|
+
return self
|
1371
|
+
|
1372
|
+
async def __aexit__(
|
1373
|
+
self,
|
1374
|
+
exc_type: type[BaseException] | None,
|
1375
|
+
exc: BaseException | None,
|
1376
|
+
exc_tb: TracebackType | None,
|
1377
|
+
) -> None:
|
1378
|
+
await self.close()
|
1379
|
+
|
1380
|
+
async def _prepare_options(
|
1381
|
+
self,
|
1382
|
+
options: FinalRequestOptions, # noqa: ARG002
|
1383
|
+
) -> FinalRequestOptions:
|
1384
|
+
"""Hook for mutating the given options"""
|
1385
|
+
return options
|
1386
|
+
|
1387
|
+
async def _prepare_request(
|
1388
|
+
self,
|
1389
|
+
request: httpx.Request, # noqa: ARG002
|
1390
|
+
) -> None:
|
1391
|
+
"""This method is used as a callback for mutating the `Request` object
|
1392
|
+
after it has been constructed.
|
1393
|
+
This is useful for cases where you want to add certain headers based off of
|
1394
|
+
the request properties, e.g. `url`, `method` etc.
|
1395
|
+
"""
|
1396
|
+
return None
|
1397
|
+
|
1398
|
+
@overload
|
1399
|
+
async def request(
|
1400
|
+
self,
|
1401
|
+
cast_to: Type[ResponseT],
|
1402
|
+
options: FinalRequestOptions,
|
1403
|
+
*,
|
1404
|
+
stream: Literal[False] = False,
|
1405
|
+
remaining_retries: Optional[int] = None,
|
1406
|
+
) -> ResponseT: ...
|
1407
|
+
|
1408
|
+
@overload
|
1409
|
+
async def request(
|
1410
|
+
self,
|
1411
|
+
cast_to: Type[ResponseT],
|
1412
|
+
options: FinalRequestOptions,
|
1413
|
+
*,
|
1414
|
+
stream: Literal[True],
|
1415
|
+
stream_cls: type[_AsyncStreamT],
|
1416
|
+
remaining_retries: Optional[int] = None,
|
1417
|
+
) -> _AsyncStreamT: ...
|
1418
|
+
|
1419
|
+
@overload
|
1420
|
+
async def request(
|
1421
|
+
self,
|
1422
|
+
cast_to: Type[ResponseT],
|
1423
|
+
options: FinalRequestOptions,
|
1424
|
+
*,
|
1425
|
+
stream: bool,
|
1426
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1427
|
+
remaining_retries: Optional[int] = None,
|
1428
|
+
) -> ResponseT | _AsyncStreamT: ...
|
1429
|
+
|
1430
|
+
async def request(
|
1431
|
+
self,
|
1432
|
+
cast_to: Type[ResponseT],
|
1433
|
+
options: FinalRequestOptions,
|
1434
|
+
*,
|
1435
|
+
stream: bool = False,
|
1436
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1437
|
+
remaining_retries: Optional[int] = None,
|
1438
|
+
) -> ResponseT | _AsyncStreamT:
|
1439
|
+
if remaining_retries is not None:
|
1440
|
+
retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
|
1441
|
+
else:
|
1442
|
+
retries_taken = 0
|
1443
|
+
|
1444
|
+
return await self._request(
|
1445
|
+
cast_to=cast_to,
|
1446
|
+
options=options,
|
1447
|
+
stream=stream,
|
1448
|
+
stream_cls=stream_cls,
|
1449
|
+
retries_taken=retries_taken,
|
1450
|
+
)
|
1451
|
+
|
1452
|
+
async def _request(
|
1453
|
+
self,
|
1454
|
+
cast_to: Type[ResponseT],
|
1455
|
+
options: FinalRequestOptions,
|
1456
|
+
*,
|
1457
|
+
stream: bool,
|
1458
|
+
stream_cls: type[_AsyncStreamT] | None,
|
1459
|
+
retries_taken: int,
|
1460
|
+
) -> ResponseT | _AsyncStreamT:
|
1461
|
+
if self._platform is None:
|
1462
|
+
# `get_platform` can make blocking IO calls so we
|
1463
|
+
# execute it earlier while we are in an async context
|
1464
|
+
self._platform = await asyncify(get_platform)()
|
1465
|
+
|
1466
|
+
# create a copy of the options we were given so that if the
|
1467
|
+
# options are mutated later & we then retry, the retries are
|
1468
|
+
# given the original options
|
1469
|
+
input_options = model_copy(options)
|
1470
|
+
|
1471
|
+
cast_to = self._maybe_override_cast_to(cast_to, options)
|
1472
|
+
options = await self._prepare_options(options)
|
1473
|
+
|
1474
|
+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
|
1475
|
+
request = self._build_request(options, retries_taken=retries_taken)
|
1476
|
+
await self._prepare_request(request)
|
1477
|
+
|
1478
|
+
kwargs: HttpxSendArgs = {}
|
1479
|
+
if self.custom_auth is not None:
|
1480
|
+
kwargs["auth"] = self.custom_auth
|
1481
|
+
|
1482
|
+
try:
|
1483
|
+
response = await self._client.send(
|
1484
|
+
request,
|
1485
|
+
stream=stream or self._should_stream_response_body(request=request),
|
1486
|
+
**kwargs,
|
1487
|
+
)
|
1488
|
+
except httpx.TimeoutException as err:
|
1489
|
+
log.debug("Encountered httpx.TimeoutException", exc_info=True)
|
1490
|
+
|
1491
|
+
if remaining_retries > 0:
|
1492
|
+
return await self._retry_request(
|
1493
|
+
input_options,
|
1494
|
+
cast_to,
|
1495
|
+
retries_taken=retries_taken,
|
1496
|
+
stream=stream,
|
1497
|
+
stream_cls=stream_cls,
|
1498
|
+
response_headers=None,
|
1499
|
+
)
|
1500
|
+
|
1501
|
+
log.debug("Raising timeout error")
|
1502
|
+
raise APITimeoutError(request=request) from err
|
1503
|
+
except Exception as err:
|
1504
|
+
log.debug("Encountered Exception", exc_info=True)
|
1505
|
+
|
1506
|
+
if remaining_retries > 0:
|
1507
|
+
return await self._retry_request(
|
1508
|
+
input_options,
|
1509
|
+
cast_to,
|
1510
|
+
retries_taken=retries_taken,
|
1511
|
+
stream=stream,
|
1512
|
+
stream_cls=stream_cls,
|
1513
|
+
response_headers=None,
|
1514
|
+
)
|
1515
|
+
|
1516
|
+
log.debug("Raising connection error")
|
1517
|
+
raise APIConnectionError(request=request) from err
|
1518
|
+
|
1519
|
+
log.debug(
|
1520
|
+
'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase
|
1521
|
+
)
|
1522
|
+
|
1523
|
+
try:
|
1524
|
+
response.raise_for_status()
|
1525
|
+
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
|
1526
|
+
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
|
1527
|
+
|
1528
|
+
if remaining_retries > 0 and self._should_retry(err.response):
|
1529
|
+
await err.response.aclose()
|
1530
|
+
return await self._retry_request(
|
1531
|
+
input_options,
|
1532
|
+
cast_to,
|
1533
|
+
retries_taken=retries_taken,
|
1534
|
+
response_headers=err.response.headers,
|
1535
|
+
stream=stream,
|
1536
|
+
stream_cls=stream_cls,
|
1537
|
+
)
|
1538
|
+
|
1539
|
+
# If the response is streamed then we need to explicitly read the response
|
1540
|
+
# to completion before attempting to access the response text.
|
1541
|
+
if not err.response.is_closed:
|
1542
|
+
await err.response.aread()
|
1543
|
+
|
1544
|
+
log.debug("Re-raising status error")
|
1545
|
+
raise self._make_status_error_from_response(err.response) from None
|
1546
|
+
|
1547
|
+
return await self._process_response(
|
1548
|
+
cast_to=cast_to,
|
1549
|
+
options=options,
|
1550
|
+
response=response,
|
1551
|
+
stream=stream,
|
1552
|
+
stream_cls=stream_cls,
|
1553
|
+
retries_taken=retries_taken,
|
1554
|
+
)
|
1555
|
+
|
1556
|
+
async def _retry_request(
|
1557
|
+
self,
|
1558
|
+
options: FinalRequestOptions,
|
1559
|
+
cast_to: Type[ResponseT],
|
1560
|
+
*,
|
1561
|
+
retries_taken: int,
|
1562
|
+
response_headers: httpx.Headers | None,
|
1563
|
+
stream: bool,
|
1564
|
+
stream_cls: type[_AsyncStreamT] | None,
|
1565
|
+
) -> ResponseT | _AsyncStreamT:
|
1566
|
+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
|
1567
|
+
if remaining_retries == 1:
|
1568
|
+
log.debug("1 retry left")
|
1569
|
+
else:
|
1570
|
+
log.debug("%i retries left", remaining_retries)
|
1571
|
+
|
1572
|
+
timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
|
1573
|
+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
|
1574
|
+
|
1575
|
+
await anyio.sleep(timeout)
|
1576
|
+
|
1577
|
+
return await self._request(
|
1578
|
+
options=options,
|
1579
|
+
cast_to=cast_to,
|
1580
|
+
retries_taken=retries_taken + 1,
|
1581
|
+
stream=stream,
|
1582
|
+
stream_cls=stream_cls,
|
1583
|
+
)
|
1584
|
+
|
1585
|
+
async def _process_response(
|
1586
|
+
self,
|
1587
|
+
*,
|
1588
|
+
cast_to: Type[ResponseT],
|
1589
|
+
options: FinalRequestOptions,
|
1590
|
+
response: httpx.Response,
|
1591
|
+
stream: bool,
|
1592
|
+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
|
1593
|
+
retries_taken: int = 0,
|
1594
|
+
) -> ResponseT:
|
1595
|
+
origin = get_origin(cast_to) or cast_to
|
1596
|
+
|
1597
|
+
if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse):
|
1598
|
+
if not issubclass(origin, AsyncAPIResponse):
|
1599
|
+
raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}")
|
1600
|
+
|
1601
|
+
response_cls = cast("type[BaseAPIResponse[Any]]", cast_to)
|
1602
|
+
return cast(
|
1603
|
+
"ResponseT",
|
1604
|
+
response_cls(
|
1605
|
+
raw=response,
|
1606
|
+
client=self,
|
1607
|
+
cast_to=extract_response_type(response_cls),
|
1608
|
+
stream=stream,
|
1609
|
+
stream_cls=stream_cls,
|
1610
|
+
options=options,
|
1611
|
+
retries_taken=retries_taken,
|
1612
|
+
),
|
1613
|
+
)
|
1614
|
+
|
1615
|
+
if cast_to == httpx.Response:
|
1616
|
+
return cast(ResponseT, response)
|
1617
|
+
|
1618
|
+
api_response = AsyncAPIResponse(
|
1619
|
+
raw=response,
|
1620
|
+
client=self,
|
1621
|
+
cast_to=cast("type[ResponseT]", cast_to), # pyright: ignore[reportUnnecessaryCast]
|
1622
|
+
stream=stream,
|
1623
|
+
stream_cls=stream_cls,
|
1624
|
+
options=options,
|
1625
|
+
retries_taken=retries_taken,
|
1626
|
+
)
|
1627
|
+
if bool(response.request.headers.get(RAW_RESPONSE_HEADER)):
|
1628
|
+
return cast(ResponseT, api_response)
|
1629
|
+
|
1630
|
+
return await api_response.parse()
|
1631
|
+
|
1632
|
+
def _request_api_list(
|
1633
|
+
self,
|
1634
|
+
model: Type[_T],
|
1635
|
+
page: Type[AsyncPageT],
|
1636
|
+
options: FinalRequestOptions,
|
1637
|
+
) -> AsyncPaginator[_T, AsyncPageT]:
|
1638
|
+
return AsyncPaginator(client=self, options=options, page_cls=page, model=model)
|
1639
|
+
|
1640
|
+
@overload
|
1641
|
+
async def get(
|
1642
|
+
self,
|
1643
|
+
path: str,
|
1644
|
+
*,
|
1645
|
+
cast_to: Type[ResponseT],
|
1646
|
+
options: RequestOptions = {},
|
1647
|
+
stream: Literal[False] = False,
|
1648
|
+
) -> ResponseT: ...
|
1649
|
+
|
1650
|
+
@overload
|
1651
|
+
async def get(
|
1652
|
+
self,
|
1653
|
+
path: str,
|
1654
|
+
*,
|
1655
|
+
cast_to: Type[ResponseT],
|
1656
|
+
options: RequestOptions = {},
|
1657
|
+
stream: Literal[True],
|
1658
|
+
stream_cls: type[_AsyncStreamT],
|
1659
|
+
) -> _AsyncStreamT: ...
|
1660
|
+
|
1661
|
+
@overload
|
1662
|
+
async def get(
|
1663
|
+
self,
|
1664
|
+
path: str,
|
1665
|
+
*,
|
1666
|
+
cast_to: Type[ResponseT],
|
1667
|
+
options: RequestOptions = {},
|
1668
|
+
stream: bool,
|
1669
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1670
|
+
) -> ResponseT | _AsyncStreamT: ...
|
1671
|
+
|
1672
|
+
async def get(
|
1673
|
+
self,
|
1674
|
+
path: str,
|
1675
|
+
*,
|
1676
|
+
cast_to: Type[ResponseT],
|
1677
|
+
options: RequestOptions = {},
|
1678
|
+
stream: bool = False,
|
1679
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1680
|
+
) -> ResponseT | _AsyncStreamT:
|
1681
|
+
opts = FinalRequestOptions.construct(method="get", url=path, **options)
|
1682
|
+
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
|
1683
|
+
|
1684
|
+
@overload
|
1685
|
+
async def post(
|
1686
|
+
self,
|
1687
|
+
path: str,
|
1688
|
+
*,
|
1689
|
+
cast_to: Type[ResponseT],
|
1690
|
+
body: Body | None = None,
|
1691
|
+
files: RequestFiles | None = None,
|
1692
|
+
options: RequestOptions = {},
|
1693
|
+
stream: Literal[False] = False,
|
1694
|
+
) -> ResponseT: ...
|
1695
|
+
|
1696
|
+
@overload
|
1697
|
+
async def post(
|
1698
|
+
self,
|
1699
|
+
path: str,
|
1700
|
+
*,
|
1701
|
+
cast_to: Type[ResponseT],
|
1702
|
+
body: Body | None = None,
|
1703
|
+
files: RequestFiles | None = None,
|
1704
|
+
options: RequestOptions = {},
|
1705
|
+
stream: Literal[True],
|
1706
|
+
stream_cls: type[_AsyncStreamT],
|
1707
|
+
) -> _AsyncStreamT: ...
|
1708
|
+
|
1709
|
+
@overload
|
1710
|
+
async def post(
|
1711
|
+
self,
|
1712
|
+
path: str,
|
1713
|
+
*,
|
1714
|
+
cast_to: Type[ResponseT],
|
1715
|
+
body: Body | None = None,
|
1716
|
+
files: RequestFiles | None = None,
|
1717
|
+
options: RequestOptions = {},
|
1718
|
+
stream: bool,
|
1719
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1720
|
+
) -> ResponseT | _AsyncStreamT: ...
|
1721
|
+
|
1722
|
+
async def post(
|
1723
|
+
self,
|
1724
|
+
path: str,
|
1725
|
+
*,
|
1726
|
+
cast_to: Type[ResponseT],
|
1727
|
+
body: Body | None = None,
|
1728
|
+
files: RequestFiles | None = None,
|
1729
|
+
options: RequestOptions = {},
|
1730
|
+
stream: bool = False,
|
1731
|
+
stream_cls: type[_AsyncStreamT] | None = None,
|
1732
|
+
) -> ResponseT | _AsyncStreamT:
|
1733
|
+
opts = FinalRequestOptions.construct(
|
1734
|
+
method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
|
1735
|
+
)
|
1736
|
+
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
|
1737
|
+
|
1738
|
+
async def patch(
|
1739
|
+
self,
|
1740
|
+
path: str,
|
1741
|
+
*,
|
1742
|
+
cast_to: Type[ResponseT],
|
1743
|
+
body: Body | None = None,
|
1744
|
+
options: RequestOptions = {},
|
1745
|
+
) -> ResponseT:
|
1746
|
+
opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
|
1747
|
+
return await self.request(cast_to, opts)
|
1748
|
+
|
1749
|
+
async def put(
|
1750
|
+
self,
|
1751
|
+
path: str,
|
1752
|
+
*,
|
1753
|
+
cast_to: Type[ResponseT],
|
1754
|
+
body: Body | None = None,
|
1755
|
+
files: RequestFiles | None = None,
|
1756
|
+
options: RequestOptions = {},
|
1757
|
+
) -> ResponseT:
|
1758
|
+
opts = FinalRequestOptions.construct(
|
1759
|
+
method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
|
1760
|
+
)
|
1761
|
+
return await self.request(cast_to, opts)
|
1762
|
+
|
1763
|
+
async def delete(
|
1764
|
+
self,
|
1765
|
+
path: str,
|
1766
|
+
*,
|
1767
|
+
cast_to: Type[ResponseT],
|
1768
|
+
body: Body | None = None,
|
1769
|
+
options: RequestOptions = {},
|
1770
|
+
) -> ResponseT:
|
1771
|
+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
|
1772
|
+
return await self.request(cast_to, opts)
|
1773
|
+
|
1774
|
+
def get_api_list(
|
1775
|
+
self,
|
1776
|
+
path: str,
|
1777
|
+
*,
|
1778
|
+
model: Type[_T],
|
1779
|
+
page: Type[AsyncPageT],
|
1780
|
+
body: Body | None = None,
|
1781
|
+
options: RequestOptions = {},
|
1782
|
+
method: str = "get",
|
1783
|
+
) -> AsyncPaginator[_T, AsyncPageT]:
|
1784
|
+
opts = FinalRequestOptions.construct(method=method, url=path, json_data=body, **options)
|
1785
|
+
return self._request_api_list(model, page, opts)
|
1786
|
+
|
1787
|
+
|
1788
|
+
def make_request_options(
|
1789
|
+
*,
|
1790
|
+
query: Query | None = None,
|
1791
|
+
extra_headers: Headers | None = None,
|
1792
|
+
extra_query: Query | None = None,
|
1793
|
+
extra_body: Body | None = None,
|
1794
|
+
idempotency_key: str | None = None,
|
1795
|
+
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
|
1796
|
+
post_parser: PostParser | NotGiven = NOT_GIVEN,
|
1797
|
+
) -> RequestOptions:
|
1798
|
+
"""Create a dict of type RequestOptions without keys of NotGiven values."""
|
1799
|
+
options: RequestOptions = {}
|
1800
|
+
if extra_headers is not None:
|
1801
|
+
options["headers"] = extra_headers
|
1802
|
+
|
1803
|
+
if extra_body is not None:
|
1804
|
+
options["extra_json"] = cast(AnyMapping, extra_body)
|
1805
|
+
|
1806
|
+
if query is not None:
|
1807
|
+
options["params"] = query
|
1808
|
+
|
1809
|
+
if extra_query is not None:
|
1810
|
+
options["params"] = {**options.get("params", {}), **extra_query}
|
1811
|
+
|
1812
|
+
if not isinstance(timeout, NotGiven):
|
1813
|
+
options["timeout"] = timeout
|
1814
|
+
|
1815
|
+
if idempotency_key is not None:
|
1816
|
+
options["idempotency_key"] = idempotency_key
|
1817
|
+
|
1818
|
+
if is_given(post_parser):
|
1819
|
+
# internal
|
1820
|
+
options["post_parser"] = post_parser # type: ignore
|
1821
|
+
|
1822
|
+
return options
|
1823
|
+
|
1824
|
+
|
1825
|
+
class ForceMultipartDict(Dict[str, None]):
|
1826
|
+
def __bool__(self) -> bool:
|
1827
|
+
return True
|
1828
|
+
|
1829
|
+
|
1830
|
+
class OtherPlatform:
|
1831
|
+
def __init__(self, name: str) -> None:
|
1832
|
+
self.name = name
|
1833
|
+
|
1834
|
+
@override
|
1835
|
+
def __str__(self) -> str:
|
1836
|
+
return f"Other:{self.name}"
|
1837
|
+
|
1838
|
+
|
1839
|
+
Platform = Union[
|
1840
|
+
OtherPlatform,
|
1841
|
+
Literal[
|
1842
|
+
"MacOS",
|
1843
|
+
"Linux",
|
1844
|
+
"Windows",
|
1845
|
+
"FreeBSD",
|
1846
|
+
"OpenBSD",
|
1847
|
+
"iOS",
|
1848
|
+
"Android",
|
1849
|
+
"Unknown",
|
1850
|
+
],
|
1851
|
+
]
|
1852
|
+
|
1853
|
+
|
1854
|
+
def get_platform() -> Platform:
|
1855
|
+
try:
|
1856
|
+
system = platform.system().lower()
|
1857
|
+
platform_name = platform.platform().lower()
|
1858
|
+
except Exception:
|
1859
|
+
return "Unknown"
|
1860
|
+
|
1861
|
+
if "iphone" in platform_name or "ipad" in platform_name:
|
1862
|
+
# Tested using Python3IDE on an iPhone 11 and Pythonista on an iPad 7
|
1863
|
+
# system is Darwin and platform_name is a string like:
|
1864
|
+
# - Darwin-21.6.0-iPhone12,1-64bit
|
1865
|
+
# - Darwin-21.6.0-iPad7,11-64bit
|
1866
|
+
return "iOS"
|
1867
|
+
|
1868
|
+
if system == "darwin":
|
1869
|
+
return "MacOS"
|
1870
|
+
|
1871
|
+
if system == "windows":
|
1872
|
+
return "Windows"
|
1873
|
+
|
1874
|
+
if "android" in platform_name:
|
1875
|
+
# Tested using Pydroid 3
|
1876
|
+
# system is Linux and platform_name is a string like 'Linux-5.10.81-android12-9-00001-geba40aecb3b7-ab8534902-aarch64-with-libc'
|
1877
|
+
return "Android"
|
1878
|
+
|
1879
|
+
if system == "linux":
|
1880
|
+
# https://distro.readthedocs.io/en/latest/#distro.id
|
1881
|
+
distro_id = distro.id()
|
1882
|
+
if distro_id == "freebsd":
|
1883
|
+
return "FreeBSD"
|
1884
|
+
|
1885
|
+
if distro_id == "openbsd":
|
1886
|
+
return "OpenBSD"
|
1887
|
+
|
1888
|
+
return "Linux"
|
1889
|
+
|
1890
|
+
if platform_name:
|
1891
|
+
return OtherPlatform(platform_name)
|
1892
|
+
|
1893
|
+
return "Unknown"
|
1894
|
+
|
1895
|
+
|
1896
|
+
@lru_cache(maxsize=None)
|
1897
|
+
def platform_headers(version: str, *, platform: Platform | None) -> Dict[str, str]:
|
1898
|
+
return {
|
1899
|
+
"X-Stainless-Lang": "python",
|
1900
|
+
"X-Stainless-Package-Version": version,
|
1901
|
+
"X-Stainless-OS": str(platform or get_platform()),
|
1902
|
+
"X-Stainless-Arch": str(get_architecture()),
|
1903
|
+
"X-Stainless-Runtime": get_python_runtime(),
|
1904
|
+
"X-Stainless-Runtime-Version": get_python_version(),
|
1905
|
+
}
|
1906
|
+
|
1907
|
+
|
1908
|
+
class OtherArch:
|
1909
|
+
def __init__(self, name: str) -> None:
|
1910
|
+
self.name = name
|
1911
|
+
|
1912
|
+
@override
|
1913
|
+
def __str__(self) -> str:
|
1914
|
+
return f"other:{self.name}"
|
1915
|
+
|
1916
|
+
|
1917
|
+
Arch = Union[OtherArch, Literal["x32", "x64", "arm", "arm64", "unknown"]]
|
1918
|
+
|
1919
|
+
|
1920
|
+
def get_python_runtime() -> str:
|
1921
|
+
try:
|
1922
|
+
return platform.python_implementation()
|
1923
|
+
except Exception:
|
1924
|
+
return "unknown"
|
1925
|
+
|
1926
|
+
|
1927
|
+
def get_python_version() -> str:
|
1928
|
+
try:
|
1929
|
+
return platform.python_version()
|
1930
|
+
except Exception:
|
1931
|
+
return "unknown"
|
1932
|
+
|
1933
|
+
|
1934
|
+
def get_architecture() -> Arch:
|
1935
|
+
try:
|
1936
|
+
machine = platform.machine().lower()
|
1937
|
+
except Exception:
|
1938
|
+
return "unknown"
|
1939
|
+
|
1940
|
+
if machine in ("arm64", "aarch64"):
|
1941
|
+
return "arm64"
|
1942
|
+
|
1943
|
+
# TODO: untested
|
1944
|
+
if machine == "arm":
|
1945
|
+
return "arm"
|
1946
|
+
|
1947
|
+
if machine == "x86_64":
|
1948
|
+
return "x64"
|
1949
|
+
|
1950
|
+
# TODO: untested
|
1951
|
+
if sys.maxsize <= 2**32:
|
1952
|
+
return "x32"
|
1953
|
+
|
1954
|
+
if machine:
|
1955
|
+
return OtherArch(machine)
|
1956
|
+
|
1957
|
+
return "unknown"
|
1958
|
+
|
1959
|
+
|
1960
|
+
def _merge_mappings(
|
1961
|
+
obj1: Mapping[_T_co, Union[_T, Omit]],
|
1962
|
+
obj2: Mapping[_T_co, Union[_T, Omit]],
|
1963
|
+
) -> Dict[_T_co, _T]:
|
1964
|
+
"""Merge two mappings of the same type, removing any values that are instances of `Omit`.
|
1965
|
+
|
1966
|
+
In cases with duplicate keys the second mapping takes precedence.
|
1967
|
+
"""
|
1968
|
+
merged = {**obj1, **obj2}
|
1969
|
+
return {key: value for key, value in merged.items() if not isinstance(value, Omit)}
|