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
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ When installed, `_emit_event` calls `trace.get_current_span().add_event(name, at
- **[Middleware guide](middleware.md)** — write your own middleware. Covers the Middleware Protocol, the phase decorators, a worked Request-ID propagation example, and OpenTelemetry wiring.
- **[Errors reference](errors.md)** — the full exception tree, catching strategies, `exc.response.*` access pattern.
- **[Testing guide](testing.md)** — mock-transport injection pattern for testing code that uses `httpware`.
- **[Recipes](recipes/modern-di.md)** — wiring `AsyncClient` into a `modern-di` container.
- **[Engineering Notes](https://github.com/modern-python/httpware/blob/main/planning/engineering.md)** — design invariants, the three protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. Lives in the repo at `planning/engineering.md`.
- **[Contributing](dev/contributing.md)** — setup, conventions, workflow.
- **[Release notes](https://github.com/modern-python/httpware/releases)** — per-version changelogs.
Expand Down
138 changes: 138 additions & 0 deletions docs/recipes/modern-di.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Wiring `AsyncClient` into `modern-di`

If you wire your app's dependencies with [`modern-di`](https://modern-di.readthedocs.io/) and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the [`modern-python`](https://github.com/modern-python) org.

## The minimal wire-up

```python
from modern_di import Container, Group, Scope, providers

from httpware import AsyncClient


class ServiceClients(Group):
api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://api.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)


async def main() -> None:
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
client = await container.resolve(AsyncClient)
response = await client.get("/users/1")
print(response.status_code)
```

Breaking that down:

- **`Scope.APP`** ties the client to the application lifetime. One client per process; the connection pool is reused across all calls.
- **`cache_settings=providers.CacheSettings(...)`** is what makes the provider a singleton. Without it, `Factory` returns a fresh `AsyncClient` on every resolve.
- **`finalizer=AsyncClient.aclose`** is the unbound async method. `modern-di` detects it as a coroutine function (via `inspect.iscoroutinefunction`) and `await`s it on container teardown.

A common first instinct here is `finalizer=lambda c: c.aclose()`. **That does not work** — the lambda itself is sync, so `modern-di` calls it synchronously and discards the returned coroutine unawaited. The underlying connection pool leaks. Pass the unbound async method directly, or wrap in `async def`.

See the [`modern-di` factories docs](https://modern-di.readthedocs.io/providers/factories/) for the broader `CacheSettings` story (scopes, `clear_cache`, sync vs async finalizers).

## Adding a second backend hits a type collision

The obvious move when you talk to a second backend — register another `Factory(creator=AsyncClient, ...)` — fails at container construction:

```python
class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://users.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)
billing_api = providers.Factory(
scope=Scope.APP,
creator=AsyncClient,
kwargs={"base_url": "https://billing.example.com"},
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)

# At Container(...) construction:
# modern_di.exceptions.DuplicateProviderTypeError: Provider is duplicated by type
# <class 'httpware.client.AsyncClient'>. To resolve this issue: ...
```

`modern-di` resolves dependencies by `bound_type`, which defaults to the creator's return type. Both providers default to `bound_type=AsyncClient` and collide in the providers registry.

## Fix: one wrapper subclass per backend

Give each provider a distinct `bound_type` by subclassing `AsyncClient`:

```python
from modern_di import Container, Group, Scope, providers

from httpware import AsyncClient


class UserApi(AsyncClient):
"""Typing handle for the User service backend."""


class BillingApi(AsyncClient):
"""Typing handle for the Billing service backend."""


class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=UserApi,
kwargs={"base_url": "https://users.example.com"},
cache_settings=providers.CacheSettings(finalizer=UserApi.aclose),
)
billing_api = providers.Factory(
scope=Scope.APP,
creator=BillingApi,
kwargs={"base_url": "https://billing.example.com"},
cache_settings=providers.CacheSettings(finalizer=BillingApi.aclose),
)


async def main() -> None:
async with Container(scope=Scope.APP, groups=[ServiceClients]) as container:
users = await container.resolve(UserApi)
billing = await container.resolve(BillingApi)
# ... use them
```

A couple of notes:

- Subclasses are **typing-only**. Empty body, no overrides. They inherit `__init__`, `aclose`, and every HTTP method unchanged.
- Each `Factory` now has a distinct `bound_type`, so `container.resolve(UserApi)` and `container.resolve(BillingApi)` route to the right provider.
- `modern-di`'s error suggestions are subclass-aware. If a caller asks for `container.resolve(AsyncClient)` after only the subclasses are registered, the error message points them at the right subclass.

## Middleware in `kwargs=`

`AsyncClient`'s middleware chain is composed once at construction and frozen for the client's lifetime. With a singleton-scoped `Factory`, "once at construction" means "once per container build." Drop the middleware list into `kwargs=`:

```python
from httpware import AsyncClient, Bulkhead, Retry


class ServiceClients(Group):
user_api = providers.Factory(
scope=Scope.APP,
creator=UserApi,
kwargs={
"base_url": "https://users.example.com",
"middleware": [Bulkhead(max_concurrent=10), Retry()],
},
cache_settings=providers.CacheSettings(finalizer=UserApi.aclose),
)
```

Each cached singleton owns its own `Bulkhead` and `Retry` state — what you want when different backends have different reliability profiles.

## See also

- **[Quick-Start](../index.md)** — the base `AsyncClient` API.
- **[Middleware guide](../middleware.md)** — what `Bulkhead` and `Retry` are doing in `kwargs[middleware]`.
- **[Resilience reference](../resilience.md)** — every parameter on `Retry`, `RetryBudget`, `Bulkhead`.
- **[`modern-di` factories](https://modern-di.readthedocs.io/providers/factories/)** — `CacheSettings`, scopes, the broader provider story.
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ nav:
- Middleware: middleware.md
- Errors: errors.md
- Testing: testing.md
- Recipes:
- modern-di: recipes/modern-di.md
- Development:
- Contributing: dev/contributing.md

Expand Down
Loading
Loading