diff --git a/CHANGELOG.md b/CHANGELOG.md index 1797875..d403710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). meant reaching into the private `registry._breakers`. Both methods take a point-in-time copy under the registry lock — a breaker created afterwards is not in it, and the returned tuple never changes. +- **The guarded transport is reachable without private attributes.** The + httpx2/httpx transports kept the transport they wrap in `self._transport`, + so checking what a wrapper was actually built around — the pool limits, the + TLS context, the proxy — meant reading privates through two libraries, and + tests ended up asserting constructor kwargs instead of the object. Both the + synchronous and asynchronous transports now expose a read-only `wrapped` + property alongside `registry`. ### Fixed diff --git a/docs/integrations/httpx.md b/docs/integrations/httpx.md index 73a8b8d..67b0ff2 100644 --- a/docs/integrations/httpx.md +++ b/docs/integrations/httpx.md @@ -160,6 +160,27 @@ connection pool closes; the application must explicitly call `await registry.aclose_all()` during async shutdown, or `registry.close_all()` when every guarded client is synchronous. +## Reach the wrapped transport + +`transport.wrapped` returns the transport being guarded, so a composed object +can be unwrapped without touching private attributes — verifying the pool +limits, TLS context or proxy the inner transport was built with, inspecting it +in a REPL, or walking a chain of wrappers: + +```python +import httpx + +from interlock.integrations.httpx import AsyncCircuitBreakerTransport + +inner = httpx.AsyncHTTPTransport(limits=httpx.Limits(max_connections=20)) +transport = AsyncCircuitBreakerTransport(inner) + +assert transport.wrapped is inner +``` + +The property is read-only: the wrapped transport is fixed at construction. Both +the synchronous and asynchronous classes expose it. + ## What counts as a failure The default `HttpStatusClassifier` counts these as failures: diff --git a/docs/integrations/httpx2.md b/docs/integrations/httpx2.md index d4b13ce..800a4e4 100644 --- a/docs/integrations/httpx2.md +++ b/docs/integrations/httpx2.md @@ -166,6 +166,27 @@ connection pool closes; the application must explicitly call `await registry.aclose_all()` during async shutdown (or `registry.close_all()` when all guarded clients are synchronous). +## Reach the wrapped transport + +`transport.wrapped` returns the transport being guarded, so a composed object +can be unwrapped without touching private attributes — verifying the pool +limits, TLS context or proxy the inner transport was built with, inspecting it +in a REPL, or walking a chain of wrappers: + +```python +import httpx2 + +from interlock.integrations.httpx2 import AsyncCircuitBreakerTransport + +inner = httpx2.AsyncHTTPTransport(limits=httpx2.Limits(max_connections=20)) +transport = AsyncCircuitBreakerTransport(inner) + +assert transport.wrapped is inner +``` + +The property is read-only: the wrapped transport is fixed at construction. Both +the synchronous and asynchronous classes expose it. + ## What counts as a failure By default the transport uses `HttpStatusClassifier`: diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 6a795ae..cce5414 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2937,6 +2937,27 @@ connection pool closes; the application must explicitly call `await registry.aclose_all()` during async shutdown (or `registry.close_all()` when all guarded clients are synchronous). +## Reach the wrapped transport + +`transport.wrapped` returns the transport being guarded, so a composed object +can be unwrapped without touching private attributes — verifying the pool +limits, TLS context or proxy the inner transport was built with, inspecting it +in a REPL, or walking a chain of wrappers: + +```python +import httpx2 + +from interlock.integrations.httpx2 import AsyncCircuitBreakerTransport + +inner = httpx2.AsyncHTTPTransport(limits=httpx2.Limits(max_connections=20)) +transport = AsyncCircuitBreakerTransport(inner) + +assert transport.wrapped is inner +``` + +The property is read-only: the wrapped transport is fixed at construction. Both +the synchronous and asynchronous classes expose it. + ## What counts as a failure By default the transport uses `HttpStatusClassifier`: @@ -3161,6 +3182,27 @@ connection pool closes; the application must explicitly call `await registry.aclose_all()` during async shutdown, or `registry.close_all()` when every guarded client is synchronous. +## Reach the wrapped transport + +`transport.wrapped` returns the transport being guarded, so a composed object +can be unwrapped without touching private attributes — verifying the pool +limits, TLS context or proxy the inner transport was built with, inspecting it +in a REPL, or walking a chain of wrappers: + +```python +import httpx + +from interlock.integrations.httpx import AsyncCircuitBreakerTransport + +inner = httpx.AsyncHTTPTransport(limits=httpx.Limits(max_connections=20)) +transport = AsyncCircuitBreakerTransport(inner) + +assert transport.wrapped is inner +``` + +The property is read-only: the wrapped transport is fixed at construction. Both +the synchronous and asynchronous classes expose it. + ## What counts as a failure The default `HttpStatusClassifier` counts these as failures: @@ -4474,8 +4516,10 @@ Extra `interlock-cb[httpx2]`, module `interlock.integrations.httpx2`: `LocalProtocolError` are caller-side and count as successes; replace that set via `excluded_exceptions`. -Both transports expose their per-host `registry`; `close()` / `aclose()` release -the wrapped transport and every breaker in that registry. See the +Both transports expose their per-host `registry` and the guarded transport as a +read-only `wrapped`. `close()` / `aclose()` release the wrapped transport and, +when the transport owns the registry, every breaker in it; a caller-owned +registry stays open and is closed by its owner. See the [httpx2 integration](integrations/httpx2.md). ## httpx adapters @@ -4490,8 +4534,10 @@ Extra `interlock-cb[httpx]` (httpx ≥ 0.27.0), module same policy, including the caller-side `UnsupportedProtocol` / `LocalProtocolError` exclusions. -Both transports expose their per-host `registry`, preserve streaming responses, -and release the wrapped transport and every breaker on `close()` / `aclose()`. +Both transports expose their per-host `registry` and the guarded transport as a +read-only `wrapped`, and preserve streaming responses. `close()` / `aclose()` +release the wrapped transport and, when the transport owns the registry, every +breaker in it; a caller-owned registry stays open and is closed by its owner. See the [httpx integration](integrations/httpx.md). ## aiohttp adapters diff --git a/docs/reference.md b/docs/reference.md index 1a6f3e5..0e51b9c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -196,8 +196,10 @@ Extra `interlock-cb[httpx2]`, module `interlock.integrations.httpx2`: `LocalProtocolError` are caller-side and count as successes; replace that set via `excluded_exceptions`. -Both transports expose their per-host `registry`; `close()` / `aclose()` release -the wrapped transport and every breaker in that registry. See the +Both transports expose their per-host `registry` and the guarded transport as a +read-only `wrapped`. `close()` / `aclose()` release the wrapped transport and, +when the transport owns the registry, every breaker in it; a caller-owned +registry stays open and is closed by its owner. See the [httpx2 integration](integrations/httpx2.md). ## httpx adapters @@ -212,8 +214,10 @@ Extra `interlock-cb[httpx]` (httpx ≥ 0.27.0), module same policy, including the caller-side `UnsupportedProtocol` / `LocalProtocolError` exclusions. -Both transports expose their per-host `registry`, preserve streaming responses, -and release the wrapped transport and every breaker on `close()` / `aclose()`. +Both transports expose their per-host `registry` and the guarded transport as a +read-only `wrapped`, and preserve streaming responses. `close()` / `aclose()` +release the wrapped transport and, when the transport owns the registry, every +breaker in it; a caller-owned registry stays open and is closed by its owner. See the [httpx integration](integrations/httpx.md). ## aiohttp adapters diff --git a/interlock/integrations/httpx.py b/interlock/integrations/httpx.py index 4fbef8d..0da0cda 100644 --- a/interlock/integrations/httpx.py +++ b/interlock/integrations/httpx.py @@ -221,6 +221,11 @@ def registry(self) -> Registry: """The breaker registry, exposed for diagnostics and operator control.""" return self._registry + @property + def wrapped(self) -> BaseTransport: + """The transport this wrapper delegates to.""" + return self._transport + def handle_request(self, request: Request) -> Response: """Run a request under the breaker for its resolved dependency. @@ -314,6 +319,11 @@ def registry(self) -> Registry: """The breaker registry, exposed for diagnostics and operator control.""" return self._registry + @property + def wrapped(self) -> AsyncBaseTransport: + """The transport this wrapper delegates to.""" + return self._transport + async def handle_async_request(self, request: Request) -> Response: """Run a request under the breaker for its resolved dependency. diff --git a/interlock/integrations/httpx2.py b/interlock/integrations/httpx2.py index 40fe88b..6da9e0a 100644 --- a/interlock/integrations/httpx2.py +++ b/interlock/integrations/httpx2.py @@ -221,6 +221,11 @@ def registry(self) -> Registry: """The breaker registry, exposed for diagnostics and operator control.""" return self._registry + @property + def wrapped(self) -> BaseTransport: + """The transport this wrapper delegates to.""" + return self._transport + def handle_request(self, request: Request) -> Response: """Run the request under its resolved dependency's breaker. @@ -315,6 +320,11 @@ def registry(self) -> Registry: """The breaker registry, exposed for diagnostics and operator control.""" return self._registry + @property + def wrapped(self) -> AsyncBaseTransport: + """The transport this wrapper delegates to.""" + return self._transport + async def handle_async_request(self, request: Request) -> Response: """Run the request under its resolved dependency's breaker. diff --git a/tests/test_httpx.py b/tests/test_httpx.py index 1c83af8..0f83888 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -224,6 +224,13 @@ def test__sync_transport__context_manager__delegates_wrapped_lifecycle() -> None assert inner.closed +def test__sync_transport__wrapped__exposes_inner_transport() -> None: + inner = _SyncStub(lambda _request: httpx.Response(200)) + transport = CircuitBreakerTransport(inner) + + assert transport.wrapped is inner + + def test__sync_transport__streaming_response__preserves_stream(fake_clock: FakeClock) -> None: response = httpx.Response(200, stream=_SyncStream()) inner = _SyncStub(lambda _request: response) @@ -550,6 +557,13 @@ async def test__async_transport__context_manager__delegates_wrapped_lifecycle() assert inner.closed +def test__async_transport__wrapped__exposes_inner_transport() -> None: + inner = _AsyncStub(lambda _request: httpx.Response(200)) + transport = AsyncCircuitBreakerTransport(inner) + + assert transport.wrapped is inner + + @pytest.mark.asyncio async def test__async_transport__streaming_response__preserves_stream( fake_clock: FakeClock, diff --git a/tests/test_httpx2.py b/tests/test_httpx2.py index 2eb39e5..6d5af7a 100644 --- a/tests/test_httpx2.py +++ b/tests/test_httpx2.py @@ -207,6 +207,13 @@ def test__sync_transport__context_manager__delegates_wrapped_lifecycle() -> None assert inner.closed +def test__sync_transport__wrapped__exposes_inner_transport() -> None: + inner = _SyncStub(lambda _request: Response(200)) + transport = CircuitBreakerTransport(inner) + + assert transport.wrapped is inner + + def test__sync_transport__server_errors__open_breaker_for_host(fake_clock: FakeClock) -> None: inner = _SyncStub(lambda _request: Response(503)) transport = CircuitBreakerTransport(inner, config=_TRIP_FAST, clock=fake_clock) @@ -404,6 +411,13 @@ async def test__async_transport__context_manager__delegates_wrapped_lifecycle() assert inner.closed +def test__async_transport__wrapped__exposes_inner_transport() -> None: + inner = _AsyncStub(lambda _request: Response(200)) + transport = AsyncCircuitBreakerTransport(inner) + + assert transport.wrapped is inner + + @pytest.mark.asyncio async def test__async_transport__server_errors__open_breaker_for_host( fake_clock: FakeClock,