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 +[![PyPI](https://img.shields.io/pypi/v/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) +[![Downloads](https://img.shields.io/pypi/dm/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) +[![Python versions](https://img.shields.io/pypi/pyversions/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/bagowix/interlock/blob/main/LICENSE) [![CI](https://github.com/bagowix/interlock/actions/workflows/ci.yml/badge.svg)](https://github.com/bagowix/interlock/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/bagowix/interlock/branch/main/graph/badge.svg)](https://codecov.io/gh/bagowix/interlock) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/bagowix/interlock/badge)](https://scorecard.dev/viewer/?uri=github.com/bagowix/interlock) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13932/badge)](https://www.bestpractices.dev/projects/13932) -[![PyPI](https://img.shields.io/pypi/v/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) -[![Downloads](https://img.shields.io/pypi/dm/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) -[![Python versions](https://img.shields.io/pypi/pyversions/interlock-cb.svg)](https://pypi.org/project/interlock-cb/) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![llms.txt](https://img.shields.io/badge/-llms.txt-brightgreen)](docs/llms.txt) +[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/bagowix/interlock?utm_source=badge) [![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-2f6f55.svg)](https://bagowix.github.io/interlock/) +[![llms.txt](https://img.shields.io/badge/-llms.txt-brightgreen)](https://bagowix.github.io/interlock/llms.txt) [![Context7](https://img.shields.io/badge/docs-Context7-1f6feb.svg)](https://context7.com/bagowix/interlock) -[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](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. +![CLOSED passes calls through and trips to OPEN once the failure or slow-call rate reaches its threshold; OPEN rejects calls without touching the dependency and moves to HALF_OPEN after the wait duration; HALF_OPEN admits probes only — a failing probe re-opens the circuit, successful probes close it](https://raw.githubusercontent.com/bagowix/interlock/main/docs/img/state-machine.svg) + ## 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 @@ + + The interlock circuit breaker state machine + CLOSED passes calls through and trips to OPEN once the failure or slow-call + rate crosses its threshold. OPEN rejects calls without touching the dependency and moves to + HALF_OPEN after the wait duration. HALF_OPEN admits probes only: a failing probe re-opens the + circuit, enough successful probes close it again. + + + + + + + + + + + + + + + + + + + + + CLOSED + OPEN + HALF_OPEN + + + calls pass through + calls rejected fast + probes only + + + + + failure or slow-call + rate ≥ threshold + probe fails + wait duration elapsed + probes succeed + + 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}'