1515 cdp_ws_url_from_browser_like ,
1616 session_id_from_browser_like ,
1717)
18+ from ..._utils import is_given
1819from ..._compat import model_copy
1920from ..._models import FinalRequestOptions
2021from ..._constants import RAW_RESPONSE_HEADER
@@ -33,6 +34,8 @@ class BrowserRoutingConfig:
3334
3435
3536_EVICTION_HOOK_CACHE_ATTR = "_kernel_browser_route_cache"
37+ _DIRECT_VM_AUTH_HOOK_ATTR = "_kernel_direct_vm_auth_hook"
38+ _DIRECT_VM_REQUEST_MARKER_HEADER = "x-kernel-direct-vm-request"
3639_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION = "kernel_stale_direct_vm_auth"
3740_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION = "kernel_direct_vm_body_replayable"
3841
@@ -211,22 +214,24 @@ def install_stale_direct_vm_auth_eviction(client: httpx.Client, *, cache: Browse
211214 whose body read fails — the read error surfaces from `send()` instead and the
212215 dead route would stay cached, wedging every later call for that session. A
213216 response event hook runs after the status is known and before any body is
214- read, which keeps eviction independent of the body. For a caller-supplied
217+ read, which keeps eviction independent of the body. It also rejects redirects
218+ before httpx can replay an unreplayable direct request body. For a caller-supplied
215219 `http_client`, the hook is installed into that client's `event_hooks` and
216- prepended so an existing hook cannot pre-empt eviction by reading a failing
217- body or raising.
220+ prepended so an existing hook cannot pre-empt these safeguards by reading a
221+ failing body or raising.
218222 """
219223 hooks = client .event_hooks .setdefault ("response" , [])
220224 if _has_eviction_hook (hooks , cache ):
221225 return
222226
223- def evict (response : httpx .Response ) -> None :
227+ def handle_response (response : httpx .Response ) -> None :
228+ _reject_unreplayable_direct_vm_redirect (response , cache = cache )
224229 if is_stale_direct_vm_auth_response (response ):
225230 response .request .extensions [_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION ] = True
226231 maybe_evict_browser_route_from_response (response , cache = cache )
227232
228- setattr (evict , _EVICTION_HOOK_CACHE_ATTR , cache )
229- hooks .insert (0 , evict )
233+ setattr (handle_response , _EVICTION_HOOK_CACHE_ATTR , cache )
234+ hooks .insert (0 , handle_response )
230235
231236
232237def install_async_stale_direct_vm_auth_eviction (client : httpx .AsyncClient , * , cache : BrowserRouteCache ) -> None :
@@ -235,13 +240,42 @@ def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient, *, ca
235240 if _has_eviction_hook (hooks , cache ):
236241 return
237242
238- async def evict (response : httpx .Response ) -> None :
243+ async def handle_response (response : httpx .Response ) -> None :
244+ _reject_unreplayable_direct_vm_redirect (response , cache = cache )
239245 if is_stale_direct_vm_auth_response (response ):
240246 response .request .extensions [_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION ] = True
241247 maybe_evict_browser_route_from_response (response , cache = cache )
242248
243- setattr (evict , _EVICTION_HOOK_CACHE_ATTR , cache )
244- hooks .insert (0 , evict )
249+ setattr (handle_response , _EVICTION_HOOK_CACHE_ATTR , cache )
250+ hooks .insert (0 , handle_response )
251+
252+
253+ def install_direct_vm_auth_stripping (client : httpx .Client ) -> None :
254+ """Remove Authorization after httpx auth and existing request hooks run."""
255+ hooks = client .event_hooks .setdefault ("request" , [])
256+ if any (getattr (hook , _DIRECT_VM_AUTH_HOOK_ATTR , False ) for hook in hooks ):
257+ return
258+
259+ def strip_auth (request : httpx .Request ) -> None :
260+ if _is_direct_vm_request (request ):
261+ request .headers .pop ("Authorization" , None )
262+
263+ setattr (strip_auth , _DIRECT_VM_AUTH_HOOK_ATTR , True )
264+ hooks .append (strip_auth )
265+
266+
267+ def install_async_direct_vm_auth_stripping (client : httpx .AsyncClient ) -> None :
268+ """Async counterpart of `install_direct_vm_auth_stripping`."""
269+ hooks = client .event_hooks .setdefault ("request" , [])
270+ if any (getattr (hook , _DIRECT_VM_AUTH_HOOK_ATTR , False ) for hook in hooks ):
271+ return
272+
273+ async def strip_auth (request : httpx .Request ) -> None :
274+ if _is_direct_vm_request (request ):
275+ request .headers .pop ("Authorization" , None )
276+
277+ setattr (strip_auth , _DIRECT_VM_AUTH_HOOK_ATTR , True )
278+ hooks .append (strip_auth )
245279
246280
247281def _has_eviction_hook (hooks : list [Any ], cache : BrowserRouteCache ) -> bool :
@@ -250,6 +284,17 @@ def _has_eviction_hook(hooks: list[Any], cache: BrowserRouteCache) -> bool:
250284 return any (getattr (hook , _EVICTION_HOOK_CACHE_ATTR , None ) is cache for hook in hooks )
251285
252286
287+ def _reject_unreplayable_direct_vm_redirect (response : httpx .Response , * , cache : BrowserRouteCache ) -> None :
288+ if not response .has_redirect_location or not direct_vm_request_body_is_known_unreplayable (response .request ):
289+ return
290+
291+ jwt = str (response .request .url .params .get ("jwt" ) or "" ).strip ()
292+ session_id = _session_id_from_direct_vm_response (response , cache = cache )
293+ if session_id and jwt :
294+ cache .delete_if_jwt (session_id , jwt )
295+ raise httpx .RequestError ("Cannot safely redirect an unreplayable direct VM request" , request = response .request )
296+
297+
253298def should_retry_direct_vm_connection_error (request : httpx .Request ) -> bool :
254299 """Prevent connection retries from replaying an unreplayable VM request body."""
255300 if direct_vm_request_body_is_known_unreplayable (request ):
@@ -414,20 +459,27 @@ def rewrite_direct_vm_options(
414459 params .update (options .params )
415460 params ["jwt" ] = route .jwt
416461 rewritten .params = params or options .params
462+
463+ headers = dict (options .headers ) if is_given (options .headers ) else {}
464+ headers [_DIRECT_VM_REQUEST_MARKER_HEADER ] = "true"
465+ rewritten .headers = headers
417466 return rewritten
418467
419468
420- def prepare_direct_vm_request (request : httpx .Request , * , cache : BrowserRouteCache ) -> None :
421- raw = str (request .url )
422- for route in cache .values ():
423- if raw .startswith (route .base_url .rstrip ("/" ) + "/" ):
424- # Request hooks and custom auth can buffer this request while consuming
425- # the original body that the SDK would use to build a retry.
426- request .extensions [_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION ] = (
427- _classify_direct_vm_request_body_replayability (request )
428- )
429- request .headers .pop ("Authorization" , None )
430- return
469+ def prepare_direct_vm_request (request : httpx .Request ) -> None :
470+ if request .headers .pop (_DIRECT_VM_REQUEST_MARKER_HEADER , None ) is None :
471+ return
472+
473+ # Request hooks and custom auth can buffer this request while consuming
474+ # the original body that the SDK would use to build a retry.
475+ request .extensions [_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION ] = _classify_direct_vm_request_body_replayability (
476+ request
477+ )
478+ request .headers .pop ("Authorization" , None )
479+
480+
481+ def _is_direct_vm_request (request : httpx .Request ) -> bool :
482+ return isinstance (request .extensions .get (_DIRECT_VM_BODY_REPLAYABLE_REQUEST_EXTENSION ), bool )
431483
432484
433485def match_direct_vm_path (path : str ) -> tuple [str , str , str ] | None :
0 commit comments