"""Recording transports for Spotify's httpx2 and Anthropic's httpx.""" from collections.abc import AsyncIterator, Callable from typing import cast import httpx import httpx2 from recording.model import RecordedInteraction from recording.redaction import is_sensitive_url class HttpxRecordingTransport(httpx.AsyncBaseTransport): """Wrap an httpx transport and retain completed response chunk sequences.""" def __init__(self, transport: httpx.AsyncBaseTransport) -> None: """Bind the real transport and start an empty in-memory cassette.""" self.transport = transport self.interactions: list[RecordedInteraction] = [] async def handle_async_request(self, request: httpx.Request) -> httpx.Response: """Forward one request and wrap its response stream for capture.""" request.headers["Accept-Encoding"] = "identity" request_body = await request.aread() response = await self.transport.handle_async_request(request) if is_sensitive_url(str(request.url)): return response stream = _HttpxRecordingStream( cast(httpx.AsyncByteStream, response.stream), lambda chunks: self._finish(request, request_body, response.status_code, chunks), ) return httpx.Response( response.status_code, headers=response.headers, stream=stream, extensions=response.extensions, request=request, ) async def aclose(self) -> None: """Close the wrapped transport.""" await self.transport.aclose() def _finish( self, request: httpx.Request, request_body: bytes, status: int, chunks: tuple[bytes, ...], ) -> None: self.interactions.append( RecordedInteraction(request.method, str(request.url), status, request_body, chunks) ) class Httpx2RecordingTransport(httpx2.AsyncBaseTransport): """Wrap an httpx2 transport and retain completed response chunk sequences.""" def __init__(self, transport: httpx2.AsyncBaseTransport) -> None: """Bind the real transport and start an empty in-memory cassette.""" self.transport = transport self.interactions: list[RecordedInteraction] = [] async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: """Forward one request and wrap its response stream for capture.""" request.headers["Accept-Encoding"] = "identity" request_body = await request.aread() response = await self.transport.handle_async_request(request) if is_sensitive_url(str(request.url)): return response stream = _Httpx2RecordingStream( cast(httpx2.AsyncByteStream, response.stream), lambda chunks: self._finish(request, request_body, response.status_code, chunks), ) return httpx2.Response( response.status_code, headers=response.headers, stream=stream, extensions=response.extensions, request=request, ) async def aclose(self) -> None: """Close the wrapped transport.""" await self.transport.aclose() def _finish( self, request: httpx2.Request, request_body: bytes, status: int, chunks: tuple[bytes, ...], ) -> None: self.interactions.append( RecordedInteraction(request.method, str(request.url), status, request_body, chunks) ) class _HttpxRecordingStream(httpx.AsyncByteStream): def __init__( self, stream: httpx.AsyncByteStream, finish: Callable[[tuple[bytes, ...]], None], ) -> None: self.stream = stream self.finish = finish self.chunks: list[bytes] = [] self.is_finished = False async def __aiter__(self) -> AsyncIterator[bytes]: try: async for chunk in self.stream: self.chunks.append(chunk) yield chunk finally: self._finish() async def aclose(self) -> None: await self.stream.aclose() self._finish() def _finish(self) -> None: if not self.is_finished: self.is_finished = True self.finish(tuple(self.chunks)) class _Httpx2RecordingStream(httpx2.AsyncByteStream): def __init__( self, stream: httpx2.AsyncByteStream, finish: Callable[[tuple[bytes, ...]], None], ) -> None: self.stream = stream self.finish = finish self.chunks: list[bytes] = [] self.is_finished = False async def __aiter__(self) -> AsyncIterator[bytes]: try: async for chunk in self.stream: self.chunks.append(chunk) yield chunk finally: self._finish() async def aclose(self) -> None: await self.stream.aclose() self._finish() def _finish(self) -> None: if not self.is_finished: self.is_finished = True self.finish(tuple(self.chunks))