From a2c1fbcf5f8c5d04a5d013de29b2e31b55c34889 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 6 Jun 2026 22:43:04 +0300 Subject: [PATCH 1/5] docs(spec): brainstorm modern-di setup-friction recipe + AsyncClient.aclose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup-friction recipe for wiring `httpware.AsyncClient` into a `modern-di` container. Documents two non-obvious moments real users will hit: - Lifecycle finalizer: bridging AsyncClient's context-manager teardown with modern-di's Factory(cache_settings=CacheSettings(finalizer=...)) via the unbound `AsyncClient.aclose` (modern-di auto-awaits async coroutine functions; a `lambda c: c.aclose()` would NOT work because iscoroutinefunction returns False on lambdas). - Multi-backend collision: two Factory(creator=AsyncClient,...) providers raise DuplicateProviderTypeError at container construction (verified against modern_di/registries/providers_registry.py:42-44). Idiomatic fix is a per-backend wrapper subclass so each Factory has a distinct bound_type. While drafting, found that `httpware.AsyncClient` exposes __aenter__ / __aexit__ but no standalone aclose() — even though the CLAUDE.md naming convention names it as if it exists. Spec scopes adding aclose() into the same PR so the recipe finalizer can be the clean `AsyncClient.aclose` instead of a workaround calling __aexit__ directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-06-06-modern-di-recipe-design.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 planning/specs/2026-06-06-modern-di-recipe-design.md diff --git a/planning/specs/2026-06-06-modern-di-recipe-design.md b/planning/specs/2026-06-06-modern-di-recipe-design.md new file mode 100644 index 0000000..1585475 --- /dev/null +++ b/planning/specs/2026-06-06-modern-di-recipe-design.md @@ -0,0 +1,275 @@ +# Spec: `modern-di` setup-friction recipe + +**Date:** 2026-06-06 +**Topic:** New docs recipe — wiring `httpware.AsyncClient` into a `modern-di` container — plus the minimal supporting code change to make the wiring clean. + +## What this is + +A new page in the httpware docs that shows how to register `httpware.AsyncClient` as a provider in a `modern-di` container so connection-pool teardown and middleware composition flow through the container's lifecycle. Both libraries ship under `modern-python`; users will reach for them together. The recipe documents the bridge between them — specifically the two non-obvious moments that real users will hit: + +1. **Lifecycle finalizer:** `AsyncClient` is a context manager; modern-di's `Factory.creator` runs synchronously. Bridging the two requires a finalizer that modern-di will await. +2. **Multi-backend type collision:** two `Factory(creator=AsyncClient, ...)` providers both bind to `AsyncClient`. `modern-di` raises `DuplicateProviderTypeError` at container construction. The idiomatic fix is a per-backend wrapper subclass. + +This is a **setup-friction recipe**, not a speculative cookbook — it answers an exact question users in this ecosystem will ask, with the specific code that resolves it. + +## Why this lands as a single PR with a small library change + +While drafting, we found that `httpware.AsyncClient` exposes `__aenter__` / `__aexit__` but has no standalone `aclose()` method. The `httpware` CLAUDE.md naming convention says `aclose()` is the sole `a`-prefixed exception — i.e., it names this method as if it already exists. It doesn't. + +Without `aclose()`, the recipe's finalizer would have to call `__aexit__(None, None, None)` directly, which reads as a workaround and tells readers the library has a teardown gap. With `aclose()` added, the recipe's finalizer is the clean `finalizer=AsyncClient.aclose`. The library gains the missing teardown method for any non-context-manager scoping use case (DI containers, background workers, anything not request-shaped). + +So this work ships as one PR with two thin pieces: +- A ~4-line `aclose()` method on `AsyncClient` + one or two tests. +- The new `docs/recipes/modern-di.md` page + `mkdocs.yml` nav update + one back-link from `docs/index.md`. + +## Out of scope + +- A modern-di primer (scopes, registries, child containers, context providers). The recipe links to modern-di docs for this. +- A Gateway/Repository worked example consuming the client via type-based DI. Rejected during brainstorming as "speculative cookbook" territory. +- FastAPI / Litestar request-scoped client wiring. That's `modern-di-fastapi` / `modern-di-litestar` territory — separate packages, separate docs. +- Back-links from `docs/resilience.md`, `docs/middleware.md`, `docs/errors.md`, `docs/testing.md`. They're topical reference pages and shouldn't fan into a recipe. +- A back-link from `modern-di/docs/integrations/` to httpware. Possible follow-up in the `modern-di` repo; not this PR. +- Automated tests for the recipe code samples. Other recipe-style pages on the org don't doctest-execute their samples; we rely on the underlying API tests. Snippets get a manual visual + local-run pass before the PR. + +## Library change — `AsyncClient.aclose()` + +**File:** `src/httpware/client.py` + +**Addition:** A standalone async teardown method, mirroring the body of `__aexit__`: + +```python +async def aclose(self) -> None: + """Close the underlying httpx2 client if we own it. + + Idempotent — safe to call after __aexit__ or another aclose() call. + Use this when the client is not managed by `async with` (e.g., wired + into a DI container's lifecycle). + """ + if self._owns_client and not self._httpx2_client.is_closed: + await self._httpx2_client.aclose() +``` + +**Why this method, not `__aexit__` directly:** standalone `aclose()` is the idiomatic teardown method for async resources in modern Python (it's the convention used by `httpx`, `asyncpg`, `aiomysql`, the stdlib `contextlib.AsyncExitStack`, etc.). It matches the project's own stated naming convention. It makes the method discoverable for users who don't think to call dunders directly. + +**Behavior:** +- No-op when called on a client whose `_httpx2_client` was provided externally (`_owns_client=False`) — same guard as `__aexit__`. The container that handed us the client owns its lifecycle. +- No-op when `_httpx2_client.is_closed` is true. Idempotent. +- Does not change `__aenter__` / `__aexit__` behavior in any way. Existing context-manager users are unaffected. + +**Tests** (in `tests/test_client.py`): +- `test_aclose_closes_owned_client`: construct without `async with`, call `aclose()`, assert the underlying `httpx2.AsyncClient.is_closed` is `True`. +- `test_aclose_is_idempotent`: call `aclose()` twice in succession; second call must not raise. +- No new test for the `_owns_client=False` branch — the existing `__aexit__` test already exercises that line and `aclose()` shares it. Avoid duplication. + +**Public API surface:** +- No change to `src/httpware/__init__.py` — `aclose` is a method on the existing `AsyncClient` class, not a new top-level export. +- No change to `planning/engineering.md` — it documents seams and architecture, not method-by-method API enumeration. + +## Docs change — `docs/recipes/modern-di.md` + +**Location:** `docs/recipes/modern-di.md` (new folder `docs/recipes/`). + +**Page title:** `Wiring AsyncClient into modern-di` + +**Structure:** Linear narrative — the reader walks from "the minimal wire" through "the moment a second backend breaks it" to "the wrapper-subclass fix" — then a brief note on middleware composition. + +### Section 1 — What this is for + +~2 sentences. If you're using `modern-di` to wire your app's dependencies and you want connection-pool teardown and middleware composition to flow through the container, this is the bridge. Both libraries ship under `modern-python`. + +### Section 2 — The minimal wire-up + +~80 words of prose + a ~25-line code sample. + +```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) +``` + +Prose covers: +- `Scope.APP` → one client per app lifetime; connection pool reuse. +- `cache_settings=providers.CacheSettings(...)` is what makes this a singleton. +- `finalizer=AsyncClient.aclose` — the unbound async method. `modern-di` detects it as a coroutine function via `inspect.iscoroutinefunction` and awaits it on container teardown. A `lambda c: c.aclose()` would **not** work — the lambda itself is sync, so modern-di would call it synchronously and discard the returned coroutine unawaited. +- One sentence linking out to `modern-di` factory docs for the broader `CacheSettings` story. + +### Section 3 — Adding a second backend hits a collision + +~60 words of prose + the exact error. + +If the application talks to more than one backend, the obvious move — registering a second `Factory(creator=AsyncClient, ...)` — fails immediately 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: AsyncClient is already registered +``` + +Prose explains why: `modern-di` resolves dependencies by `bound_type`, which defaults to the creator's return type. Two providers with the same `bound_type` collide. + +### Section 4 — Fix: per-backend wrapper subclass + +~60 words of prose + a ~30-line code sample. + +Give each provider a distinct `bound_type` by sub-classing `AsyncClient`: + +```python +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) +``` + +Prose: +- Subclasses are typing-only — empty body, no overrides. They inherit `__init__`, `aclose`, all HTTP methods unchanged. +- Each `Factory` now has a distinct `bound_type`, so `container.resolve(UserApi)` and `container.resolve(BillingApi)` route to the right provider. +- Bonus: `modern-di`'s error suggestions are subclass-aware (per `providers_registry.py:_hierarchy_hint`). If a caller tries `container.resolve(AsyncClient)` after registering only the subclasses, they'll be pointed at the right subclass to ask for. + +### Section 5 — Middleware in `kwargs=` + +~50 words of prose + a ~15-line code sample. + +```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), + ) +``` + +Prose anchors the existing "chain is frozen at construction" invariant — middleware composes once at container build (when the cached singleton is created), not per call. + +### Section 6 — See also + +- httpware **Quick-Start** — base `AsyncClient` API. +- httpware **Middleware guide** — what `Bulkhead` and `Retry` are doing in `kwargs[middleware]`. +- httpware **Resilience reference** — every parameter on `Retry`, `RetryBudget`, `Bulkhead`. +- `modern-di` **Factories** docs (https://modern-di.readthedocs.io/providers/factories/) — `CacheSettings`, scopes, the broader provider story. + +## mkdocs.yml change + +Add a `Recipes` nav section between `Testing` and `Development`: + +```yaml +nav: + - Quick-Start: index.md + - Resilience: resilience.md + - Middleware: middleware.md + - Errors: errors.md + - Testing: testing.md + - Recipes: + - modern-di: recipes/modern-di.md + - Development: + - Contributing: dev/contributing.md +``` + +The single-item folder is an honest representation of what we have — it doesn't pretend to a category before one exists. Future recipes (when warranted by real friction) drop into the same folder. + +## `docs/index.md` change + +In the "Where to go next" section, add one bullet between **Testing guide** and **Engineering Notes**: + +```markdown +- **[Recipes](recipes/modern-di.md)** — wiring `AsyncClient` into a `modern-di` container. +``` + +No other changes to existing pages. + +## Acceptance criteria + +- `src/httpware/client.py` has an `aclose()` method on `AsyncClient`, idempotent, with the `_owns_client` guard. +- `tests/test_client.py` has the two new tests (`test_aclose_closes_owned_client`, `test_aclose_is_idempotent`); both pass under `just test`. +- `just lint` passes (ruff format + ruff check + ty check). +- `docs/recipes/modern-di.md` exists with the six sections above, all four code samples present. +- `mkdocs.yml` `nav` has the new `Recipes` section. +- `docs/index.md` "Where to go next" has the new bullet. +- `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` produces a valid site with the new page rendered and no broken intra-site links. +- A local run of the Section 2 minimal-wire sample (against a `httpx2.MockTransport`) executes cleanly and confirms `aclose()` is awaited on container teardown. + +## File-by-file change summary + +| File | Change | Approx. lines | +|---|---|---| +| `src/httpware/client.py` | Add `async def aclose(self)` method on `AsyncClient` | +6 | +| `tests/test_client.py` | Add two tests covering `aclose()` | +30 | +| `docs/recipes/modern-di.md` | NEW — full recipe page | +180 | +| `mkdocs.yml` | Add `Recipes` nav section | +2 | +| `docs/index.md` | Add one bullet to "Where to go next" | +1 | + +Total: ~5 files touched, ~220 lines added, 0 lines removed. + +## Risks and trade-offs + +**Risk: "Recipes" nav with one item looks underweight.** +Trade-off: hiding the recipe inside an existing page (e.g., `testing.md` or `index.md`) avoids the empty-category appearance but buries the content. Picked the explicit nav section because the brainstorming conversation specifically confirmed `httpware docs — new short recipe page`. Future recipes (genuine setup-friction only, per the project's docs philosophy) accrete into the same folder. + +**Risk: `AsyncClient.aclose()` could be seen as scope creep on a docs PR.** +Trade-off: deferring it to a separate PR (option C in the brainstorming) keeps the history cleaner but slows the docs work. The method is ~6 lines + 2 tests and is independently justified by the project's own naming convention. Net win to ship together; the PR title can name both pieces ("docs: modern-di recipe + AsyncClient.aclose"). + +**Risk: the recipe documents a specific modern-di version's behavior (`DuplicateProviderTypeError`).** +Trade-off: this error name and behavior are stable in `modern-di` as of the version of the source we inspected. If they change, the recipe gets a one-line edit. Worth the precision — vague language like "you'll get an error" is worse than naming the actual exception. + +**Risk: telling readers `lambda c: c.aclose()` does not work is a strong claim.** +Trade-off: the claim is correct (verified against `modern_di/providers/factory.py:27` — `is_async_finalizer = inspect.iscoroutinefunction(self.finalizer)`, and a lambda is not a coroutine function regardless of what it returns). Calling it out explicitly saves users from a confusing "coroutine was never awaited" warning and a connection pool that quietly leaks. Worth the precision. From 05b12a21554904f3820914af0b3ea86b01c89c11 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 6 Jun 2026 22:48:45 +0300 Subject: [PATCH 2/5] docs(plan): implementation plan for modern-di recipe + AsyncClient.aclose Three tasks: (1) TDD aclose() with two new tests, (2) write the recipe page + mkdocs nav + index back-link, (3) verification (round-trip finalizer call, multi-backend distinct providers, exact-name-of-error gate for the documented DuplicateProviderTypeError). Plan corrects a spec discrepancy: lifecycle tests go in tests/test_client_lifecycle.py, not the non-existent tests/test_client.py. Test names tightened to match the existing test_aexit_* convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-06-06-modern-di-recipe-plan.md | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 planning/plans/2026-06-06-modern-di-recipe-plan.md diff --git a/planning/plans/2026-06-06-modern-di-recipe-plan.md b/planning/plans/2026-06-06-modern-di-recipe-plan.md new file mode 100644 index 0000000..2f2a9d3 --- /dev/null +++ b/planning/plans/2026-06-06-modern-di-recipe-plan.md @@ -0,0 +1,612 @@ +# `modern-di` recipe + `AsyncClient.aclose()` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `httpware.AsyncClient.aclose()` and ship a setup-friction recipe page at `docs/recipes/modern-di.md` showing how to wire `AsyncClient` into a `modern-di` container. + +**Architecture:** Two thin pieces in one PR. (1) A standalone `aclose()` method on `AsyncClient` (mirrors the body of `__aexit__`, idempotent via the existing `_owns_client` + `is_closed` guards). (2) A new `docs/recipes/modern-di.md` page in linear-narrative form — minimal wire → multi-backend collision → wrapper-subclass fix → middleware composition — plus a small `mkdocs.yml` nav update and one back-link from `docs/index.md`. The two pieces are bundled because the recipe's `finalizer=AsyncClient.aclose` reads as a clean one-liner; without the method it would have to call `__aexit__(None, None, None)` directly, which signals a library gap. + +**Tech Stack:** Python 3.11+, `httpx2`, `modern-di` (recipe only — not a project dependency), `pytest` + `pytest-asyncio` auto-mode, `ruff`, `ty`, `mkdocs` + `mkdocs-material`. Task runner: `just`. Package manager: `uv`. + +**Spec:** `planning/specs/2026-06-06-modern-di-recipe-design.md` — commit `a2c1fbc`. + +**Note on a spec-vs-tree discrepancy:** the spec says new tests go in `tests/test_client.py`. That file does not exist — tests in this project are split per concern. The actual home for the new tests is `tests/test_client_lifecycle.py`. This plan uses the correct path throughout. + +--- + +## File structure + +| Path | Action | Responsibility | +|---|---|---| +| `src/httpware/client.py` | Modify | Add `async def aclose(self)` to `AsyncClient` | +| `tests/test_client_lifecycle.py` | Modify | Add two new tests covering `aclose()` | +| `docs/recipes/` | Create (dir) | New folder for setup-friction recipes | +| `docs/recipes/modern-di.md` | Create | The recipe page itself | +| `mkdocs.yml` | Modify | Add `Recipes` nav section | +| `docs/index.md` | Modify | Add one bullet under "Where to go next" | + +No new test file is created — the existing `test_client_lifecycle.py` already groups the lifecycle tests by concern and the new tests fit naturally beside `test_aexit_*`. + +--- + +## Task 1: Add `AsyncClient.aclose()` with TDD + +**Files:** +- Modify: `src/httpware/client.py:768-770` (existing `__aexit__` body) — add new `aclose()` method directly after line 770 +- Modify: `tests/test_client_lifecycle.py:33` (end of file) — append two new tests + +- [ ] **Step 1: Add the first failing test — `test_aclose_closes_owned_httpx2_client`** + +Append to `tests/test_client_lifecycle.py` (after the existing `test_aexit_is_idempotent_for_owned_client`): + +```python + + +async def test_aclose_closes_owned_httpx2_client() -> None: + client = AsyncClient() + await client.aclose() + assert client._httpx2_client.is_closed # noqa: SLF001 +``` + +- [ ] **Step 2: Run it — confirm it fails for the right reason** + +```bash +uv run pytest tests/test_client_lifecycle.py::test_aclose_closes_owned_httpx2_client -v +``` + +Expected: `FAILED ... AttributeError: 'AsyncClient' object has no attribute 'aclose'`. + +If the failure is anything else (e.g., import error, fixture problem), stop and resolve before proceeding. + +- [ ] **Step 3: Add the second failing test — `test_aclose_is_idempotent_for_owned_client`** + +Append to `tests/test_client_lifecycle.py`: + +```python + + +async def test_aclose_is_idempotent_for_owned_client() -> None: + client = AsyncClient() + await client.aclose() + # Second call must not raise — the boolean prevents a double-close on httpx2 internals. + await client.aclose() + assert client._httpx2_client.is_closed # noqa: SLF001 +``` + +- [ ] **Step 4: Run both new tests — confirm both fail** + +```bash +uv run pytest tests/test_client_lifecycle.py -v -k "test_aclose" +``` + +Expected: both `test_aclose_closes_owned_httpx2_client` and `test_aclose_is_idempotent_for_owned_client` FAIL with `AttributeError: 'AsyncClient' object has no attribute 'aclose'`. + +- [ ] **Step 5: Add the `aclose()` method to `AsyncClient`** + +In `src/httpware/client.py`, after the existing `__aexit__` method (which ends at line 770 with `await self._httpx2_client.aclose()`), append: + +```python + + async def aclose(self) -> None: + """Close the underlying httpx2 client if we own it. + + Idempotent — safe to call after ``__aexit__`` or another ``aclose()`` call. + Use this when the client is not managed by ``async with`` (e.g., wired + into a DI container's lifecycle). + """ + if self._owns_client and not self._httpx2_client.is_closed: + await self._httpx2_client.aclose() +``` + +Insertion point: directly under the closing line of `__aexit__`. The new method becomes the final method of `AsyncClient`. + +- [ ] **Step 6: Run the two new tests — confirm both pass** + +```bash +uv run pytest tests/test_client_lifecycle.py -v -k "test_aclose" +``` + +Expected: both PASS. + +- [ ] **Step 7: Run the full test_client_lifecycle.py file — confirm nothing else broke** + +```bash +uv run pytest tests/test_client_lifecycle.py -v +``` + +Expected: all 5 tests PASS (3 existing `test_aexit_*` + 2 new `test_aclose_*`). + +- [ ] **Step 8: Run the full lint pipeline** + +```bash +just lint +``` + +Expected: clean — no ruff or `ty` errors. + +If ty flags an issue with the new method (e.g., missing return type), fix it inline rather than suppressing. + +- [ ] **Step 9: Run the full test suite — confirm no other test relied on `aclose` NOT existing** + +```bash +just test +``` + +Expected: all tests PASS. Coverage of `src/httpware/client.py` should increase by the body of `aclose()` (a handful of lines). + +- [ ] **Step 10: Commit** + +```bash +git add src/httpware/client.py tests/test_client_lifecycle.py +git commit -m "$(cat <<'EOF' +feat(client): add AsyncClient.aclose() standalone teardown + +Mirrors the body of __aexit__: closes the underlying httpx2 client iff +we own it and it isn't already closed. Idempotent. + +Use case: DI containers, background workers, anything not request-shaped +that can't lean on `async with`. Aligns the library with its own CLAUDE.md +naming convention which already names aclose() as the sole a-prefixed +method exception. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Write the `modern-di` recipe page + nav + back-link + +**Files:** +- Create: `docs/recipes/modern-di.md` +- Modify: `mkdocs.yml` (the `nav:` block) +- Modify: `docs/index.md` (the "Where to go next" section) + +- [ ] **Step 1: Create the recipe page** + +Write `docs/recipes/modern-di.md` with the full content below. + +````markdown +# 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: AsyncClient is already registered +``` + +`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. +```` + +- [ ] **Step 2: Update `mkdocs.yml` to add the `Recipes` nav section** + +Open `mkdocs.yml`. The existing `nav:` block is: + +```yaml +nav: + - Quick-Start: index.md + - Resilience: resilience.md + - Middleware: middleware.md + - Errors: errors.md + - Testing: testing.md + - Development: + - Contributing: dev/contributing.md +``` + +Replace it with: + +```yaml +nav: + - Quick-Start: index.md + - Resilience: resilience.md + - Middleware: middleware.md + - Errors: errors.md + - Testing: testing.md + - Recipes: + - modern-di: recipes/modern-di.md + - Development: + - Contributing: dev/contributing.md +``` + +- [ ] **Step 3: Update `docs/index.md` "Where to go next" with a recipe back-link** + +In `docs/index.md`, locate the `## Where to go next` section. The current bullets are: + +```markdown +- **[Resilience reference](resilience.md)** — every parameter on `Retry`, `RetryBudget`, and `Bulkhead`; the retry-rule matrix; Retry-After parsing; budget sharing. +- **[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`. +- **[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. +``` + +Insert a new bullet between **Testing guide** and **Engineering Notes**: + +```markdown +- **[Recipes](recipes/modern-di.md)** — wiring `AsyncClient` into a `modern-di` container. +``` + +Final section after the edit: + +```markdown +- **[Resilience reference](resilience.md)** — ... +- **[Middleware guide](middleware.md)** — ... +- **[Errors reference](errors.md)** — ... +- **[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)** — ... +- **[Contributing](dev/contributing.md)** — setup, conventions, workflow. +- **[Release notes](https://github.com/modern-python/httpware/releases)** — per-version changelogs. +``` + +- [ ] **Step 4: Build the docs site with `--strict` to catch broken links** + +```bash +uv run --with mkdocs --with mkdocs-material mkdocs build --strict +``` + +Expected: build completes with no warnings. `--strict` turns broken intra-site links and ambiguous references into errors. + +If it fails: most likely a relative link target. Verify: +- `../index.md`, `../middleware.md`, `../resilience.md` exist relative to `docs/recipes/modern-di.md` — they do; `docs/` is the docs root. +- `recipes/modern-di.md` from `docs/index.md` resolves — it does. + +- [ ] **Step 5: Eyeball the rendered page locally** + +```bash +uv run --with mkdocs --with mkdocs-material mkdocs serve +``` + +Open `http://127.0.0.1:8000/recipes/modern-di/` in a browser. Check: +- The "Recipes" section appears in the left nav with `modern-di` under it. +- Code blocks render syntax-highlighted. +- All "See also" links work. +- The new bullet on the index page links to the recipe. + +Kill the server with Ctrl+C when satisfied. + +- [ ] **Step 6: Commit** + +```bash +git add docs/recipes/modern-di.md mkdocs.yml docs/index.md +git commit -m "$(cat <<'EOF' +docs(recipes): add modern-di setup-friction recipe + +Linear-narrative walk-through of wiring AsyncClient into a modern-di +container: minimal Factory + finalizer → multi-backend type collision +(DuplicateProviderTypeError) → per-backend wrapper-subclass fix → +middleware in kwargs. + +Adds a new top-level "Recipes" nav section (single item for now) and +one back-link from the index's "Where to go next". + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: End-to-end verification of the recipe sample + +**Files:** +- Create (scratch, not committed): `/tmp/verify_modern_di_recipe.py` + +This task is verification-only — no source changes, no commit. It confirms the recipe's claims actually hold against the current code. + +- [ ] **Step 1: Write the verification script** + +Create `/tmp/verify_modern_di_recipe.py` (the repo's `tests/` directory is reserved for the formal test suite; this is a one-off check, not a regression test): + +```python +"""End-to-end check that the modern-di recipe in docs/recipes/modern-di.md +actually wires AsyncClient.aclose into Container teardown.""" + +import asyncio + +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.test"}, + cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose), + ) + + +async def main() -> None: + captured: AsyncClient | None = None + async with Container(scope=Scope.APP, groups=[ServiceClients]) as container: + captured = await container.resolve(AsyncClient) + assert captured._httpx2_client.is_closed is False, "client should be open during scope" + assert captured is not None + assert captured._httpx2_client.is_closed is True, "finalizer should have closed the client" + print("OK: container teardown invoked AsyncClient.aclose; underlying httpx2 client is closed.") + + +asyncio.run(main()) +``` + +- [ ] **Step 2: Run it** + +```bash +uv run --with modern-di python /tmp/verify_modern_di_recipe.py +``` + +Expected output: `OK: container teardown invoked AsyncClient.aclose; underlying httpx2 client is closed.` + +If the assertions fire: +- "client should be open during scope" → the finalizer fired too early (a `modern-di` bug or a scope misconfiguration); flag the issue, don't paper over it. +- "finalizer should have closed the client" → the finalizer wasn't awaited. Most likely cause: `aclose` was passed as something other than an unbound async method, or `inspect.iscoroutinefunction(AsyncClient.aclose)` returns `False` (which would mean `aclose` isn't `async def` — check `client.py`). + +- [ ] **Step 3: Run the multi-backend variation** + +Replace the body of `/tmp/verify_modern_di_recipe.py` with the wrapper-subclass form to confirm the collision-fix actually resolves to distinct providers: + +```python +"""End-to-end check for the multi-backend wrapper-subclass form.""" + +import asyncio + +from modern_di import Container, Group, Scope, providers + +from httpware import AsyncClient + + +class UserApi(AsyncClient): + pass + + +class BillingApi(AsyncClient): + pass + + +class ServiceClients(Group): + user_api = providers.Factory( + scope=Scope.APP, + creator=UserApi, + kwargs={"base_url": "https://users.example.test"}, + cache_settings=providers.CacheSettings(finalizer=UserApi.aclose), + ) + billing_api = providers.Factory( + scope=Scope.APP, + creator=BillingApi, + kwargs={"base_url": "https://billing.example.test"}, + cache_settings=providers.CacheSettings(finalizer=BillingApi.aclose), + ) + + +async def main() -> None: + captured_user: UserApi | None = None + captured_billing: BillingApi | None = None + async with Container(scope=Scope.APP, groups=[ServiceClients]) as container: + captured_user = await container.resolve(UserApi) + captured_billing = await container.resolve(BillingApi) + assert isinstance(captured_user, UserApi) + assert isinstance(captured_billing, BillingApi) + assert captured_user is not captured_billing + assert captured_user is not None and captured_billing is not None + assert captured_user._httpx2_client.is_closed is True + assert captured_billing._httpx2_client.is_closed is True + print("OK: two backends resolve to distinct subclass instances; both finalizers ran.") + + +asyncio.run(main()) +``` + +```bash +uv run --with modern-di python /tmp/verify_modern_di_recipe.py +``` + +Expected: `OK: two backends resolve to distinct subclass instances; both finalizers ran.` + +- [ ] **Step 4: Confirm the collision claim — the documented error actually fires** + +Replace the script body once more, this time with the broken form from the recipe's collision section: + +```python +"""End-to-end check that the documented DuplicateProviderTypeError actually fires.""" + +from modern_di import Container, Group, Scope, providers + +from httpware import AsyncClient + + +class ServiceClients(Group): + user_api = providers.Factory( + scope=Scope.APP, + creator=AsyncClient, + kwargs={"base_url": "https://users.example.test"}, + cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose), + ) + billing_api = providers.Factory( + scope=Scope.APP, + creator=AsyncClient, + kwargs={"base_url": "https://billing.example.test"}, + cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose), + ) + + +try: + Container(scope=Scope.APP, groups=[ServiceClients]) +except Exception as exc: # noqa: BLE001 - we want the exact class name + print(f"OK: collision raised {type(exc).__module__}.{type(exc).__name__}: {exc}") +else: + raise SystemExit("FAIL: expected DuplicateProviderTypeError, got no error") +``` + +```bash +uv run --with modern-di python /tmp/verify_modern_di_recipe.py +``` + +Expected: a line starting `OK: collision raised modern_di.exceptions.DuplicateProviderTypeError: ...`. + +If the exception name or fully-qualified path differs from what the recipe's "At `Container(...)` construction" comment shows, update the recipe text to match exactly. This is a docs-accuracy gate. + +- [ ] **Step 5: Final cleanup and full repo health check** + +```bash +rm /tmp/verify_modern_di_recipe.py +just lint +just test +uv run --with mkdocs --with mkdocs-material mkdocs build --strict +``` + +All four commands must succeed. If `mkdocs build --strict` warns about anything, fix it before declaring the work done. + +- [ ] **Step 6: No commit for this task** + +This task produced no source changes — it was verification only. Move on to PR creation per the user's normal workflow. + +--- + +## Self-review notes + +- **Spec coverage:** + - `aclose()` method on `AsyncClient` → Task 1 Step 5. + - Two tests for `aclose()` → Task 1 Steps 1 and 3. + - `docs/recipes/modern-di.md` with all six spec sections → Task 2 Step 1. + - `mkdocs.yml` nav update → Task 2 Step 2. + - `docs/index.md` "Where to go next" bullet → Task 2 Step 3. + - "Local run of the Section 2 minimal-wire sample" acceptance criterion → Task 3 Step 2. + - `mkdocs build --strict` acceptance criterion → Task 2 Step 4 and Task 3 Step 5. + - All spec-listed exclusions (modern-di primer, Gateway example, FastAPI/Litestar coverage, back-links from other reference pages, back-link from modern-di repo) → respected by omission. + +- **Type consistency:** `AsyncClient.aclose` is referenced identically in the source (Task 1 Step 5), the recipe sample finalizer (Task 2 Step 1), and the verification script (Task 3 Step 1). `UserApi`, `BillingApi` names match across recipe and verification script. + +- **Naming:** test names follow the existing `test_aexit_*` pattern in `test_client_lifecycle.py` — `test_aclose_closes_owned_httpx2_client` and `test_aclose_is_idempotent_for_owned_client`. Spec used slightly looser names; plan tightened them to the existing convention. From a1b9faafbca6731b938073d729243014b5434bbf Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 6 Jun 2026 22:55:38 +0300 Subject: [PATCH 3/5] feat(client): add AsyncClient.aclose() standalone teardown Mirrors the body of __aexit__: closes the underlying httpx2 client iff we own it and it isn't already closed. Idempotent. Use case: DI containers, background workers, anything not request-shaped that can't lean on `async with`. Aligns the library with its own CLAUDE.md naming convention which already names aclose() as the sole a-prefixed method exception. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/client.py | 10 ++++++++++ tests/test_client_lifecycle.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/httpware/client.py b/src/httpware/client.py index 2984ed9..8f83db2 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -768,3 +768,13 @@ async def __aexit__( """Exit the async context manager; close the underlying client only if owned.""" if self._owns_client and not self._httpx2_client.is_closed: await self._httpx2_client.aclose() + + async def aclose(self) -> None: + """Close the underlying httpx2 client if we own it. + + Idempotent — safe to call after ``__aexit__`` or another ``aclose()`` call. + Use this when the client is not managed by ``async with`` (e.g., wired + into a DI container's lifecycle). + """ + if self._owns_client and not self._httpx2_client.is_closed: + await self._httpx2_client.aclose() diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 5bd9c73..7f23cb0 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -30,3 +30,17 @@ async def test_aexit_is_idempotent_for_owned_client() -> None: pass # Second use should not raise — the boolean prevents a double-close on httpx2 internals. await client.__aexit__(None, None, None) + + +async def test_aclose_closes_owned_httpx2_client() -> None: + client = AsyncClient() + await client.aclose() + assert client._httpx2_client.is_closed # noqa: SLF001 + + +async def test_aclose_is_idempotent_for_owned_client() -> None: + client = AsyncClient() + await client.aclose() + # Second call must not raise — the boolean prevents a double-close on httpx2 internals. + await client.aclose() + assert client._httpx2_client.is_closed # noqa: SLF001 From 014211076867b0e9f9a3f52a9db8a55c7a75a30b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 6 Jun 2026 23:01:04 +0300 Subject: [PATCH 4/5] docs(recipes): add modern-di setup-friction recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear-narrative walk-through of wiring AsyncClient into a modern-di container: minimal Factory + finalizer → multi-backend type collision (DuplicateProviderTypeError) → per-backend wrapper-subclass fix → middleware in kwargs. Adds a new top-level "Recipes" nav section (single item for now) and one back-link from the index's "Where to go next". Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/index.md | 1 + docs/recipes/modern-di.md | 137 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 3 files changed, 140 insertions(+) create mode 100644 docs/recipes/modern-di.md diff --git a/docs/index.md b/docs/index.md index e10a5c9..2f056d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/recipes/modern-di.md b/docs/recipes/modern-di.md new file mode 100644 index 0000000..7077ff9 --- /dev/null +++ b/docs/recipes/modern-di.md @@ -0,0 +1,137 @@ +# 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: AsyncClient is already registered +``` + +`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. diff --git a/mkdocs.yml b/mkdocs.yml index e9afd1f..3753d56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 From e91892ff41f1cde5f2eb1ac7d99eed12bd5e2516 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 6 Jun 2026 23:07:47 +0300 Subject: [PATCH 5/5] docs(recipes): correct DuplicateProviderTypeError message text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end verification against the published modern-di surfaced that the actual exception message reads "Provider is duplicated by type . To resolve this issue: ..." — not the shorter "AsyncClient is already registered" the recipe showed. Module path and class name were already correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/recipes/modern-di.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/recipes/modern-di.md b/docs/recipes/modern-di.md index 7077ff9..4147623 100644 --- a/docs/recipes/modern-di.md +++ b/docs/recipes/modern-di.md @@ -56,7 +56,8 @@ class ServiceClients(Group): ) # At Container(...) construction: -# modern_di.exceptions.DuplicateProviderTypeError: AsyncClient is already registered +# modern_di.exceptions.DuplicateProviderTypeError: Provider is duplicated by type +# . 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.