diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3681c6b..74bdca0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
the theme emits no such tags and nothing supplied them. Each page now declares
its title, description and canonical url. The card is the text-only `summary`
kind: Zensical ships no social-card generation and the documentation carries
- no image asset.
+ no social-card image.
- **The documentation site is verified with Google Search Console.** Every
generated page carries the ownership tag for the property, so the maintainers
@@ -21,6 +21,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
previously a blind spot. Nothing about the library itself changes. Removing
the tag silently un-verifies the property.
+- **The README opens with the state machine as a diagram.** The three states and
+ the condition on every edge had to be assembled from prose, so the shape of
+ the thing being installed was the one thing the landing page never showed.
+ `docs/img/state-machine.svg` draws CLOSED, OPEN and HALF_OPEN with their
+ transitions — including the slow-call rate, the edge no other Python breaker
+ has. One asset, no external fonts and no theme-dependent colours, so it
+ renders the same on GitHub, on PyPI and in either colour scheme.
+
### Changed
- **Every link in the PyPI sidebar now goes somewhere different.** `Homepage`
@@ -37,6 +45,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`seo_titles` map in `zensical.toml` gives each page a self-describing title;
a page left out of the map keeps the theme default.
+- **The README now demonstrates what this breaker does differently.** Its only
+ configured example set a failure rate and a minimum call count — the two knobs
+ every other library has — while slow-call detection and the single sync/async
+ class, the reasons to choose it, stayed bullet points with no code behind
+ them. The quickstart now configures the slow-call thresholds, explains the
+ dependency they catch (one that answers slowly and never raises, so a
+ consecutive-failure counter never trips), and guards an async callable with
+ the same instance. A new section shows Redis-backed shared state in five
+ lines, and the rollout section dropped the paragraph that restated the states
+ guide.
+
### Fixed
- **The documentation landing page was titled `interlock - interlock`.**
@@ -55,6 +74,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
never imported the middleware and the adapter they construct, unlike every
other block on those pages.
+- **Every link in the README now works from PyPI as well.** The README is the
+ package's long description, and PyPI resolves a relative link against
+ `pypi.org` rather than the repository — so all 31 of them, the whole of
+ `docs/` plus `CONTRIBUTING.md`, `SECURITY.md`, the licence and the examples,
+ answered 404 for a reader who arrived on the package page, which is exactly
+ where the new `Homepage` link now sends people. Every link is absolute:
+ documentation goes to the published site rather than to raw Markdown, which
+ also spares a GitHub reader the tab syntax that only renders once built.
+ `tests/test_readme.py` fails the build on a relative link, on a documentation
+ url with no page behind it, on a repository url with no file behind it, and on
+ an anchor with no matching heading.
+
## [2.6.0] - 2026-08-12
### Added
diff --git a/README.md b/README.md
index b621e33..b72af1a 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,24 @@
# interlock
+[](https://pypi.org/project/interlock-cb/)
+[](https://pypi.org/project/interlock-cb/)
+[](https://pypi.org/project/interlock-cb/)
+[](https://github.com/bagowix/interlock/blob/main/LICENSE)
[](https://github.com/bagowix/interlock/actions/workflows/ci.yml)
[](https://codecov.io/gh/bagowix/interlock)
[](https://scorecard.dev/viewer/?uri=github.com/bagowix/interlock)
[](https://www.bestpractices.dev/projects/13932)
-[](https://pypi.org/project/interlock-cb/)
-[](https://pypi.org/project/interlock-cb/)
-[](https://pypi.org/project/interlock-cb/)
-[](LICENSE)
-[](docs/llms.txt)
+[](https://app.codspeed.io/bagowix/interlock?utm_source=badge)
[](https://bagowix.github.io/interlock/)
+[](https://bagowix.github.io/interlock/llms.txt)
[](https://context7.com/bagowix/interlock)
-[](https://app.codspeed.io/bagowix/interlock?utm_source=badge)
A modern circuit breaker for Python — sync and async in a single class,
sliding-window rate and slow-call detection, a type-safe API, and transparent
integrations at the transport level.
+
+
## Installation
```bash
@@ -28,18 +30,23 @@ library; external integrations are installed as [optional extras](#integrations)
## Quickstart
-Create one named breaker and reuse it around calls to the same dependency:
+Create one named breaker per dependency and reuse it around every call to it:
```python
from interlock import CircuitBreaker, CircuitOpenError, Config
-breaker = CircuitBreaker(
+payments = CircuitBreaker(
name='payments',
- config=Config(failure_rate_threshold=0.5, minimum_number_of_calls=20),
+ config=Config(
+ failure_rate_threshold=0.5, # trip at 50% failures...
+ minimum_number_of_calls=20, # ...once the window holds 20 calls
+ slow_call_duration_threshold=2.0, # a call slower than 2s counts as slow
+ slow_call_rate_threshold=0.3, # 30% slow calls trip it just as well
+ ),
)
-@breaker
+@payments
def charge(amount: int) -> str:
return gateway.charge(amount)
@@ -47,14 +54,29 @@ def charge(amount: int) -> str:
try:
receipt = charge(100)
except CircuitOpenError as exc:
- print(exc)
+ print(exc) # Circuit 'payments' is open; retry in ~60.000s
+```
+
+The slow-call thresholds matter as much as the failure ones: a dependency that
+answers every call in 30 seconds raises nothing, so a consecutive-failure
+counter keeps the circuit closed while your own request queue fills up.
+
+The same instance protects async callables — there is no second class to
+configure and no separate state to reason about:
+
+```python
+@payments
+async def refund(charge_id: str) -> None:
+ await gateway.refund(charge_id)
```
-The decorator preserves the function's signature and whether it is sync or
-async. The same breaker also supports `breaker.call(fn, ...)`, `with breaker`,
-and `async with breaker`. See [Getting started](docs/getting-started.md) for all
-calling styles and [Configuration](docs/guides/configuration.md) for every
-threshold.
+The decorator preserves the wrapped signature and whether it is sync or async.
+`breaker.call(fn, ...)`, `with breaker` and `async with breaker` protect the
+same call in other shapes — see
+[Getting started](https://bagowix.github.io/interlock/getting-started/) for all
+calling styles and
+[Configuration](https://bagowix.github.io/interlock/guides/configuration/) for
+every threshold.
## Why interlock
@@ -92,14 +114,36 @@ transport = AsyncCircuitBreakerTransport(
```
`LoggingEventListener` writes every event through stdlib logging; swap it for
-an `EventListener` that exports to your metrics backend. For local diagnostics,
-`transport.registry.get_existing(host)` returns an already-created breaker
-without creating one, so its `state` and `snapshot()` can be inspected safely.
-Hosts are only known at runtime, so `transport.registry.items()` lists every
-breaker created so far — a point-in-time copy, name and breaker together.
-After tuning thresholds, deploy a new transport with the default
-`initial_state=State.CLOSED`; the enforcing instance starts with a fresh window.
-See [States and manual control](docs/guides/states.md#safe-rollout).
+an `EventListener` that exports to your metrics backend. Hosts are only known at
+runtime, so `transport.registry.items()` lists every breaker created so far and
+`get_existing(host)` inspects one without creating it. After tuning thresholds,
+deploy a new transport with the default `initial_state=State.CLOSED`; the
+enforcing instance starts with a fresh window. See
+[States and manual control](https://bagowix.github.io/interlock/guides/states/#safe-rollout).
+
+## Shared state across instances
+
+A local breaker only reacts to what its own process saw. Back it with Redis and
+the whole fleet backs off together:
+
+```python
+import redis
+
+from interlock import CircuitBreaker
+from interlock.integrations.redis import RedisStorage
+
+payments = CircuitBreaker(
+ name='payments',
+ storage=RedisStorage(redis.Redis(host='redis.internal')),
+)
+```
+
+Tripping is atomic across racing instances, recovery probes are budgeted
+globally rather than per process, and a Redis outage degrades to local state
+instead of failing calls. Sharing state gates traffic everywhere at once — that
+is the point, and the risk, so the
+[Redis integration](https://bagowix.github.io/interlock/integrations/redis/)
+page starts with when *not* to use it.
## Resilience pipeline
@@ -129,7 +173,7 @@ async def fetch_picks(user: str) -> list[str]:
Retries never hammer an open circuit, one hung attempt cannot eat the retry
budget, and every decision is observable — see the
-[pipeline guide](docs/guides/pipeline.md).
+[pipeline guide](https://bagowix.github.io/interlock/guides/pipeline/).
## Integrations
@@ -149,18 +193,18 @@ By default, transport exceptions and the canonical retryable statuses
| Integration | Install | Documentation |
|---|---|---|
-| httpx2 | `interlock-cb[httpx2]` | [Per-host transport](docs/integrations/httpx2.md) |
-| httpx | `interlock-cb[httpx]` | [Per-host transport](docs/integrations/httpx.md) |
-| aiohttp | `interlock-cb[aiohttp]` | [Client middleware](docs/integrations/aiohttp.md) |
-| requests | `interlock-cb[requests]` | [Session adapter](docs/integrations/requests.md) |
-| FastAPI | `interlock-cb[fastapi]` | [`503 + Retry-After` handler](docs/integrations/fastapi.md) |
-| Litestar | `interlock-cb[litestar]` | [`503 + Retry-After` handler](docs/integrations/litestar.md) |
-| tenacity | `interlock-cb[tenacity]` | [Retry composition](docs/integrations/tenacity.md) |
-| Redis | `interlock-cb[redis]` | [Shared state](docs/integrations/redis.md) |
-| OpenTelemetry | `interlock-cb[otel]` | [Metrics listener](docs/guides/observability.md) |
-
-The [integrations overview](docs/integrations/index.md) also includes recipes
-for LLM SDKs and Flask/Django.
+| httpx2 | `interlock-cb[httpx2]` | [Per-host transport](https://bagowix.github.io/interlock/integrations/httpx2/) |
+| httpx | `interlock-cb[httpx]` | [Per-host transport](https://bagowix.github.io/interlock/integrations/httpx/) |
+| aiohttp | `interlock-cb[aiohttp]` | [Client middleware](https://bagowix.github.io/interlock/integrations/aiohttp/) |
+| requests | `interlock-cb[requests]` | [Session adapter](https://bagowix.github.io/interlock/integrations/requests/) |
+| FastAPI | `interlock-cb[fastapi]` | [`503 + Retry-After` handler](https://bagowix.github.io/interlock/integrations/fastapi/) |
+| Litestar | `interlock-cb[litestar]` | [`503 + Retry-After` handler](https://bagowix.github.io/interlock/integrations/litestar/) |
+| tenacity | `interlock-cb[tenacity]` | [Retry composition](https://bagowix.github.io/interlock/integrations/tenacity/) |
+| Redis | `interlock-cb[redis]` | [Shared state](https://bagowix.github.io/interlock/integrations/redis/) |
+| OpenTelemetry | `interlock-cb[otel]` | [Metrics listener](https://bagowix.github.io/interlock/guides/observability/) |
+
+The [integrations overview](https://bagowix.github.io/interlock/integrations/)
+also includes recipes for LLM SDKs and Flask/Django.
## How it compares
@@ -181,13 +225,15 @@ more than the feature differences.
| Composable resilience pipeline | ✅ | — | — |
| Fully typed API (`py.typed`) | ✅ | — | — |
-The [full comparison](docs/comparison.md) covers more features as well as
-aiobreaker and purgatory. Something out of date or unfair? Please open a PR.
+The [full comparison](https://bagowix.github.io/interlock/comparison/) covers
+more features as well as aiobreaker and purgatory. Something out of date or
+unfair? Please open a PR.
The reliability work compensating for the project's shorter production history
includes 100% branch coverage, three strict type checkers, mutation testing of
the state machine and engine, property- and model-based tests, and CI on
-free-threaded CPython. The [correctness and testing](docs/correctness.md) page
+free-threaded CPython. The
+[correctness and testing](https://bagowix.github.io/interlock/correctness/) page
documents what is verified and where the limits are.
## Documentation
@@ -195,23 +241,31 @@ documents what is verified and where the limits are.
The full documentation is hosted at ****.
Start with:
-- [Getting started](docs/getting-started.md)
-- [Configuration](docs/guides/configuration.md) and [states](docs/guides/states.md)
-- [Resilience pipeline](docs/guides/pipeline.md)
-- [Integrations](docs/integrations/index.md)
-- [Correctness and testing](docs/correctness.md)
-- [API reference](docs/reference.md)
+- [Getting started](https://bagowix.github.io/interlock/getting-started/)
+- [Configuration](https://bagowix.github.io/interlock/guides/configuration/) and
+ [states](https://bagowix.github.io/interlock/guides/states/)
+- [Timeouts](https://bagowix.github.io/interlock/guides/timeout/) and
+ [retries](https://bagowix.github.io/interlock/guides/retries/)
+- [Resilience pipeline](https://bagowix.github.io/interlock/guides/pipeline/)
+- [Integrations](https://bagowix.github.io/interlock/integrations/)
+- [Correctness and testing](https://bagowix.github.io/interlock/correctness/)
+- [API reference](https://bagowix.github.io/interlock/reference/)
For a deterministic, network-free demonstration of every state transition, run
-the [`examples/`](examples/) scripts or follow the [walkthrough](docs/demo.md).
+the [`examples/`](https://github.com/bagowix/interlock/tree/main/examples)
+scripts or follow the
+[walkthrough](https://bagowix.github.io/interlock/demo/).
## Contributing
Bug reports and pull requests are welcome. See
-[`CONTRIBUTING.md`](CONTRIBUTING.md) for the local setup and the checks a change
-must pass, and [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for community
-expectations. Security issues: please follow [`SECURITY.md`](SECURITY.md).
+[`CONTRIBUTING.md`](https://github.com/bagowix/interlock/blob/main/CONTRIBUTING.md)
+for the local setup and the checks a change must pass, and
+[`CODE_OF_CONDUCT.md`](https://github.com/bagowix/interlock/blob/main/CODE_OF_CONDUCT.md)
+for community expectations. Security issues: please follow
+[`SECURITY.md`](https://github.com/bagowix/interlock/blob/main/SECURITY.md).
## License
-interlock is released under the [MIT License](LICENSE).
+interlock is released under the
+[MIT License](https://github.com/bagowix/interlock/blob/main/LICENSE).
diff --git a/docs/img/state-machine.svg b/docs/img/state-machine.svg
new file mode 100644
index 0000000..e027a32
--- /dev/null
+++ b/docs/img/state-machine.svg
@@ -0,0 +1,48 @@
+
diff --git a/tests/test_readme.py b/tests/test_readme.py
new file mode 100644
index 0000000..7d9e742
--- /dev/null
+++ b/tests/test_readme.py
@@ -0,0 +1,89 @@
+"""Keeps the README usable away from GitHub.
+
+The README is also the PyPI long description, and PyPI resolves a relative link
+against ``pypi.org`` rather than the repository — every ``docs/...`` link a
+reader clicks there is a 404. Absolute urls are the fix; these tests keep them
+absolute and keep each one pointing at something that exists, since nothing
+else in CI follows a link the README makes up.
+"""
+
+import re
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parent.parent
+_README = (_ROOT / 'README.md').read_text(encoding='utf-8')
+
+_DOCS_SITE = 'https://bagowix.github.io/interlock/'
+_REPO_URLS = re.compile(
+ r'https://(?:github\.com/bagowix/interlock/(?:blob|tree)|'
+ r'raw\.githubusercontent\.com/bagowix/interlock)/main/(?P[^)\s]+)'
+)
+_MARKDOWN_LINK = re.compile(r'!?\[[^\]]*\]\((?P[^)\s]+)\)')
+_HEADING = re.compile(r'^#{1,6} (?P.+)$', re.MULTILINE)
+
+
+def _targets() -> tuple[str, ...]:
+ return tuple(match['target'] for match in _MARKDOWN_LINK.finditer(_README))
+
+
+def _slug(heading: str) -> str:
+ return re.sub(r'[^a-z0-9]+', '-', heading.lower()).strip('-')
+
+
+def _headings(markdown: str) -> set[str]:
+ return {_slug(match['text']) for match in _HEADING.finditer(markdown)}
+
+
+def _docs_candidates(url: str) -> tuple[Path, ...]:
+ """Return the sources a documentation url could have been built from."""
+ page = url.removeprefix(_DOCS_SITE).split('#')[0].strip('/')
+ if not page:
+ return (_ROOT / 'docs' / 'index.md',)
+ if '.' in Path(page).name:
+ return (_ROOT / 'docs' / page,)
+
+ return (_ROOT / 'docs' / f'{page}.md', _ROOT / 'docs' / page / 'index.md')
+
+
+def test__readme_links__every_target__is_absolute_or_an_anchor() -> None:
+ relative = [target for target in _targets() if not target.startswith(('https://', '#'))]
+
+ assert relative == [], f'relative links 404 on PyPI: {relative}'
+
+
+def test__readme_links__every_documentation_url__matches_a_docs_page() -> None:
+ missing = [
+ url
+ for url in _targets()
+ if url.startswith(_DOCS_SITE)
+ and not any(candidate.exists() for candidate in _docs_candidates(url))
+ ]
+
+ assert missing == [], f'documentation urls without a page in docs/: {missing}'
+
+
+def test__readme_links__every_repository_url__matches_a_repo_path() -> None:
+ missing = [
+ match['path']
+ for match in _REPO_URLS.finditer(_README)
+ if not (_ROOT / match['path']).exists()
+ ]
+
+ assert missing == [], f'repository urls without a file on main: {missing}'
+
+
+def test__readme_links__every_anchor__matches_a_heading() -> None:
+ broken: list[str] = []
+ for target in _targets():
+ url, _, anchor = target.partition('#')
+ if not anchor:
+ continue
+ pages = (
+ [_ROOT / 'README.md']
+ if not url
+ else [page for page in _docs_candidates(url) if page.exists()]
+ )
+ if not any(anchor in _headings(page.read_text(encoding='utf-8')) for page in pages):
+ broken.append(target)
+
+ assert broken == [], f'anchors without a matching heading: {broken}'