Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions docs/integrations/httpx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions docs/integrations/httpx2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
54 changes: 50 additions & 4 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions interlock/integrations/httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions interlock/integrations/httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions tests/test_httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions tests/test_httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading