Skip to content

feat: Bulkhead middleware (0.4.0 slice 2)#23

Merged
lesnik512 merged 15 commits into
mainfrom
feat/v0.4-bulkhead
Jun 5, 2026
Merged

feat: Bulkhead middleware (0.4.0 slice 2)#23
lesnik512 merged 15 commits into
mainfrom
feat/v0.4-bulkhead

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Summary

Second ship-unit of Epic 3 (Resilience): a Bulkhead middleware (concurrency limiter via asyncio.Semaphore) plus BulkheadFullError. Bundles into the 0.4.0 release alongside slice 1 (Retry + RetryBudget, already on main via #22).

  • httpware.Bulkhead middleware — caps in-flight requests at the caller layer (distinct from httpx2.Limits which caps the connection pool). Required max_concurrent parameter (no default — no universally-correct value). acquire_timeout=1.0 default, None = wait forever, 0 = fail fast. Explicit acquire → try/finally → release guarantees slot release on success, exception, and cancellation. Bulkhead is the sharable unit: reuse the instance across AsyncClient(middleware=[shared]) to enforce a joint cap.
  • httpware.BulkheadFullError(ClientError) — raised when acquire_timeout elapses without acquiring a slot. Carries max_concurrent + acquire_timeout. Picklable via __reduce__ + module-level reconstructor (matches StatusError / RetryBudgetExhaustedError pattern). Inherits ClientError (not TimeoutError) — semantically a backpressure signal, not a network timeout.
  • 0.4.0 release notes amended to describe Bulkhead alongside Retry/RetryBudget with usage examples (default config, sharing pattern, recommended Bulkhead-outside-Retry ordering).

Remaining Epic 3 work: 3-6 extension-slot docs (docs-only follow-up). Out of scope for this slice (per spec): per-host partitioning, queue-depth metrics, fallback callbacks.

Test Plan

  • just test — 207 passing, 100% line coverage
  • just lint-ci — eof-fixer, ruff format, ruff check, ty check all clean
  • All 5 architecture invariants verified clean (no httpx2._, no from __future__ import annotations, no print(), no global logging, no # type:/# mypy: ignore)
  • Optional-extras isolation verified (Bulkhead is pure stdlib; importing httpware does not pull pydantic / msgspec)
  • Hypothesis property tests: in-flight never exceeds max_concurrent; fail-fast at capacity raises BulkheadFullError; no slot leak after drain
  • Cancellation semantics: slot released after exception in next(), after CancelledError during next(), and when cancelled while parked on acquire()
  • _MODULE_SCOPE_BULKHEAD constant pins construct-outside-event-loop behavior
  • Shared Bulkhead enforces joint cap across two AsyncClients (verified with shared in-flight counter)
  • Reviewer sanity-check that the recommended [Bulkhead, Retry] ordering composes correctly (covered structurally by middleware chain tests; no explicit integration test in this slice — flagged in final review as a minor follow-up)

Refs

🤖 Generated with Claude Code

lesnik512 and others added 13 commits June 5, 2026 13:51
8 TDD tasks targeting feat/v0.5-bulkhead. Implements the Bulkhead
middleware (asyncio.Semaphore-backed, required max_concurrent, bounded
acquire wait with BulkheadFullError on timeout), BulkheadFullError
(picklable, ClientError parent), three test files (unit, sharing/sanity,
Hypothesis property), and wires the public API + planning docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user decision, Bulkhead ships in 0.4.0 with Retry rather than waiting
for its own 0.5.0 cut.

- Amend planning/releases/0.4.0.md to describe Bulkhead + BulkheadFullError
  alongside Retry / RetryBudget / NetworkError, with usage examples that
  show the recommended Bulkhead-outside-Retry middleware ordering.
- Update Bulkhead spec target release: 0.5.0 → 0.4.0 (bundled with slice 1).
- Update Bulkhead plan target branch: feat/v0.5-bulkhead → feat/v0.4-bulkhead;
  update Task 8 release-notes guidance to reflect amendment vs. fresh draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Distinct exception raised by the Bulkhead middleware when acquire_timeout
elapses without acquiring a slot. Carries max_concurrent + acquire_timeout
for caller logging. Picklable via _reconstruct_bulkhead_full + __reduce__,
mirroring the existing StatusError / RetryBudgetExhaustedError pattern.

Inherits ClientError (not TimeoutError) because a bulkhead-full event is
a backpressure signal, not a network timeout.
…acity

Constructor validates max_concurrent >= 1 and acquire_timeout >= 0
(None and 0 both accepted). asyncio.Semaphore enforces the cap; the
explicit acquire + try/finally around next() guarantees release on
every exit path. Acquire failures map to BulkheadFullError.

Subsequent tasks cover fail-fast / wait-forever modes, exception +
cancellation release semantics, cross-client sharing, and property tests.
Code reviewer noted the docstring was stale after Bulkhead landed.
One-line update; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t / forever

Pins the three acquire_timeout modes: bounded wait raises BulkheadFullError
after the configured timeout, =0 fails fast without waiting, =None waits
until a slot frees.
Pins the try/finally release: exception in next() releases the slot,
cancellation during next() releases the slot, cancellation while
parked on acquire() does not hold a slot.
…-loop

Pins two behaviors: a Bulkhead instantiated at module scope (outside any
event loop) works correctly when used inside one, and a single Bulkhead
instance passed to multiple AsyncClient instances enforces the joint cap
across all of them.
Three invariants: in-flight never exceeds max_concurrent under any
interleaving; fail-fast (acquire_timeout=0) raises BulkheadFullError
when at capacity; after all calls drain, the bulkhead has full capacity
available again (no slot leak).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes the v0.4 slice 2: Bulkhead concurrency limiter middleware + its
backpressure exception. Pure-stdlib core, no new optional extra.
…, not asyncio.TimeoutError)

In Python 3.11+, asyncio.TimeoutError IS builtins.TimeoutError. The
implementation uses the bare name; align the spec snippet so spec and
code agree. Cosmetic — no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 self-assigned this Jun 5, 2026
lesnik512 and others added 2 commits June 5, 2026 14:44
…tests + docstring

- Bulkhead class docstring: replace one-liner with Parameters section so
  IDE hovers carry the required-vs-default + range info.
- test_cancellation_before_acquire_does_not_hold_slot: issue a third request
  WHILE first still holds the slot. Pins that the cancelled second did not
  leave a phantom free slot behind.
- Add Bulkhead+Retry composition tests (test_bulkhead.py):
  - test_bulkhead_outside_retry_holds_one_slot_across_attempts — pins the
    documented [Bulkhead, Retry] ordering: one slot covers the whole retry
    sequence (max_in_flight stays at 1 even though handler is called twice).
  - test_bulkhead_full_error_is_not_retried_by_retry — pins that
    BulkheadFullError (ClientError) is NOT in Retry's catch set; injected
    _sleep with # pragma: no cover documents the "must never run" assertion.

Skipped one review item (#3): the BulkheadFullError summary string includes
"acquire_timeout=None" when constructed manually with None. This branch is
unreachable by design — the exception is never raised when acquire_timeout=None.
Cosmetic at best; not worth special-casing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lled sleep

User feedback: # pragma: no cover on a user-defined function body that
intentionally never runs is the wrong shape — refactor to use a mock
whose body is structural (unittest.mock internals are excluded from
coverage measurement). AsyncMock().assert_not_called() expresses the
"must never run" assertion cleanly with no coverage gymnastics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 merged commit 85174d3 into main Jun 5, 2026
5 checks passed
@lesnik512 lesnik512 deleted the feat/v0.4-bulkhead branch June 5, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant