openai/openai-python

Public

mirrored from https://github.com/openai/openai-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.7.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

tests/test_client.py

1386lines · modecode

1# File generated from our OpenAPI spec by Stainless.
2
3from __future__ import annotations
4
5import gc
6import os
7import json
8import asyncio
9import inspect
10import tracemalloc
11from typing import Any, Union, cast
12from unittest import mock
13
14import httpx
15import pytest
16from respx import MockRouter
17from pydantic import ValidationError
18
19from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
20from openai._client import OpenAI, AsyncOpenAI
21from openai._models import BaseModel, FinalRequestOptions
22from openai._streaming import Stream, AsyncStream
23from openai._exceptions import OpenAIError, APIStatusError, APITimeoutError, APIResponseValidationError
24from openai._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options
25
26from .utils import update_env
27
28base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
29api_key = "My API Key"
30
31
32def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
33 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
34 url = httpx.URL(request.url)
35 return dict(url.params)
36
37
38def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
39 return 0.1
40
41
42def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int:
43 transport = client._client._transport
44 assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
45
46 pool = transport._pool
47 return len(pool._requests)
48
49
50class TestOpenAI:
51 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
52
53 @pytest.mark.respx(base_url=base_url)
54 def test_raw_response(self, respx_mock: MockRouter) -> None:
55 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
56
57 response = self.client.post("/foo", cast_to=httpx.Response)
58 assert response.status_code == 200
59 assert isinstance(response, httpx.Response)
60 assert response.json() == {"foo": "bar"}
61
62 @pytest.mark.respx(base_url=base_url)
63 def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
64 respx_mock.post("/foo").mock(
65 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
66 )
67
68 response = self.client.post("/foo", cast_to=httpx.Response)
69 assert response.status_code == 200
70 assert isinstance(response, httpx.Response)
71 assert response.json() == {"foo": "bar"}
72
73 def test_copy(self) -> None:
74 copied = self.client.copy()
75 assert id(copied) != id(self.client)
76
77 copied = self.client.copy(api_key="another My API Key")
78 assert copied.api_key == "another My API Key"
79 assert self.client.api_key == "My API Key"
80
81 def test_copy_default_options(self) -> None:
82 # options that have a default are overridden correctly
83 copied = self.client.copy(max_retries=7)
84 assert copied.max_retries == 7
85 assert self.client.max_retries == 2
86
87 copied2 = copied.copy(max_retries=6)
88 assert copied2.max_retries == 6
89 assert copied.max_retries == 7
90
91 # timeout
92 assert isinstance(self.client.timeout, httpx.Timeout)
93 copied = self.client.copy(timeout=None)
94 assert copied.timeout is None
95 assert isinstance(self.client.timeout, httpx.Timeout)
96
97 def test_copy_default_headers(self) -> None:
98 client = OpenAI(
99 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
100 )
101 assert client.default_headers["X-Foo"] == "bar"
102
103 # does not override the already given value when not specified
104 copied = client.copy()
105 assert copied.default_headers["X-Foo"] == "bar"
106
107 # merges already given headers
108 copied = client.copy(default_headers={"X-Bar": "stainless"})
109 assert copied.default_headers["X-Foo"] == "bar"
110 assert copied.default_headers["X-Bar"] == "stainless"
111
112 # uses new values for any already given headers
113 copied = client.copy(default_headers={"X-Foo": "stainless"})
114 assert copied.default_headers["X-Foo"] == "stainless"
115
116 # set_default_headers
117
118 # completely overrides already set values
119 copied = client.copy(set_default_headers={})
120 assert copied.default_headers.get("X-Foo") is None
121
122 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
123 assert copied.default_headers["X-Bar"] == "Robert"
124
125 with pytest.raises(
126 ValueError,
127 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
128 ):
129 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
130
131 def test_copy_default_query(self) -> None:
132 client = OpenAI(
133 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
134 )
135 assert _get_params(client)["foo"] == "bar"
136
137 # does not override the already given value when not specified
138 copied = client.copy()
139 assert _get_params(copied)["foo"] == "bar"
140
141 # merges already given params
142 copied = client.copy(default_query={"bar": "stainless"})
143 params = _get_params(copied)
144 assert params["foo"] == "bar"
145 assert params["bar"] == "stainless"
146
147 # uses new values for any already given headers
148 copied = client.copy(default_query={"foo": "stainless"})
149 assert _get_params(copied)["foo"] == "stainless"
150
151 # set_default_query
152
153 # completely overrides already set values
154 copied = client.copy(set_default_query={})
155 assert _get_params(copied) == {}
156
157 copied = client.copy(set_default_query={"bar": "Robert"})
158 assert _get_params(copied)["bar"] == "Robert"
159
160 with pytest.raises(
161 ValueError,
162 # TODO: update
163 match="`default_query` and `set_default_query` arguments are mutually exclusive",
164 ):
165 client.copy(set_default_query={}, default_query={"foo": "Bar"})
166
167 def test_copy_signature(self) -> None:
168 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
169 init_signature = inspect.signature(
170 # mypy doesn't like that we access the `__init__` property.
171 self.client.__init__, # type: ignore[misc]
172 )
173 copy_signature = inspect.signature(self.client.copy)
174 exclude_params = {"transport", "proxies", "_strict_response_validation"}
175
176 for name in init_signature.parameters.keys():
177 if name in exclude_params:
178 continue
179
180 copy_param = copy_signature.parameters.get(name)
181 assert copy_param is not None, f"copy() signature is missing the {name} param"
182
183 def test_copy_build_request(self) -> None:
184 options = FinalRequestOptions(method="get", url="/foo")
185
186 def build_request(options: FinalRequestOptions) -> None:
187 client = self.client.copy()
188 client._build_request(options)
189
190 # ensure that the machinery is warmed up before tracing starts.
191 build_request(options)
192 gc.collect()
193
194 tracemalloc.start(1000)
195
196 snapshot_before = tracemalloc.take_snapshot()
197
198 ITERATIONS = 10
199 for _ in range(ITERATIONS):
200 build_request(options)
201
202 gc.collect()
203 snapshot_after = tracemalloc.take_snapshot()
204
205 tracemalloc.stop()
206
207 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
208 if diff.count == 0:
209 # Avoid false positives by considering only leaks (i.e. allocations that persist).
210 return
211
212 if diff.count % ITERATIONS != 0:
213 # Avoid false positives by considering only leaks that appear per iteration.
214 return
215
216 for frame in diff.traceback:
217 if any(
218 frame.filename.endswith(fragment)
219 for fragment in [
220 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
221 #
222 # removing the decorator fixes the leak for reasons we don't understand.
223 "openai/_response.py",
224 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
225 "openai/_compat.py",
226 # Standard library leaks we don't care about.
227 "/logging/__init__.py",
228 ]
229 ):
230 return
231
232 leaks.append(diff)
233
234 leaks: list[tracemalloc.StatisticDiff] = []
235 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
236 add_leak(leaks, diff)
237 if leaks:
238 for leak in leaks:
239 print("MEMORY LEAK:", leak)
240 for frame in leak.traceback:
241 print(frame)
242 raise AssertionError()
243
244 def test_request_timeout(self) -> None:
245 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
246 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
247 assert timeout == DEFAULT_TIMEOUT
248
249 request = self.client._build_request(
250 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
251 )
252 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
253 assert timeout == httpx.Timeout(100.0)
254
255 def test_client_timeout_option(self) -> None:
256 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
257
258 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
259 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
260 assert timeout == httpx.Timeout(0)
261
262 def test_http_client_timeout_option(self) -> None:
263 # custom timeout given to the httpx client should be used
264 with httpx.Client(timeout=None) as http_client:
265 client = OpenAI(
266 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
267 )
268
269 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
270 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
271 assert timeout == httpx.Timeout(None)
272
273 # no timeout given to the httpx client should not use the httpx default
274 with httpx.Client() as http_client:
275 client = OpenAI(
276 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
277 )
278
279 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
280 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
281 assert timeout == DEFAULT_TIMEOUT
282
283 # explicitly passing the default timeout currently results in it being ignored
284 with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
285 client = OpenAI(
286 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
287 )
288
289 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
290 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
291 assert timeout == DEFAULT_TIMEOUT # our default
292
293 def test_default_headers_option(self) -> None:
294 client = OpenAI(
295 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
296 )
297 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
298 assert request.headers.get("x-foo") == "bar"
299 assert request.headers.get("x-stainless-lang") == "python"
300
301 client2 = OpenAI(
302 base_url=base_url,
303 api_key=api_key,
304 _strict_response_validation=True,
305 default_headers={
306 "X-Foo": "stainless",
307 "X-Stainless-Lang": "my-overriding-header",
308 },
309 )
310 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
311 assert request.headers.get("x-foo") == "stainless"
312 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
313
314 def test_validate_headers(self) -> None:
315 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
316 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
317 assert request.headers.get("Authorization") == f"Bearer {api_key}"
318
319 with pytest.raises(OpenAIError):
320 client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
321 _ = client2
322
323 def test_default_query_option(self) -> None:
324 client = OpenAI(
325 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
326 )
327 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
328 url = httpx.URL(request.url)
329 assert dict(url.params) == {"query_param": "bar"}
330
331 request = client._build_request(
332 FinalRequestOptions(
333 method="get",
334 url="/foo",
335 params={"foo": "baz", "query_param": "overriden"},
336 )
337 )
338 url = httpx.URL(request.url)
339 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
340
341 def test_request_extra_json(self) -> None:
342 request = self.client._build_request(
343 FinalRequestOptions(
344 method="post",
345 url="/foo",
346 json_data={"foo": "bar"},
347 extra_json={"baz": False},
348 ),
349 )
350 data = json.loads(request.content.decode("utf-8"))
351 assert data == {"foo": "bar", "baz": False}
352
353 request = self.client._build_request(
354 FinalRequestOptions(
355 method="post",
356 url="/foo",
357 extra_json={"baz": False},
358 ),
359 )
360 data = json.loads(request.content.decode("utf-8"))
361 assert data == {"baz": False}
362
363 # `extra_json` takes priority over `json_data` when keys clash
364 request = self.client._build_request(
365 FinalRequestOptions(
366 method="post",
367 url="/foo",
368 json_data={"foo": "bar", "baz": True},
369 extra_json={"baz": None},
370 ),
371 )
372 data = json.loads(request.content.decode("utf-8"))
373 assert data == {"foo": "bar", "baz": None}
374
375 def test_request_extra_headers(self) -> None:
376 request = self.client._build_request(
377 FinalRequestOptions(
378 method="post",
379 url="/foo",
380 **make_request_options(extra_headers={"X-Foo": "Foo"}),
381 ),
382 )
383 assert request.headers.get("X-Foo") == "Foo"
384
385 # `extra_headers` takes priority over `default_headers` when keys clash
386 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
387 FinalRequestOptions(
388 method="post",
389 url="/foo",
390 **make_request_options(
391 extra_headers={"X-Bar": "false"},
392 ),
393 ),
394 )
395 assert request.headers.get("X-Bar") == "false"
396
397 def test_request_extra_query(self) -> None:
398 request = self.client._build_request(
399 FinalRequestOptions(
400 method="post",
401 url="/foo",
402 **make_request_options(
403 extra_query={"my_query_param": "Foo"},
404 ),
405 ),
406 )
407 params = dict(request.url.params)
408 assert params == {"my_query_param": "Foo"}
409
410 # if both `query` and `extra_query` are given, they are merged
411 request = self.client._build_request(
412 FinalRequestOptions(
413 method="post",
414 url="/foo",
415 **make_request_options(
416 query={"bar": "1"},
417 extra_query={"foo": "2"},
418 ),
419 ),
420 )
421 params = dict(request.url.params)
422 assert params == {"bar": "1", "foo": "2"}
423
424 # `extra_query` takes priority over `query` when keys clash
425 request = self.client._build_request(
426 FinalRequestOptions(
427 method="post",
428 url="/foo",
429 **make_request_options(
430 query={"foo": "1"},
431 extra_query={"foo": "2"},
432 ),
433 ),
434 )
435 params = dict(request.url.params)
436 assert params == {"foo": "2"}
437
438 @pytest.mark.respx(base_url=base_url)
439 def test_basic_union_response(self, respx_mock: MockRouter) -> None:
440 class Model1(BaseModel):
441 name: str
442
443 class Model2(BaseModel):
444 foo: str
445
446 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
447
448 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
449 assert isinstance(response, Model2)
450 assert response.foo == "bar"
451
452 @pytest.mark.respx(base_url=base_url)
453 def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
454 """Union of objects with the same field name using a different type"""
455
456 class Model1(BaseModel):
457 foo: int
458
459 class Model2(BaseModel):
460 foo: str
461
462 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
463
464 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
465 assert isinstance(response, Model2)
466 assert response.foo == "bar"
467
468 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
469
470 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
471 assert isinstance(response, Model1)
472 assert response.foo == 1
473
474 @pytest.mark.respx(base_url=base_url)
475 def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
476 """
477 Response that sets Content-Type to something other than application/json but returns json data
478 """
479
480 class Model(BaseModel):
481 foo: int
482
483 respx_mock.get("/foo").mock(
484 return_value=httpx.Response(
485 200,
486 content=json.dumps({"foo": 2}),
487 headers={"Content-Type": "application/text"},
488 )
489 )
490
491 response = self.client.get("/foo", cast_to=Model)
492 assert isinstance(response, Model)
493 assert response.foo == 2
494
495 def test_base_url_setter(self) -> None:
496 client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
497 assert client.base_url == "https://example.com/from_init/"
498
499 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
500
501 assert client.base_url == "https://example.com/from_setter/"
502
503 def test_base_url_env(self) -> None:
504 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
505 client = OpenAI(api_key=api_key, _strict_response_validation=True)
506 assert client.base_url == "http://localhost:5000/from/env/"
507
508 @pytest.mark.parametrize(
509 "client",
510 [
511 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
512 OpenAI(
513 base_url="http://localhost:5000/custom/path/",
514 api_key=api_key,
515 _strict_response_validation=True,
516 http_client=httpx.Client(),
517 ),
518 ],
519 ids=["standard", "custom http client"],
520 )
521 def test_base_url_trailing_slash(self, client: OpenAI) -> None:
522 request = client._build_request(
523 FinalRequestOptions(
524 method="post",
525 url="/foo",
526 json_data={"foo": "bar"},
527 ),
528 )
529 assert request.url == "http://localhost:5000/custom/path/foo"
530
531 @pytest.mark.parametrize(
532 "client",
533 [
534 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
535 OpenAI(
536 base_url="http://localhost:5000/custom/path/",
537 api_key=api_key,
538 _strict_response_validation=True,
539 http_client=httpx.Client(),
540 ),
541 ],
542 ids=["standard", "custom http client"],
543 )
544 def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
545 request = client._build_request(
546 FinalRequestOptions(
547 method="post",
548 url="/foo",
549 json_data={"foo": "bar"},
550 ),
551 )
552 assert request.url == "http://localhost:5000/custom/path/foo"
553
554 @pytest.mark.parametrize(
555 "client",
556 [
557 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
558 OpenAI(
559 base_url="http://localhost:5000/custom/path/",
560 api_key=api_key,
561 _strict_response_validation=True,
562 http_client=httpx.Client(),
563 ),
564 ],
565 ids=["standard", "custom http client"],
566 )
567 def test_absolute_request_url(self, client: OpenAI) -> None:
568 request = client._build_request(
569 FinalRequestOptions(
570 method="post",
571 url="https://myapi.com/foo",
572 json_data={"foo": "bar"},
573 ),
574 )
575 assert request.url == "https://myapi.com/foo"
576
577 def test_copied_client_does_not_close_http(self) -> None:
578 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
579 assert not client.is_closed()
580
581 copied = client.copy()
582 assert copied is not client
583
584 del copied
585
586 assert not client.is_closed()
587
588 def test_client_context_manager(self) -> None:
589 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
590 with client as c2:
591 assert c2 is client
592 assert not c2.is_closed()
593 assert not client.is_closed()
594 assert client.is_closed()
595
596 @pytest.mark.respx(base_url=base_url)
597 def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
598 class Model(BaseModel):
599 foo: str
600
601 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
602
603 with pytest.raises(APIResponseValidationError) as exc:
604 self.client.get("/foo", cast_to=Model)
605
606 assert isinstance(exc.value.__cause__, ValidationError)
607
608 @pytest.mark.respx(base_url=base_url)
609 def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
610 class Model(BaseModel):
611 name: str
612
613 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
614
615 response = self.client.post("/foo", cast_to=Model, stream=True)
616 assert isinstance(response, Stream)
617
618 @pytest.mark.respx(base_url=base_url)
619 def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
620 class Model(BaseModel):
621 name: str
622
623 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
624
625 strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
626
627 with pytest.raises(APIResponseValidationError):
628 strict_client.get("/foo", cast_to=Model)
629
630 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
631
632 response = client.get("/foo", cast_to=Model)
633 assert isinstance(response, str) # type: ignore[unreachable]
634
635 @pytest.mark.parametrize(
636 "remaining_retries,retry_after,timeout",
637 [
638 [3, "20", 20],
639 [3, "0", 0.5],
640 [3, "-10", 0.5],
641 [3, "60", 60],
642 [3, "61", 0.5],
643 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
644 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
645 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
646 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
647 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
648 [3, "99999999999999999999999999999999999", 0.5],
649 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
650 [3, "", 0.5],
651 [2, "", 0.5 * 2.0],
652 [1, "", 0.5 * 4.0],
653 ],
654 )
655 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
656 def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
657 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
658
659 headers = httpx.Headers({"retry-after": retry_after})
660 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
661 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
662 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
663
664 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
665 @pytest.mark.respx(base_url=base_url)
666 def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
667 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
668
669 with pytest.raises(APITimeoutError):
670 self.client.post(
671 "/chat/completions",
672 body=dict(
673 messages=[
674 {
675 "role": "user",
676 "content": "Say this is a test",
677 }
678 ],
679 model="gpt-3.5-turbo",
680 ),
681 cast_to=httpx.Response,
682 options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}},
683 )
684
685 assert _get_open_connections(self.client) == 0
686
687 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
688 @pytest.mark.respx(base_url=base_url)
689 def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
690 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
691
692 with pytest.raises(APIStatusError):
693 self.client.post(
694 "/chat/completions",
695 body=dict(
696 messages=[
697 {
698 "role": "user",
699 "content": "Say this is a test",
700 }
701 ],
702 model="gpt-3.5-turbo",
703 ),
704 cast_to=httpx.Response,
705 options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}},
706 )
707
708 assert _get_open_connections(self.client) == 0
709
710
711class TestAsyncOpenAI:
712 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
713
714 @pytest.mark.respx(base_url=base_url)
715 @pytest.mark.asyncio
716 async def test_raw_response(self, respx_mock: MockRouter) -> None:
717 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
718
719 response = await self.client.post("/foo", cast_to=httpx.Response)
720 assert response.status_code == 200
721 assert isinstance(response, httpx.Response)
722 assert response.json() == {"foo": "bar"}
723
724 @pytest.mark.respx(base_url=base_url)
725 @pytest.mark.asyncio
726 async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
727 respx_mock.post("/foo").mock(
728 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
729 )
730
731 response = await self.client.post("/foo", cast_to=httpx.Response)
732 assert response.status_code == 200
733 assert isinstance(response, httpx.Response)
734 assert response.json() == {"foo": "bar"}
735
736 def test_copy(self) -> None:
737 copied = self.client.copy()
738 assert id(copied) != id(self.client)
739
740 copied = self.client.copy(api_key="another My API Key")
741 assert copied.api_key == "another My API Key"
742 assert self.client.api_key == "My API Key"
743
744 def test_copy_default_options(self) -> None:
745 # options that have a default are overridden correctly
746 copied = self.client.copy(max_retries=7)
747 assert copied.max_retries == 7
748 assert self.client.max_retries == 2
749
750 copied2 = copied.copy(max_retries=6)
751 assert copied2.max_retries == 6
752 assert copied.max_retries == 7
753
754 # timeout
755 assert isinstance(self.client.timeout, httpx.Timeout)
756 copied = self.client.copy(timeout=None)
757 assert copied.timeout is None
758 assert isinstance(self.client.timeout, httpx.Timeout)
759
760 def test_copy_default_headers(self) -> None:
761 client = AsyncOpenAI(
762 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
763 )
764 assert client.default_headers["X-Foo"] == "bar"
765
766 # does not override the already given value when not specified
767 copied = client.copy()
768 assert copied.default_headers["X-Foo"] == "bar"
769
770 # merges already given headers
771 copied = client.copy(default_headers={"X-Bar": "stainless"})
772 assert copied.default_headers["X-Foo"] == "bar"
773 assert copied.default_headers["X-Bar"] == "stainless"
774
775 # uses new values for any already given headers
776 copied = client.copy(default_headers={"X-Foo": "stainless"})
777 assert copied.default_headers["X-Foo"] == "stainless"
778
779 # set_default_headers
780
781 # completely overrides already set values
782 copied = client.copy(set_default_headers={})
783 assert copied.default_headers.get("X-Foo") is None
784
785 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
786 assert copied.default_headers["X-Bar"] == "Robert"
787
788 with pytest.raises(
789 ValueError,
790 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
791 ):
792 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
793
794 def test_copy_default_query(self) -> None:
795 client = AsyncOpenAI(
796 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
797 )
798 assert _get_params(client)["foo"] == "bar"
799
800 # does not override the already given value when not specified
801 copied = client.copy()
802 assert _get_params(copied)["foo"] == "bar"
803
804 # merges already given params
805 copied = client.copy(default_query={"bar": "stainless"})
806 params = _get_params(copied)
807 assert params["foo"] == "bar"
808 assert params["bar"] == "stainless"
809
810 # uses new values for any already given headers
811 copied = client.copy(default_query={"foo": "stainless"})
812 assert _get_params(copied)["foo"] == "stainless"
813
814 # set_default_query
815
816 # completely overrides already set values
817 copied = client.copy(set_default_query={})
818 assert _get_params(copied) == {}
819
820 copied = client.copy(set_default_query={"bar": "Robert"})
821 assert _get_params(copied)["bar"] == "Robert"
822
823 with pytest.raises(
824 ValueError,
825 # TODO: update
826 match="`default_query` and `set_default_query` arguments are mutually exclusive",
827 ):
828 client.copy(set_default_query={}, default_query={"foo": "Bar"})
829
830 def test_copy_signature(self) -> None:
831 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
832 init_signature = inspect.signature(
833 # mypy doesn't like that we access the `__init__` property.
834 self.client.__init__, # type: ignore[misc]
835 )
836 copy_signature = inspect.signature(self.client.copy)
837 exclude_params = {"transport", "proxies", "_strict_response_validation"}
838
839 for name in init_signature.parameters.keys():
840 if name in exclude_params:
841 continue
842
843 copy_param = copy_signature.parameters.get(name)
844 assert copy_param is not None, f"copy() signature is missing the {name} param"
845
846 def test_copy_build_request(self) -> None:
847 options = FinalRequestOptions(method="get", url="/foo")
848
849 def build_request(options: FinalRequestOptions) -> None:
850 client = self.client.copy()
851 client._build_request(options)
852
853 # ensure that the machinery is warmed up before tracing starts.
854 build_request(options)
855 gc.collect()
856
857 tracemalloc.start(1000)
858
859 snapshot_before = tracemalloc.take_snapshot()
860
861 ITERATIONS = 10
862 for _ in range(ITERATIONS):
863 build_request(options)
864
865 gc.collect()
866 snapshot_after = tracemalloc.take_snapshot()
867
868 tracemalloc.stop()
869
870 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
871 if diff.count == 0:
872 # Avoid false positives by considering only leaks (i.e. allocations that persist).
873 return
874
875 if diff.count % ITERATIONS != 0:
876 # Avoid false positives by considering only leaks that appear per iteration.
877 return
878
879 for frame in diff.traceback:
880 if any(
881 frame.filename.endswith(fragment)
882 for fragment in [
883 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
884 #
885 # removing the decorator fixes the leak for reasons we don't understand.
886 "openai/_response.py",
887 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
888 "openai/_compat.py",
889 # Standard library leaks we don't care about.
890 "/logging/__init__.py",
891 ]
892 ):
893 return
894
895 leaks.append(diff)
896
897 leaks: list[tracemalloc.StatisticDiff] = []
898 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
899 add_leak(leaks, diff)
900 if leaks:
901 for leak in leaks:
902 print("MEMORY LEAK:", leak)
903 for frame in leak.traceback:
904 print(frame)
905 raise AssertionError()
906
907 async def test_request_timeout(self) -> None:
908 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
909 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
910 assert timeout == DEFAULT_TIMEOUT
911
912 request = self.client._build_request(
913 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
914 )
915 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
916 assert timeout == httpx.Timeout(100.0)
917
918 async def test_client_timeout_option(self) -> None:
919 client = AsyncOpenAI(
920 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
921 )
922
923 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
924 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
925 assert timeout == httpx.Timeout(0)
926
927 async def test_http_client_timeout_option(self) -> None:
928 # custom timeout given to the httpx client should be used
929 async with httpx.AsyncClient(timeout=None) as http_client:
930 client = AsyncOpenAI(
931 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
932 )
933
934 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
935 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
936 assert timeout == httpx.Timeout(None)
937
938 # no timeout given to the httpx client should not use the httpx default
939 async with httpx.AsyncClient() as http_client:
940 client = AsyncOpenAI(
941 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
942 )
943
944 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
945 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
946 assert timeout == DEFAULT_TIMEOUT
947
948 # explicitly passing the default timeout currently results in it being ignored
949 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
950 client = AsyncOpenAI(
951 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
952 )
953
954 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
955 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
956 assert timeout == DEFAULT_TIMEOUT # our default
957
958 def test_default_headers_option(self) -> None:
959 client = AsyncOpenAI(
960 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
961 )
962 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
963 assert request.headers.get("x-foo") == "bar"
964 assert request.headers.get("x-stainless-lang") == "python"
965
966 client2 = AsyncOpenAI(
967 base_url=base_url,
968 api_key=api_key,
969 _strict_response_validation=True,
970 default_headers={
971 "X-Foo": "stainless",
972 "X-Stainless-Lang": "my-overriding-header",
973 },
974 )
975 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
976 assert request.headers.get("x-foo") == "stainless"
977 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
978
979 def test_validate_headers(self) -> None:
980 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
981 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
982 assert request.headers.get("Authorization") == f"Bearer {api_key}"
983
984 with pytest.raises(OpenAIError):
985 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
986 _ = client2
987
988 def test_default_query_option(self) -> None:
989 client = AsyncOpenAI(
990 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
991 )
992 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
993 url = httpx.URL(request.url)
994 assert dict(url.params) == {"query_param": "bar"}
995
996 request = client._build_request(
997 FinalRequestOptions(
998 method="get",
999 url="/foo",
1000 params={"foo": "baz", "query_param": "overriden"},
1001 )
1002 )
1003 url = httpx.URL(request.url)
1004 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
1005
1006 def test_request_extra_json(self) -> None:
1007 request = self.client._build_request(
1008 FinalRequestOptions(
1009 method="post",
1010 url="/foo",
1011 json_data={"foo": "bar"},
1012 extra_json={"baz": False},
1013 ),
1014 )
1015 data = json.loads(request.content.decode("utf-8"))
1016 assert data == {"foo": "bar", "baz": False}
1017
1018 request = self.client._build_request(
1019 FinalRequestOptions(
1020 method="post",
1021 url="/foo",
1022 extra_json={"baz": False},
1023 ),
1024 )
1025 data = json.loads(request.content.decode("utf-8"))
1026 assert data == {"baz": False}
1027
1028 # `extra_json` takes priority over `json_data` when keys clash
1029 request = self.client._build_request(
1030 FinalRequestOptions(
1031 method="post",
1032 url="/foo",
1033 json_data={"foo": "bar", "baz": True},
1034 extra_json={"baz": None},
1035 ),
1036 )
1037 data = json.loads(request.content.decode("utf-8"))
1038 assert data == {"foo": "bar", "baz": None}
1039
1040 def test_request_extra_headers(self) -> None:
1041 request = self.client._build_request(
1042 FinalRequestOptions(
1043 method="post",
1044 url="/foo",
1045 **make_request_options(extra_headers={"X-Foo": "Foo"}),
1046 ),
1047 )
1048 assert request.headers.get("X-Foo") == "Foo"
1049
1050 # `extra_headers` takes priority over `default_headers` when keys clash
1051 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1052 FinalRequestOptions(
1053 method="post",
1054 url="/foo",
1055 **make_request_options(
1056 extra_headers={"X-Bar": "false"},
1057 ),
1058 ),
1059 )
1060 assert request.headers.get("X-Bar") == "false"
1061
1062 def test_request_extra_query(self) -> None:
1063 request = self.client._build_request(
1064 FinalRequestOptions(
1065 method="post",
1066 url="/foo",
1067 **make_request_options(
1068 extra_query={"my_query_param": "Foo"},
1069 ),
1070 ),
1071 )
1072 params = dict(request.url.params)
1073 assert params == {"my_query_param": "Foo"}
1074
1075 # if both `query` and `extra_query` are given, they are merged
1076 request = self.client._build_request(
1077 FinalRequestOptions(
1078 method="post",
1079 url="/foo",
1080 **make_request_options(
1081 query={"bar": "1"},
1082 extra_query={"foo": "2"},
1083 ),
1084 ),
1085 )
1086 params = dict(request.url.params)
1087 assert params == {"bar": "1", "foo": "2"}
1088
1089 # `extra_query` takes priority over `query` when keys clash
1090 request = self.client._build_request(
1091 FinalRequestOptions(
1092 method="post",
1093 url="/foo",
1094 **make_request_options(
1095 query={"foo": "1"},
1096 extra_query={"foo": "2"},
1097 ),
1098 ),
1099 )
1100 params = dict(request.url.params)
1101 assert params == {"foo": "2"}
1102
1103 @pytest.mark.respx(base_url=base_url)
1104 async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1105 class Model1(BaseModel):
1106 name: str
1107
1108 class Model2(BaseModel):
1109 foo: str
1110
1111 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1112
1113 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1114 assert isinstance(response, Model2)
1115 assert response.foo == "bar"
1116
1117 @pytest.mark.respx(base_url=base_url)
1118 async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1119 """Union of objects with the same field name using a different type"""
1120
1121 class Model1(BaseModel):
1122 foo: int
1123
1124 class Model2(BaseModel):
1125 foo: str
1126
1127 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1128
1129 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1130 assert isinstance(response, Model2)
1131 assert response.foo == "bar"
1132
1133 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1134
1135 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1136 assert isinstance(response, Model1)
1137 assert response.foo == 1
1138
1139 @pytest.mark.respx(base_url=base_url)
1140 async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1141 """
1142 Response that sets Content-Type to something other than application/json but returns json data
1143 """
1144
1145 class Model(BaseModel):
1146 foo: int
1147
1148 respx_mock.get("/foo").mock(
1149 return_value=httpx.Response(
1150 200,
1151 content=json.dumps({"foo": 2}),
1152 headers={"Content-Type": "application/text"},
1153 )
1154 )
1155
1156 response = await self.client.get("/foo", cast_to=Model)
1157 assert isinstance(response, Model)
1158 assert response.foo == 2
1159
1160 def test_base_url_setter(self) -> None:
1161 client = AsyncOpenAI(
1162 base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1163 )
1164 assert client.base_url == "https://example.com/from_init/"
1165
1166 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1167
1168 assert client.base_url == "https://example.com/from_setter/"
1169
1170 def test_base_url_env(self) -> None:
1171 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1172 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1173 assert client.base_url == "http://localhost:5000/from/env/"
1174
1175 @pytest.mark.parametrize(
1176 "client",
1177 [
1178 AsyncOpenAI(
1179 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1180 ),
1181 AsyncOpenAI(
1182 base_url="http://localhost:5000/custom/path/",
1183 api_key=api_key,
1184 _strict_response_validation=True,
1185 http_client=httpx.AsyncClient(),
1186 ),
1187 ],
1188 ids=["standard", "custom http client"],
1189 )
1190 def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1191 request = client._build_request(
1192 FinalRequestOptions(
1193 method="post",
1194 url="/foo",
1195 json_data={"foo": "bar"},
1196 ),
1197 )
1198 assert request.url == "http://localhost:5000/custom/path/foo"
1199
1200 @pytest.mark.parametrize(
1201 "client",
1202 [
1203 AsyncOpenAI(
1204 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1205 ),
1206 AsyncOpenAI(
1207 base_url="http://localhost:5000/custom/path/",
1208 api_key=api_key,
1209 _strict_response_validation=True,
1210 http_client=httpx.AsyncClient(),
1211 ),
1212 ],
1213 ids=["standard", "custom http client"],
1214 )
1215 def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1216 request = client._build_request(
1217 FinalRequestOptions(
1218 method="post",
1219 url="/foo",
1220 json_data={"foo": "bar"},
1221 ),
1222 )
1223 assert request.url == "http://localhost:5000/custom/path/foo"
1224
1225 @pytest.mark.parametrize(
1226 "client",
1227 [
1228 AsyncOpenAI(
1229 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1230 ),
1231 AsyncOpenAI(
1232 base_url="http://localhost:5000/custom/path/",
1233 api_key=api_key,
1234 _strict_response_validation=True,
1235 http_client=httpx.AsyncClient(),
1236 ),
1237 ],
1238 ids=["standard", "custom http client"],
1239 )
1240 def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1241 request = client._build_request(
1242 FinalRequestOptions(
1243 method="post",
1244 url="https://myapi.com/foo",
1245 json_data={"foo": "bar"},
1246 ),
1247 )
1248 assert request.url == "https://myapi.com/foo"
1249
1250 async def test_copied_client_does_not_close_http(self) -> None:
1251 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1252 assert not client.is_closed()
1253
1254 copied = client.copy()
1255 assert copied is not client
1256
1257 del copied
1258
1259 await asyncio.sleep(0.2)
1260 assert not client.is_closed()
1261
1262 async def test_client_context_manager(self) -> None:
1263 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1264 async with client as c2:
1265 assert c2 is client
1266 assert not c2.is_closed()
1267 assert not client.is_closed()
1268 assert client.is_closed()
1269
1270 @pytest.mark.respx(base_url=base_url)
1271 @pytest.mark.asyncio
1272 async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1273 class Model(BaseModel):
1274 foo: str
1275
1276 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1277
1278 with pytest.raises(APIResponseValidationError) as exc:
1279 await self.client.get("/foo", cast_to=Model)
1280
1281 assert isinstance(exc.value.__cause__, ValidationError)
1282
1283 @pytest.mark.respx(base_url=base_url)
1284 @pytest.mark.asyncio
1285 async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1286 class Model(BaseModel):
1287 name: str
1288
1289 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1290
1291 response = await self.client.post("/foo", cast_to=Model, stream=True)
1292 assert isinstance(response, AsyncStream)
1293
1294 @pytest.mark.respx(base_url=base_url)
1295 @pytest.mark.asyncio
1296 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1297 class Model(BaseModel):
1298 name: str
1299
1300 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1301
1302 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1303
1304 with pytest.raises(APIResponseValidationError):
1305 await strict_client.get("/foo", cast_to=Model)
1306
1307 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1308
1309 response = await client.get("/foo", cast_to=Model)
1310 assert isinstance(response, str) # type: ignore[unreachable]
1311
1312 @pytest.mark.parametrize(
1313 "remaining_retries,retry_after,timeout",
1314 [
1315 [3, "20", 20],
1316 [3, "0", 0.5],
1317 [3, "-10", 0.5],
1318 [3, "60", 60],
1319 [3, "61", 0.5],
1320 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1321 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1322 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1323 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1324 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1325 [3, "99999999999999999999999999999999999", 0.5],
1326 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1327 [3, "", 0.5],
1328 [2, "", 0.5 * 2.0],
1329 [1, "", 0.5 * 4.0],
1330 ],
1331 )
1332 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1333 @pytest.mark.asyncio
1334 async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1335 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1336
1337 headers = httpx.Headers({"retry-after": retry_after})
1338 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1339 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1340 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1341
1342 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1343 @pytest.mark.respx(base_url=base_url)
1344 async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1345 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1346
1347 with pytest.raises(APITimeoutError):
1348 await self.client.post(
1349 "/chat/completions",
1350 body=dict(
1351 messages=[
1352 {
1353 "role": "user",
1354 "content": "Say this is a test",
1355 }
1356 ],
1357 model="gpt-3.5-turbo",
1358 ),
1359 cast_to=httpx.Response,
1360 options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}},
1361 )
1362
1363 assert _get_open_connections(self.client) == 0
1364
1365 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1366 @pytest.mark.respx(base_url=base_url)
1367 async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1368 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
1369
1370 with pytest.raises(APIStatusError):
1371 await self.client.post(
1372 "/chat/completions",
1373 body=dict(
1374 messages=[
1375 {
1376 "role": "user",
1377 "content": "Say this is a test",
1378 }
1379 ],
1380 model="gpt-3.5-turbo",
1381 ),
1382 cast_to=httpx.Response,
1383 options={"headers": {"X-Stainless-Streamed-Raw-Response": "true"}},
1384 )
1385
1386 assert _get_open_connections(self.client) == 0
1387