From ece7bbddfec0d08ee8360c7ef702df55502527a2 Mon Sep 17 00:00:00 2001 From: bagowix Date: Wed, 12 Aug 2026 17:19:41 +0400 Subject: [PATCH 1/2] feat: expose the wrapped transport as a public read-only property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The httpx2/httpx transports kept the transport they wrap in a private `self._transport`, while `registry` was already public. Verifying what a wrapper was built around — pool limits, TLS context, proxy — meant reading privates across two libraries, so tests asserted constructor kwargs instead of the object that was built. Both the synchronous and asynchronous transports now expose `wrapped` alongside `registry`. The requests adapter has no equivalent (it inherits `HTTPAdapter`), so this stays transport-specific. Closes #156 --- CHANGELOG.md | 10 ++++++ docs/integrations/httpx.md | 21 +++++++++++++ docs/integrations/httpx2.md | 21 +++++++++++++ docs/llms-full.txt | 52 +++++++++++++++++++++++++++++--- docs/reference.md | 10 +++--- interlock/integrations/httpx.py | 10 ++++++ interlock/integrations/httpx2.py | 10 ++++++ tests/test_httpx.py | 14 +++++++++ tests/test_httpx2.py | 14 +++++++++ 9 files changed, 154 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee34c38..8d439c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **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 - **A bug in your own code no longer opens the circuit of a healthy diff --git a/docs/integrations/httpx.md b/docs/integrations/httpx.md index 992b5bd..83cb288 100644 --- a/docs/integrations/httpx.md +++ b/docs/integrations/httpx.md @@ -157,6 +157,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 2d603c8..7a564bb 100644 --- a/docs/integrations/httpx2.md +++ b/docs/integrations/httpx2.md @@ -163,6 +163,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 1d986e3..f89b9a6 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2911,6 +2911,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`: @@ -3132,6 +3153,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: @@ -4421,8 +4463,9 @@ 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 +every breaker in that registry. See the [httpx2 integration](integrations/httpx2.md). ## httpx adapters @@ -4437,8 +4480,9 @@ 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`, preserve streaming responses, and release the wrapped +transport and every breaker on `close()` / `aclose()`. See the [httpx integration](integrations/httpx.md). ## aiohttp adapters diff --git a/docs/reference.md b/docs/reference.md index 81dbbb7..1fbfd74 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -176,8 +176,9 @@ 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 +every breaker in that registry. See the [httpx2 integration](integrations/httpx2.md). ## httpx adapters @@ -192,8 +193,9 @@ 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`, preserve streaming responses, and release the wrapped +transport and every breaker on `close()` / `aclose()`. See the [httpx integration](integrations/httpx.md). ## aiohttp adapters diff --git a/interlock/integrations/httpx.py b/interlock/integrations/httpx.py index 44bf247..5ae14fe 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. @@ -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 a request under the breaker for its resolved dependency. diff --git a/interlock/integrations/httpx2.py b/interlock/integrations/httpx2.py index 94159df..0bffcf3 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. @@ -316,6 +321,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 02d50d1..937e01f 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -212,6 +212,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) @@ -524,6 +531,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 f6ce541..39248b8 100644 --- a/tests/test_httpx2.py +++ b/tests/test_httpx2.py @@ -195,6 +195,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) @@ -378,6 +385,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, From 647eb6e27db56d96ffbbe631cd1abf49e326aa56 Mon Sep 17 00:00:00 2001 From: bagowix Date: Wed, 12 Aug 2026 21:56:57 +0400 Subject: [PATCH 2/2] docs: qualify the transport cleanup claim in the API reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `close()` / `aclose()` release the wrapped transport unconditionally, but close breakers only when the transport owns the registry — an injected one stays open for its owner to close. The httpx2/httpx sections of the reference stated the unqualified version; the integration pages already carried the caveat. --- docs/llms-full.txt | 10 ++++++---- docs/reference.md | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a33c4db..cce5414 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -4517,8 +4517,9 @@ Extra `interlock-cb[httpx2]`, module `interlock.integrations.httpx2`: via `excluded_exceptions`. Both transports expose their per-host `registry` and the guarded transport as a -read-only `wrapped`; `close()` / `aclose()` release the wrapped transport and -every breaker in that registry. See the +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 @@ -4534,8 +4535,9 @@ Extra `interlock-cb[httpx]` (httpx ≥ 0.27.0), module `LocalProtocolError` exclusions. Both transports expose their per-host `registry` and the guarded transport as a -read-only `wrapped`, preserve streaming responses, and release the wrapped -transport and every breaker on `close()` / `aclose()`. +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 b2b4d9f..0e51b9c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -197,8 +197,9 @@ Extra `interlock-cb[httpx2]`, module `interlock.integrations.httpx2`: via `excluded_exceptions`. Both transports expose their per-host `registry` and the guarded transport as a -read-only `wrapped`; `close()` / `aclose()` release the wrapped transport and -every breaker in that registry. See the +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 @@ -214,8 +215,9 @@ Extra `interlock-cb[httpx]` (httpx ≥ 0.27.0), module `LocalProtocolError` exclusions. Both transports expose their per-host `registry` and the guarded transport as a -read-only `wrapped`, preserve streaming responses, and release the wrapped -transport and every breaker on `close()` / `aclose()`. +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