A modern circuit breaker for Python: sync + async in a single class, sliding-window rate, slow-call detection, a type-safe API and transparent transport-level integrations. This is a foundational library — people put it on the critical path of their services, so the bar for simplicity, reliability and dependency hygiene is higher than usual.
- Start from the problem: understand it first, pick tools second.
- Occam's razor: no new entities or abstractions without necessity.
- Never break existing functionality while making a change.
- A solution must be simple, reliable and readable without extra explanation.
- Minimise cognitive load — for the human and for the next agent.
- The upsides of a decision must heavily outweigh the downsides. When combining approaches, take the best of each.
- Write docstrings in English.
- Python 3.11+ — the minimum supported version (
asyncio.timeout,TaskGroup, exception groups,Selfare required). Development and CI run on 3.11–3.14. The floor is deliberately below the usual 3.12 default: a foundational library values reach. - Zero-dependency core — stdlib only. A fault-tolerance tool must not depend on someone
else's reliability. Everything external goes through optional extras
(
interlock-cb[httpx2],[httpx],[aiohttp],[requests],[tenacity],[fastapi],[litestar],[redis],[otel]). - Pydantic ≥ 2.0 — extras only, NEVER in the core. The core config is a frozen dataclass with eager validation. Pydantic is acceptable only where it is optional.
- uv — package manager.
- hatchling — build backend. The version is static in
interlock/version.py([tool.hatch.version] path), bumped manually on release and exposed asinterlock.__version__. - ruff — formatting and linting.
- mypy + pyright + pyrefly in strict mode — static analysis.
- pytest +
pytest-asyncio,pytest-cov,pytest-mock,pytest-sugar,faker,hypothesis. - mutmut — mutation testing over the state machine and the engine; out of band, never a PR gate.
- zizmor — static audit of the GitHub Actions workflows; a PR gate like ruff and mypy.
- griffe — public-API breakage detection against the latest release tag; a PR gate,
overridable with the
breaking-changelabel for intentional changes. - Zensical — documentation (plain-Markdown content, portable).
- Virtual environment:
uv venv --python 3.12 .venv - Run commands:
uv run <command> - Dependencies:
uv add <package>/ dev:uv add --dev <package> - Format:
uv run ruff format - Lint:
uv run ruff check --fix - Types:
uv run mypy,uv run pyrightanduv run pyrefly check - Tests:
uv run pytest/ with coverage:uv run pytest --cov - Workflows:
uv run zizmor .github/workflows/(exportGH_TOKENfor the online audits) - Public API:
uv run griffe check interlock --search .(diffs against the latest release tag) - Mutants:
uv run mutmut run(optional, out of band — seeCONTRIBUTING.md)
ruff is configured in pyproject.toml (line-length = 100, target-version = "py311"), not via
CLI flags.
- The core is an I/O-free state machine. State, window, thresholds and transitions do no I/O
and know nothing about sync vs async. Around the await-free critical section sits a single
threading.Lock(correct for both threads and the event loop); the lock is never held across the protected call. - Extension points are
Protocols, not inheritance of internal classes:Clock,SlidingWindow,Storage,FailureClassifier,EventListener. - One public
CircuitBreakerclass for sync and async. NoSync*/Async*twins. The class detects a coroutine function and dispatches to the right path. - Time only through the injected
Clock. No directtime.monotonic()/sleepin logic: it breaks test determinism. - Group by feature, not by kind (no
models/,services/,utils/). Each concept — window, state machine, classifier — gets its own module. - The public API goes through the package
__init__.py. Helpers are underscore-prefixed and hidden. - Encapsulate external dependencies behind wrappers; extras never leak into the core.
- Maximum line length: 100 characters.
- Single quotes for strings.
- f-strings instead of
.format()and%. pathlib.Pathinstead ofos.path.- Context managers instead of
try/finally.
- Always at the top of the file.
- Absolute imports only.
- Group order: stdlib → third-party → local (blank line between groups).
- Annotations on every parameter and return value.
- Modern syntax:
list[str],dict[str, int],str | None. - Never
Optional[X]— onlyX | None. - No magic constants — use
StrEnumor module-level constants.
With 3+ arguments, keyword arguments only.
# bad
config = Config(0.5, 10, 60.0)
# good
config = Config(failure_rate_threshold=0.5, minimum_number_of_calls=10, wait_duration_in_open=60.0)- One function — one job.
- 20–30 lines at most.
- Minimal side effects; prefer pure functions over stateful ones.
- Repeated code in a loop gets extracted into a variable or function.
async/awaitis the standard for I/O-bound work.asyncio.TaskGroupinstead ofasyncio.gather(3.11+).- Do not mix sync and async in one function: if a function calls async code, it is async itself.
This does not contradict the unified
CircuitBreaker: it has separate internal sync/async paths selected by the detector. We never await a sync callable and never block on an async one. - CPU-bound work goes through
asyncio.to_threadorProcessPoolExecutor.
- Fail fast: on invalid input or state —
raiseimmediately, never continue with a partial result. - Custom exception classes with informative messages and context (e.g.
CircuitOpenErrorcarries the breaker name, an estimate for the next probe, the last failure). - Catch only expected exceptions, log with context, re-raise.
# bad — the error is hidden behind a fallback
def get_state(name: str) -> State:
try:
return storage.load(name)
except Exception:
return State.CLOSED
# good — fail fast, transparent error
def get_state(name: str) -> State:
if not name:
raise ValueError(f'Empty breaker name: {name!r}')
return storage.load(name)- pytest, functions only (no test classes).
- Naming:
test__unit_of_work__state_under_test__expected_behavior(lower case). Example:test__closed__failure_rate_at_threshold__opens. - Test files mirror the package layout:
interlock/window.py→tests/test_window.py. - One test — one behaviour. Arrange-Act-Assert structure.
- Fixtures for repeated setup.
pytest-mockto isolate external dependencies. pytest-asynciofor async (@pytest.mark.asyncio).fakerfor test data.- Deterministic time via the injected
Clock— nosleepin tests. - Property-based tests (
hypothesis) for the state machine; 100% coverage of all transitions and races.
def test__count_based__majority_failures__failure_rate_above_threshold() -> None:
window = CountBasedSlidingWindow(size=10)
for _ in range(6):
window.record(Outcome.FAILURE)
assert window.snapshot().failure_rate >= 0.5- Expressive names instead of comments.
- Docstrings for the public API: what it does, arguments, return value, raised exceptions.
- Comments only for non-obvious logic, trade-offs and limitations.
- Visible deprecations via
InterlockDeprecationWarning(UserWarning).
# bad — states the obvious
# open the circuit
self._state = State.OPEN
# good — explains what is not obvious
# In HALF_OPEN admit at most N probes at a time, otherwise the whole concurrent load
# rushes in as probes and knocks over the barely recovered dependency.A change is not finished when the tests pass. Before opening a PR:
CHANGELOG.md— add an entry under[Unreleased](Added/Fixed/Changed). Describe what was broken and why it mattered to a user, not just which symbol moved. Only the release step dates the section and updates the link refs.docs/— update the affected pages for any user-facing change, then regenerate the LLM mirror:uv run python scripts/build_llms_full.py. A new page also goes intodocs/llms.txtunder## Docsfirst. Commit the Markdown andllms-full.txttogether.- Coverage stays at 100%;
ruff format,ruff check,mypy,pyrightandpyreflyall clean. - Tests first. A bug fix starts with a test that reproduces it; a feature starts with the behaviour it must exhibit.
- Touching
_state_machine.pyor_engine.py? Runuv run mutmut runas well. Coverage will stay at 100% whether or not the new tests assert anything; a surviving mutant says they do not.
The PR checklist in .github/PULL_REQUEST_TEMPLATE.md mirrors this — it is the last gate, not the
first reminder.
- No fallback values: never invent defaults to paper over missing data.
- No silent exceptions: catch only what is expected, log with context, re-raise.
The one sanctioned exception is
interlock/_notify.py: anEventListenerhook is observability, never policy, so a raising hook is logged with its traceback and swallowed rather than allowed to fail the protected call or kill a coordinator lane. It is not silent, andBaseExceptionstill propagates. Do not "fix" it, and do not generalise it — every otherexceptobeys the rule. - No default chains in business logic:
a or b or cis for UI labels only, never for required config or data. - No hidden retries: retries are acceptable only when explicitly requested, idempotent, aimed at transient errors, bounded in attempts and logged.
- Fail fast: on invalid input or state —
raise.
Specific to this project: retry / fallback / timeout are library features (v1–v2), but they must be exactly what the rules above demand — explicit, bounded, observable. No hidden "it will retry / substitute something by itself" magic inside the breaker.