|
4 | 4 |
|
5 | 5 | import os |
6 | 6 | from typing import TYPE_CHECKING, Any, Dict, Type, Mapping, cast |
7 | | -from typing_extensions import Self, Literal, override |
| 7 | +from contextvars import ContextVar |
| 8 | +from typing_extensions import Self, Literal, overload, override |
8 | 9 |
|
9 | 10 | import httpx |
10 | 11 |
|
|
25 | 26 | is_mapping_t, |
26 | 27 | get_async_library, |
27 | 28 | ) |
28 | | -from ._compat import cached_property |
| 29 | +from ._compat import model_copy, cached_property |
29 | 30 | from ._models import FinalRequestOptions |
30 | 31 | from ._version import __version__ |
31 | 32 | from ._streaming import Stream as Stream, AsyncStream as AsyncStream |
32 | | -from ._exceptions import KernelError, APIStatusError |
| 33 | +from ._exceptions import KernelError, APIStatusError, APIConnectionError |
33 | 34 | from ._base_client import ( |
34 | 35 | DEFAULT_MAX_RETRIES, |
35 | 36 | SyncAPIClient, |
36 | 37 | AsyncAPIClient, |
| 38 | + _StreamT, |
| 39 | + _AsyncStreamT, |
37 | 40 | ) |
38 | 41 | from .lib.browser_routing.routing import ( |
39 | 42 | BrowserRouteCache, |
40 | 43 | BrowserRoutingConfig, |
41 | 44 | strip_direct_vm_auth, |
42 | 45 | rewrite_direct_vm_options, |
| 46 | + is_vm_unreachable_response, |
43 | 47 | browser_routing_config_from_env, |
| 48 | + should_fallback_to_control_plane, |
44 | 49 | maybe_evict_browser_route_from_response, |
45 | 50 | maybe_populate_browser_route_cache_from_response, |
46 | 51 | ) |
|
92 | 97 | "development": "https://localhost:3001/", |
93 | 98 | } |
94 | 99 |
|
| 100 | +# Set (per thread / per async task) only during a control-plane fallback attempt |
| 101 | +# so `_prepare_options` skips direct-VM rewriting for that single re-issued request. |
| 102 | +# A ContextVar (rather than an instance attribute) keeps the flag local to the |
| 103 | +# in-flight request and avoids interfering with other concurrent requests sharing |
| 104 | +# the same client. |
| 105 | +_disable_browser_routing: ContextVar[bool] = ContextVar("kernel_disable_browser_routing", default=False) |
| 106 | + |
95 | 107 |
|
96 | 108 | class Kernel(SyncAPIClient): |
97 | 109 | # client options |
@@ -307,12 +319,98 @@ def default_headers(self) -> dict[str, str | Omit]: |
307 | 319 | @override |
308 | 320 | def _prepare_options(self, options: Any) -> Any: |
309 | 321 | options = cast(Any, super()._prepare_options(options)) |
| 322 | + # During a control-plane fallback attempt we deliberately skip direct-VM |
| 323 | + # rewriting so the original request is re-issued to the control plane. |
| 324 | + if _disable_browser_routing.get(): |
| 325 | + return options |
310 | 326 | return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing) |
311 | 327 |
|
312 | 328 | @override |
313 | 329 | def _prepare_request(self, request: httpx.Request) -> None: |
314 | 330 | strip_direct_vm_auth(request, cache=self.browser_route_cache) |
315 | 331 |
|
| 332 | + @overload |
| 333 | + def request( |
| 334 | + self, |
| 335 | + cast_to: Type[ResponseT], |
| 336 | + options: FinalRequestOptions, |
| 337 | + *, |
| 338 | + stream: Literal[True], |
| 339 | + stream_cls: Type[_StreamT], |
| 340 | + ) -> _StreamT: ... |
| 341 | + |
| 342 | + @overload |
| 343 | + def request( |
| 344 | + self, |
| 345 | + cast_to: Type[ResponseT], |
| 346 | + options: FinalRequestOptions, |
| 347 | + *, |
| 348 | + stream: Literal[False] = False, |
| 349 | + ) -> ResponseT: ... |
| 350 | + |
| 351 | + @overload |
| 352 | + def request( |
| 353 | + self, |
| 354 | + cast_to: Type[ResponseT], |
| 355 | + options: FinalRequestOptions, |
| 356 | + *, |
| 357 | + stream: bool = False, |
| 358 | + stream_cls: Type[_StreamT] | None = None, |
| 359 | + ) -> ResponseT | _StreamT: ... |
| 360 | + |
| 361 | + @override |
| 362 | + def request( |
| 363 | + self, |
| 364 | + cast_to: Type[ResponseT], |
| 365 | + options: FinalRequestOptions, |
| 366 | + *, |
| 367 | + stream: bool = False, |
| 368 | + stream_cls: type[_StreamT] | None = None, |
| 369 | + ) -> ResponseT | _StreamT: |
| 370 | + # Capture the ORIGINAL, pre-rewrite control-plane options. `request` |
| 371 | + # rewrites a routed request to the VM internally (via `_prepare_options`), |
| 372 | + # so we must decide fallback eligibility from these untouched options. |
| 373 | + original_options = model_copy(options) |
| 374 | + eligible = _disable_browser_routing.get() is False and should_fallback_to_control_plane( |
| 375 | + original_options, cache=self.browser_route_cache, config=self._browser_routing |
| 376 | + ) |
| 377 | + |
| 378 | + try: |
| 379 | + return super().request(cast_to, options, stream=stream, stream_cls=stream_cls) |
| 380 | + except APIStatusError as err: |
| 381 | + # Direct-VM attempt completed but the VM is unreachable/gone. The SDK's |
| 382 | + # own retry logic has already run and given up by the time we get here. |
| 383 | + if eligible and is_vm_unreachable_response(err.response): |
| 384 | + return self._control_plane_fallback( |
| 385 | + cast_to, original_options, stream=stream, stream_cls=stream_cls |
| 386 | + ) |
| 387 | + raise |
| 388 | + except APIConnectionError: |
| 389 | + # Connection/network/transport error (incl. timeouts) talking to the VM. |
| 390 | + if eligible: |
| 391 | + return self._control_plane_fallback( |
| 392 | + cast_to, original_options, stream=stream, stream_cls=stream_cls |
| 393 | + ) |
| 394 | + raise |
| 395 | + |
| 396 | + def _control_plane_fallback( |
| 397 | + self, |
| 398 | + cast_to: Type[ResponseT], |
| 399 | + original_options: FinalRequestOptions, |
| 400 | + *, |
| 401 | + stream: bool, |
| 402 | + stream_cls: type[_StreamT] | None, |
| 403 | + ) -> ResponseT | _StreamT: |
| 404 | + # Re-issue the ORIGINAL request to the control plane exactly once: original |
| 405 | + # URL, Authorization restored (default auth headers are re-applied because |
| 406 | + # routing is disabled, so `strip_direct_vm_auth` is a no-op), no jwt param. |
| 407 | + # The flag prevents re-routing to the VM during this single attempt. |
| 408 | + token = _disable_browser_routing.set(True) |
| 409 | + try: |
| 410 | + return super().request(cast_to, model_copy(original_options), stream=stream, stream_cls=stream_cls) |
| 411 | + finally: |
| 412 | + _disable_browser_routing.reset(token) |
| 413 | + |
316 | 414 | @override |
317 | 415 | def _process_response( |
318 | 416 | self, |
@@ -638,12 +736,100 @@ def default_headers(self) -> dict[str, str | Omit]: |
638 | 736 | @override |
639 | 737 | async def _prepare_options(self, options: Any) -> Any: |
640 | 738 | options = cast(Any, await super()._prepare_options(options)) |
| 739 | + # During a control-plane fallback attempt we deliberately skip direct-VM |
| 740 | + # rewriting so the original request is re-issued to the control plane. |
| 741 | + if _disable_browser_routing.get(): |
| 742 | + return options |
641 | 743 | return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing) |
642 | 744 |
|
643 | 745 | @override |
644 | 746 | async def _prepare_request(self, request: httpx.Request) -> None: |
645 | 747 | strip_direct_vm_auth(request, cache=self.browser_route_cache) |
646 | 748 |
|
| 749 | + @overload |
| 750 | + async def request( |
| 751 | + self, |
| 752 | + cast_to: Type[ResponseT], |
| 753 | + options: FinalRequestOptions, |
| 754 | + *, |
| 755 | + stream: Literal[False] = False, |
| 756 | + ) -> ResponseT: ... |
| 757 | + |
| 758 | + @overload |
| 759 | + async def request( |
| 760 | + self, |
| 761 | + cast_to: Type[ResponseT], |
| 762 | + options: FinalRequestOptions, |
| 763 | + *, |
| 764 | + stream: Literal[True], |
| 765 | + stream_cls: type[_AsyncStreamT], |
| 766 | + ) -> _AsyncStreamT: ... |
| 767 | + |
| 768 | + @overload |
| 769 | + async def request( |
| 770 | + self, |
| 771 | + cast_to: Type[ResponseT], |
| 772 | + options: FinalRequestOptions, |
| 773 | + *, |
| 774 | + stream: bool = False, |
| 775 | + stream_cls: type[_AsyncStreamT] | None = None, |
| 776 | + ) -> ResponseT | _AsyncStreamT: ... |
| 777 | + |
| 778 | + @override |
| 779 | + async def request( |
| 780 | + self, |
| 781 | + cast_to: Type[ResponseT], |
| 782 | + options: FinalRequestOptions, |
| 783 | + *, |
| 784 | + stream: bool = False, |
| 785 | + stream_cls: type[_AsyncStreamT] | None = None, |
| 786 | + ) -> ResponseT | _AsyncStreamT: |
| 787 | + # Capture the ORIGINAL, pre-rewrite control-plane options. `request` |
| 788 | + # rewrites a routed request to the VM internally (via `_prepare_options`), |
| 789 | + # so we must decide fallback eligibility from these untouched options. |
| 790 | + original_options = model_copy(options) |
| 791 | + eligible = _disable_browser_routing.get() is False and should_fallback_to_control_plane( |
| 792 | + original_options, cache=self.browser_route_cache, config=self._browser_routing |
| 793 | + ) |
| 794 | + |
| 795 | + try: |
| 796 | + return await super().request(cast_to, options, stream=stream, stream_cls=stream_cls) |
| 797 | + except APIStatusError as err: |
| 798 | + # Direct-VM attempt completed but the VM is unreachable/gone. The SDK's |
| 799 | + # own retry logic has already run and given up by the time we get here. |
| 800 | + if eligible and is_vm_unreachable_response(err.response): |
| 801 | + return await self._control_plane_fallback( |
| 802 | + cast_to, original_options, stream=stream, stream_cls=stream_cls |
| 803 | + ) |
| 804 | + raise |
| 805 | + except APIConnectionError: |
| 806 | + # Connection/network/transport error (incl. timeouts) talking to the VM. |
| 807 | + if eligible: |
| 808 | + return await self._control_plane_fallback( |
| 809 | + cast_to, original_options, stream=stream, stream_cls=stream_cls |
| 810 | + ) |
| 811 | + raise |
| 812 | + |
| 813 | + async def _control_plane_fallback( |
| 814 | + self, |
| 815 | + cast_to: Type[ResponseT], |
| 816 | + original_options: FinalRequestOptions, |
| 817 | + *, |
| 818 | + stream: bool, |
| 819 | + stream_cls: type[_AsyncStreamT] | None, |
| 820 | + ) -> ResponseT | _AsyncStreamT: |
| 821 | + # Re-issue the ORIGINAL request to the control plane exactly once: original |
| 822 | + # URL, Authorization restored (default auth headers are re-applied because |
| 823 | + # routing is disabled, so `strip_direct_vm_auth` is a no-op), no jwt param. |
| 824 | + # The flag prevents re-routing to the VM during this single attempt. |
| 825 | + token = _disable_browser_routing.set(True) |
| 826 | + try: |
| 827 | + return await super().request( |
| 828 | + cast_to, model_copy(original_options), stream=stream, stream_cls=stream_cls |
| 829 | + ) |
| 830 | + finally: |
| 831 | + _disable_browser_routing.reset(token) |
| 832 | + |
647 | 833 | @override |
648 | 834 | async def _process_response( |
649 | 835 | self, |
|
0 commit comments