feat: Bulkhead middleware (0.4.0 slice 2)#23
Merged
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second ship-unit of Epic 3 (Resilience): a
Bulkheadmiddleware (concurrency limiter viaasyncio.Semaphore) plusBulkheadFullError. Bundles into the 0.4.0 release alongside slice 1 (Retry + RetryBudget, already onmainvia #22).httpware.Bulkheadmiddleware — caps in-flight requests at the caller layer (distinct fromhttpx2.Limitswhich caps the connection pool). Requiredmax_concurrentparameter (no default — no universally-correct value).acquire_timeout=1.0default,None= wait forever,0= fail fast. Explicitacquire → try/finally → releaseguarantees slot release on success, exception, and cancellation.Bulkheadis the sharable unit: reuse the instance acrossAsyncClient(middleware=[shared])to enforce a joint cap.httpware.BulkheadFullError(ClientError)— raised whenacquire_timeoutelapses without acquiring a slot. Carriesmax_concurrent+acquire_timeout. Picklable via__reduce__+ module-level reconstructor (matchesStatusError/RetryBudgetExhaustedErrorpattern). InheritsClientError(notTimeoutError) — semantically a backpressure signal, not a network timeout.Bulkhead-outside-Retryordering).Remaining Epic 3 work:
3-6extension-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 coveragejust lint-ci— eof-fixer, ruff format, ruff check, ty check all cleanhttpx2._, nofrom __future__ import annotations, noprint(), no global logging, no# type:/# mypy:ignore)Bulkheadis pure stdlib; importinghttpwaredoes not pull pydantic / msgspec)max_concurrent; fail-fast at capacity raisesBulkheadFullError; no slot leak after drainnext(), afterCancelledErrorduringnext(), and when cancelled while parked onacquire()_MODULE_SCOPE_BULKHEADconstant pins construct-outside-event-loop behaviorBulkheadenforces joint cap across twoAsyncClients (verified with shared in-flight counter)[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