perf: stop rebuilding the guarded wrapper on every request - #161
Conversation
Every request through the httpx2/httpx transports, the requests adapter and the aiohttp middleware re-ran the breaker's sync/async detection and built a fresh functools.wraps closure before the request could start — per-request overhead with no behavioural value, paid exactly when a service is busiest. - Add CircuitBreaker.call_sync() / call_async(): the same protected path with the dispatch removed, for callers that already know their own nature. call_async awaits whatever the callable returns, so an awaitable-returning callable that is not a coroutine function (an aiohttp handler) works too — which is what let the middleware drop its per-request wrapper coroutine. - Route all four HTTP integrations through them. - Serve a Registry cache hit without taking the registry lock; creation stays on the locked, double-checked path, so one name still yields one breaker. - Apply the same fix to CircuitBreakerStrategy, which decorated the next pipeline layer per call although it statically knows its nature. - Add benchmarks/test_transport.py covering the wrapped transport path. Against a stubbed transport, an httpx request through the wrapper falls from ~10.5 to ~7.5 us (sync) and ~9.9 to ~8.0 us (async); ~3.5 us of that is the stub itself, so the integration's own overhead drops by roughly 40%. Closes #155
WalkthroughChangesThe PR adds explicit Breaker execution and typing
Integration request paths
Registry lookup
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Integration
participant Registry
participant CircuitBreaker
participant Transport
Client->>Integration: send request
Integration->>Registry: get breaker
Registry-->>Integration: cached breaker
Integration->>CircuitBreaker: call_sync or call_async
CircuitBreaker->>Transport: execute request
Transport-->>Client: return response
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 9✅ Passed checks (9 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Merging this PR will improve performance by 35.24%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | test_registry_get_cached |
40.4 µs | 26.3 µs | +53.7% |
| ⚡ | test_pipeline_decorator |
345 µs | 248.9 µs | +38.63% |
| ⚡ | test_pipeline_breaker_only |
332.9 µs | 255 µs | +30.54% |
| ⚡ | test_pipeline_sync_success |
388.1 µs | 302.5 µs | +28.29% |
| ⚡ | test_pipeline_sync_fallback |
354.5 µs | 279.6 µs | +26.81% |
| 🆕 | test_baseline_unwrapped_transport |
N/A | 291.5 µs | N/A |
| 🆕 | test_baseline_unwrapped_transport_async |
N/A | 548.4 µs | N/A |
| 🆕 | test_transport_request |
N/A | 408.4 µs | N/A |
| 🆕 | test_transport_request_async |
N/A | 707.8 µs | N/A |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing perf/155-transport-hot-path (c44f5fe) with main (90f2afd)
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
interlock/integrations/httpx.py (1)
234-234: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winCache transport callables outside the request path.
Each of the four request paths performs a bound-method lookup for every request, creating a transient bound-method object. Capture each transport callable during initialization and pass the cached callable to
call_syncorcall_async.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interlock/integrations/httpx.py` at line 234, Cache the transport callables during initialization instead of performing bound-method lookups in each request path. Update the four affected call sites—interlock/integrations/httpx.py lines 234-234 and 327-327, and interlock/integrations/httpx2.py lines 234-234 and 328-328—to pass the corresponding cached callable to breaker.call_sync or breaker.call_async, preserving existing request behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@interlock/integrations/httpx.py`:
- Line 234: Cache the transport callables during initialization instead of
performing bound-method lookups in each request path. Update the four affected
call sites—interlock/integrations/httpx.py lines 234-234 and 327-327, and
interlock/integrations/httpx2.py lines 234-234 and 328-328—to pass the
corresponding cached callable to breaker.call_sync or breaker.call_async,
preserving existing request behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1cbed504-99ec-4689-bb4b-836870b67992
📒 Files selected for processing (19)
CHANGELOG.mdbenchmarks/test_transport.pydocs/getting-started.mddocs/llms-full.txtdocs/reference.mdinterlock/breaker.pyinterlock/integrations/aiohttp.pyinterlock/integrations/httpx.pyinterlock/integrations/httpx2.pyinterlock/integrations/requests.pyinterlock/pipeline.pyinterlock/registry.pytests/test_aiohttp.pytests/test_breaker.pytests/test_httpx.pytests/test_httpx2.pytests/test_registry.pytests/test_requests.pytests/typing_surface.py
📜 Review details
⏰ Context from checks skipped due to timeout. (5)
- GitHub Check: Platform smoke (macos-latest, Python 3.11)
- GitHub Check: Platform smoke (macos-latest, Python 3.14)
- GitHub Check: quality (3.14t)
- GitHub Check: quality (3.13)
- GitHub Check: Run benchmarks
⚠️ CI failures not shown inline (2)
GitHub Actions: Code scanning AI findings on PR #161 / 0_github-advanced-security.txt: Code scanning AI findings on PR #161
Conclusion: failure
##[group]Run set -euo pipefail
�[36;1mset -euo pipefail�[0m
�[36;1mecho "RUNNER_TEMP=$RUNNER_TEMP"�[0m
�[36;1mfind "$RUNNER_TEMP" -maxdepth 1 -type f -name 'git-credentials-*.config' -print -delete�[0m
�[36;1m�[0m
�[36;1m# Generate a unique token and stop processing workflow commands to prevent the runtime from injecting commands�[0m
�[36;1mSTOP_***REDACTED_SECRET_ASSIGNMENT*** /proc/sys/kernel/random/uuid)�[0m
�[36;1m�[0m
�[36;1m# Use a trap to ensure we always resume command processing and check for�[0m
�[36;1m# fallback error annotations, even if the runtime exits with a non-zero code�[0m
�[36;1m# (which would otherwise cause set -e to abort the shell before we get here).�[0m
�[36;1m# The trap preserves the original exit code.�[0m
�[36;1mcopilot_cleanup() {�[0m
�[36;1m �[0m
�[36;1m if [ -n "${GIT_PROXY_PID:-}" ] && kill -0 "$GIT_PROXY_PID" 2>/dev/null; then�[0m
�[36;1m echo "Stopping git-proxy (pid=$GIT_PROXY_PID)..."�[0m
�[36;1m kill "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m for _ in {1..25}; do�[0m
�[36;1m if ! kill -0 "$GIT_PROXY_PID" 2>/dev/null; then break; fi�[0m
�[36;1m sleep 0.2�[0m
�[36;1m done�[0m
�[36;1m if kill -0 "$GIT_PROXY_PID" 2>/dev/null; then�[0m
�[36;1m echo "git-proxy did not stop gracefully; forcing termination."�[0m
�[36;1m kill -KILL "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m fi�[0m
�[36;1m wait "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m fi�[0m
�[36;1m �[0m
�[36;1m echo "::$STOP_***REDACTED_SECRET_ASSIGNMENT***
�[36;1m FALLBACK_FILE="${RUNNER_TEMP}/copilot-fallback-error.txt"�[0m
�[36;1m if [ -f "$FALLBACK_FILE" ]; then�[0m
�[36;1m FALLBACK_MSG=$(head -c 500 "$FALLBACK_FILE" | tr -d '\n\r')�[0m
�[36;1m echo "::error title=Copilot Error::${FALLBACK_MSG}"�[0m
GitHub Actions: Code scanning AI findings on PR #161 / github-advanced-security: Code scanning AI findings on PR #161
Conclusion: failure
##[group]Run set -euo pipefail
�[36;1mset -euo pipefail�[0m
�[36;1mecho "RUNNER_TEMP=$RUNNER_TEMP"�[0m
�[36;1mfind "$RUNNER_TEMP" -maxdepth 1 -type f -name 'git-credentials-*.config' -print -delete�[0m
�[36;1m�[0m
�[36;1m# Generate a unique token and stop processing workflow commands to prevent the runtime from injecting commands�[0m
�[36;1mSTOP_***REDACTED_SECRET_ASSIGNMENT*** /proc/sys/kernel/random/uuid)�[0m
�[36;1m�[0m
�[36;1m# Use a trap to ensure we always resume command processing and check for�[0m
�[36;1m# fallback error annotations, even if the runtime exits with a non-zero code�[0m
�[36;1m# (which would otherwise cause set -e to abort the shell before we get here).�[0m
�[36;1m# The trap preserves the original exit code.�[0m
�[36;1mcopilot_cleanup() {�[0m
�[36;1m �[0m
�[36;1m if [ -n "${GIT_PROXY_PID:-}" ] && kill -0 "$GIT_PROXY_PID" 2>/dev/null; then�[0m
�[36;1m echo "Stopping git-proxy (pid=$GIT_PROXY_PID)..."�[0m
�[36;1m kill "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m for _ in {1..25}; do�[0m
�[36;1m if ! kill -0 "$GIT_PROXY_PID" 2>/dev/null; then break; fi�[0m
�[36;1m sleep 0.2�[0m
�[36;1m done�[0m
�[36;1m if kill -0 "$GIT_PROXY_PID" 2>/dev/null; then�[0m
�[36;1m echo "git-proxy did not stop gracefully; forcing termination."�[0m
�[36;1m kill -KILL "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m fi�[0m
�[36;1m wait "$GIT_PROXY_PID" 2>/dev/null || true�[0m
�[36;1m fi�[0m
�[36;1m �[0m
�[36;1m echo "::$STOP_***REDACTED_SECRET_ASSIGNMENT***
�[36;1m FALLBACK_FILE="${RUNNER_TEMP}/copilot-fallback-error.txt"�[0m
�[36;1m if [ -f "$FALLBACK_FILE" ]; then�[0m
�[36;1m FALLBACK_MSG=$(head -c 500 "$FALLBACK_FILE" | tr -d '\n\r')�[0m
�[36;1m echo "::error title=Copilot Error::${FALLBACK_MSG}"�[0m
🧰 Additional context used
📓 Path-based instructions (13)
**/*.py
📄 CodeRabbit inference engine (AGENTS.md)
**/*.py: Support Python 3.11 and newer; use Python 3.11+ features where required.
Keep the core zero-dependency and use only the standard library; external dependencies must be isolated behind optional integrations.
Use injectedClockinstances for all time access; do not calltime.monotonic()orsleep()directly in logic.
Implement the core as an I/O-free state machine with a singlethreading.Lockaround the await-free critical section, never held across the protected call.
UseProtocols for extension points:Clock,SlidingWindow,Storage,FailureClassifier, andEventListener; do not inherit from internal classes.
Expose one publicCircuitBreakerclass for sync and async operation, with separate internal paths selected by coroutine detection.
Expose the public API through the package__init__.py; keep helpers underscore-prefixed and hidden.
Use absolute imports, placed at the top of the file, ordered as standard library, third-party, then local imports with blank lines between groups.
Use a maximum line length of 100 characters, single-quoted strings, f-strings, andpathlib.Pathinstead ofos.path.
Annotate every parameter and return value; use modern generic syntax andX | Noneinstead ofOptional[X].
UseStrEnumor module-level constants instead of magic constants.
When a constructor or function has three or more arguments, pass them by keyword.
Keep functions focused on one job, generally no longer than 20–30 lines, with minimal side effects and extracted repeated loop logic.
Useasync/awaitfor I/O-bound work,asyncio.TaskGroupinstead ofasyncio.gather, andasyncio.to_threadorProcessPoolExecutorfor CPU-bound work.
Do not mix sync and async in one function; never await a sync callable or block on an async callable.
Fail fast on invalid input or state by raising immediately; do not continue with partial results or invented defaults.
Catch only expected exceptions, log them with context, and re-raise; do not use ...
Files:
interlock/integrations/aiohttp.pytests/typing_surface.pyinterlock/integrations/httpx.pytests/test_httpx.pyinterlock/registry.pytests/test_requests.pyinterlock/integrations/httpx2.pyinterlock/pipeline.pyinterlock/breaker.pyinterlock/integrations/requests.pytests/test_httpx2.pytests/test_breaker.pytests/test_registry.pytests/test_aiohttp.pybenchmarks/test_transport.py
{interlock/**/*.py,docs/**/*.md,docs/llms-full.txt,docs/llms.txt}
📄 CodeRabbit inference engine (Custom checks)
When a change affects user-facing behaviour through the public API, integrations, or configuration options, update the relevant page under
docs/and regeneratedocs/llms-full.txt; when adding a new documentation page, list it under## Docsindocs/llms.txt.
Files:
interlock/integrations/aiohttp.pyinterlock/integrations/httpx.pyinterlock/registry.pydocs/reference.mddocs/getting-started.mddocs/llms-full.txtinterlock/integrations/httpx2.pyinterlock/pipeline.pyinterlock/breaker.pyinterlock/integrations/requests.py
interlock/**/*.py
📄 CodeRabbit inference engine (Custom checks)
Every production behaviour change in
interlock/must be accompanied by a change undertests/; changes limited to docstrings, comments, or type annotations are exempt. Bug fixes must include at least one regression test that fails without the production fix.
Files:
interlock/integrations/aiohttp.pyinterlock/integrations/httpx.pyinterlock/registry.pyinterlock/integrations/httpx2.pyinterlock/pipeline.pyinterlock/breaker.pyinterlock/integrations/requests.py
⚙️ CodeRabbit configuration file
Core rules (AGENTS.md is authoritative): (1) Zero-dependency core — anything under interlock/ except interlock/integrations/ may import stdlib only. Flag every third-party import as a blocking issue. (2) No fallbacks, no silent excepts, no
a or b or cfor required config or data, no hidden retries. Invalid input or state raises immediately. interlock/_notify.py is the one sanctioned swallow (listener hooks are observability, logged with traceback, BaseException still propagates) — do not suggest generalising or "fixing" it. (3) Time comes only from the injected Clock protocol; direct time.monotonic()/time.sleep() in library logic is a bug. (4) Style: 100-char lines, single quotes, f-strings, pathlib, full annotations,X | NoneneverOptional[X], keyword arguments for calls with 3+ arguments, no magic constants (StrEnum or module constants), functions under ~30 lines. (5) Extension points are Protocols (Clock, SlidingWindow, Storage, FailureClassifier, EventListener) — do not propose inheriting internal classes. (6) Sync and async live in one CircuitBreaker with separate internal paths; never propose Sync*/Async* twins and never mix the two paths in one function. (7) Public API is exported from interlock/init.py; everything else is underscore-prefixed. New public symbols need__all__and a docstring. (8) Python 3.11 is the floor — no 3.12+ syntax or stdlib.
Files:
interlock/integrations/aiohttp.pyinterlock/integrations/httpx.pyinterlock/registry.pyinterlock/integrations/httpx2.pyinterlock/pipeline.pyinterlock/breaker.pyinterlock/integrations/requests.py
interlock/integrations/**/*.py
⚙️ CodeRabbit configuration file
Optional extras. The third-party import must stay inside this package, must never be re-exported from interlock/init.py, and a missing extra must fail with a clear install hint rather than a fallback. Wrap the dependency behind the project's own types so its objects do not leak into core signatures. Check that the extra is declared in pyproject.toml
[project.optional-dependencies]and documented under docs/integrations/.
Files:
interlock/integrations/aiohttp.pyinterlock/integrations/httpx.pyinterlock/integrations/httpx2.pyinterlock/integrations/requests.py
tests/**/*.py
📄 CodeRabbit inference engine (AGENTS.md)
tests/**/*.py: Use pytest functions rather than test classes, with names formatted astest__unit_of_work__state_under_test__expected_behavior.
Mirror package layout in test filenames, use Arrange-Act-Assert, and create fixtures for repeated setup.
Use injectedClockinstances for deterministic tests; do not usesleep()in tests.
Usepytest-asyncioand@pytest.mark.asynciofor asynchronous tests, and usepytest-mockto isolate external dependencies.
Use Hypothesis property-based tests for the state machine and cover all transitions and races.
Write the reproducing test before a bug fix and specify the required behavior before implementing a feature.
Files:
tests/typing_surface.pytests/test_httpx.pytests/test_requests.pytests/test_httpx2.pytests/test_breaker.pytests/test_registry.pytests/test_aiohttp.py
⚙️ CodeRabbit configuration file
pytest functions only, never test classes. Names follow
test__unit_of_work__state_under_test__expected_behaviorin lower case. One behaviour per test, Arrange-Act-Assert. Time is the injected fake Clock — any real sleep or wall-clock read is flakiness, flag it. Async tests use@pytest.mark.asyncio; state-machine work carries hypothesis property tests. Coverage must stay at 100%: point out uncovered branches the diff introduces. Tests run under-n auto, so anything relying on ordering or shared global state is a bug.
Files:
tests/typing_surface.pytests/test_httpx.pytests/test_requests.pytests/test_httpx2.pytests/test_breaker.pytests/test_registry.pytests/test_aiohttp.py
{interlock/*.py,interlock/!(integrations)/**/*.py,pyproject.toml}
📄 CodeRabbit inference engine (Custom checks)
Keep the core zero-dependency: files under
interlock/outsideinterlock/integrations/may import only the standard library or otherinterlockmodules;[project] dependenciesinpyproject.tomlmust remain empty; andinterlock/__init__.pymust not re-export frominterlock.integrations.
Files:
interlock/registry.pyinterlock/pipeline.pyinterlock/breaker.py
**/*.md
📄 CodeRabbit inference engine (AGENTS.md)
Document user-facing changes in English Markdown documentation and keep generated documentation mirrors synchronized.
Files:
docs/reference.mdCHANGELOG.mddocs/getting-started.md
docs/**/*.md
⚙️ CodeRabbit configuration file
User-facing documentation. Check that code samples match the current public API and would actually run. A new page must also be listed in docs/llms.txt under
## Docs. Keep the existing voice: short sentences, no marketing.
Files:
docs/reference.mddocs/getting-started.md
CHANGELOG.md
📄 CodeRabbit inference engine (AGENTS.md)
Add every change to the
[Unreleased]section underAdded,Fixed, orChanged, explaining user impact rather than only symbol movement.
Files:
CHANGELOG.md
⚙️ CodeRabbit configuration file
Keep a Changelog format. New entries go under
## [Unreleased]in Added / Fixed / Changed. An entry describes what a user could not do before and can now, not which symbol moved. Only the release commit dates a section and updates the link references.
Files:
CHANGELOG.md
docs/llms-full.txt
⚙️ CodeRabbit configuration file
Generated artefact — produced by
uv run python scripts/build_llms_full.py. Do not review its content or suggest edits; only confirm it was regenerated together with the docs/ changes in the same PR.
Files:
docs/llms-full.txt
{interlock/__init__.py,interlock/pipeline.py}
📄 CodeRabbit inference engine (Custom checks)
Do not remove or change signatures of symbols exported from
interlock/__init__.pyorinterlock/pipeline.py—including removed or reordered positional parameters, narrowed types, or renamed public names—unless the PR has thebreaking-changelabel andCHANGELOG.mdincludes a migration note. CI enforces this withuv run griffe check.
Files:
interlock/pipeline.py
benchmarks/**/*.py
📄 CodeRabbit inference engine (CONTRIBUTING.md)
benchmarks/**/*.py: Add a benchmark when you touch a hot path: the call paths, the state machine,
the sliding windows or the pipeline.
Files:
benchmarks/test_transport.py
benchmarks/**
⚙️ CodeRabbit configuration file
CodSpeed hot-path benchmarks, run only by .github/workflows/codspeed.yml and never by
uv run pytest. Judge them for measurement validity (no setup inside the timed region, no dead code the optimiser can drop), not for test-suite conventions.
Files:
benchmarks/test_transport.py
🧠 Learnings (7)
📓 Common learnings
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T15:34:32.493Z
Learning: Applies to **/*.py : Expose one public `CircuitBreaker` class for sync and async operation, with separate internal paths selected by coroutine detection.
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T15:34:32.493Z
Learning: Applies to CHANGELOG.md : Add every change to the `[Unreleased]` section under `Added`, `Fixed`, or `Changed`, explaining user impact rather than only symbol movement.
📚 Learning: 2026-08-07T15:34:32.493Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T15:34:32.493Z
Learning: Applies to **/*.py : Expose one public `CircuitBreaker` class for sync and async operation, with separate internal paths selected by coroutine detection.
Applied to files:
interlock/integrations/aiohttp.pytests/typing_surface.pyinterlock/integrations/httpx.pytests/test_httpx.pydocs/reference.mdCHANGELOG.mddocs/getting-started.mddocs/llms-full.txttests/test_requests.pyinterlock/integrations/httpx2.pyinterlock/pipeline.pyinterlock/breaker.pyinterlock/integrations/requests.pytests/test_httpx2.pytests/test_breaker.pytests/test_aiohttp.py
📚 Learning: 2026-08-07T15:34:32.493Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T15:34:32.493Z
Learning: Applies to tests/**/*.py : Use `pytest-asyncio` and `pytest.mark.asyncio` for asynchronous tests, and use `pytest-mock` to isolate external dependencies.
Applied to files:
tests/test_httpx.pytests/test_httpx2.pytests/test_breaker.pytests/test_aiohttp.py
📚 Learning: 2026-08-07T15:34:59.595Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-08-07T15:34:59.595Z
Learning: Applies to {interlock/**/*.py,docs/**/*.md,docs/llms-full.txt,docs/llms.txt} : When a change affects user-facing behaviour through the public API, integrations, or configuration options, update the relevant page under `docs/` and regenerate `docs/llms-full.txt`; when adding a new documentation page, list it under `## Docs` in `docs/llms.txt`.
Applied to files:
docs/llms-full.txt
📚 Learning: 2026-08-07T15:34:59.595Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-08-07T15:34:59.595Z
Learning: Applies to {interlock/__init__.py,interlock/pipeline.py} : Do not remove or change signatures of symbols exported from `interlock/__init__.py` or `interlock/pipeline.py`—including removed or reordered positional parameters, narrowed types, or renamed public names—unless the PR has the `breaking-change` label and `CHANGELOG.md` includes a migration note. CI enforces this with `uv run griffe check`.
Applied to files:
interlock/pipeline.py
📚 Learning: 2026-08-07T15:34:32.493Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-07T15:34:32.493Z
Learning: Applies to **/*.py : Do not mix sync and async in one function; never await a sync callable or block on an async callable.
Applied to files:
interlock/breaker.py
📚 Learning: 2026-08-07T15:34:59.595Z
Learnt from: CR
Repo: bagowix/interlock PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-08-07T15:34:59.595Z
Learning: Applies to interlock/**/*.py : Every production behaviour change in `interlock/` must be accompanied by a change under `tests/`; changes limited to docstrings, comments, or type annotations are exempt. Bug fixes must include at least one regression test that fails without the production fix.
Applied to files:
tests/test_breaker.pytests/test_registry.py
Summary
Every guarded request through an HTTP integration rebuilt the same wrapper from scratch:
breaker(fn)re-ran the coroutine-function detection, allocated afunctools.wrapsclosure, and threw it away one call later — on the hottest path a fault-tolerance library has.Registry.getthen took the registry lock even for a plain cache hit, serialising readers that had nothing to create.Two additions remove the work instead of caching its result (the issue's option 1 would have added a second cache to invalidate — a new entity for a problem that disappears once the caller states what it already knows):
CircuitBreaker.call_sync/call_async— the same protected path, no dispatch, no decoration. The caller declares the callable's nature, so nothing is detected and nothing is wrapped.call_syncnever awaits (a coroutine function passed there is recorded as an immediate success — documented);call_asyncawaits whatever the callable returns, so it also fits handlers that are not coroutine functions, which is exactly the shape an aiohttp middleware chain hands over.callis unchanged and stays the dispatching front door.Registry.get— a hit reads the dict without the lock; a miss falls through to the existing locked create, now with a double-check. A breaker is never replaced or removed once cached, so a reader that races a creation can at worst take the slow path. One name still yields exactly one breaker; there is a test that loses the race deliberately and asserts the winner is returned, not replaced.All four transports (
httpx,httpx2,requests,aiohttp) now call through the new methods. aiohttp additionally loses its per-requestasync def _send()closure — the handler goes straight tocall_async.Beyond the issue's scope, the same defect lives in the core:
CircuitBreakerStrategyinpipeline.pydecorated the next strategy on every execution although it statically knows whether it is on the sync or the async path. Fixed in the same way; noted separately inCHANGELOG.md.Measurements
Stub transport answering from memory, three passes, same venv (a temporary worktree at
mainfor the baseline):Discounting the stub itself, the integration's own overhead drops roughly 40%.
benchmarks/test_transport.pyis new so CodSpeed guards the wrapped-transport path from now on (wrapped and unwrapped, sync and async).Semantics are untouched: same classification, same exception propagation, same cancellation handling in the engine, same lock scope.
griffe checkis clean — the two methods are purely additive, so nobreaking-changelabel.Checklist
uv run ruff format --checkanduv run ruff checkpassuv run mypy,uv run pyrightanduv run pyrefly checkpassdocs/) for user-facing changesCHANGELOG.md[Unreleased]updatedTests first: each integration got a test that fails on the old code by making re-decoration observable (
CircuitBreaker.__call__patched to raise), plus the behavioural cases for the new methods —call_syncnot awaiting a coroutine function,call_asyncaccepting a non-coroutine callable returning an awaitable, both respectingCircuitOpenErrorand the sync-storage guard.tests/typing_surface.pypins the inferred return types of both. The registry got the lock-free-hit assertion (a counting lock double) and the lost-race test.Related issues
Closes #155
Added
CircuitBreaker.call_sync()andCircuitBreaker.call_async()to theinterlockAPI.httpxextra.Fixed
httpx,httpx2,requests, andaiohttpextras.aiohttpextra.Changed
Registry.get()now returns cache hits without acquiring the registry lock.