From c9be4b85d846887cae66d5af9a449eb7c097bf77 Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:20:12 +0300
Subject: [PATCH 01/10] Add mkdocs-redirects for the docs restructure
---
pyproject.toml | 1 +
uv.lock | 40 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 41 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 5a6df3a..e48cc31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,6 +62,7 @@ dev = [
]
docs = [
"mkdocs-material>=9.5",
+ "mkdocs-redirects>=1.2.3",
"mkdocstrings[python]>=0.27",
]
release = ["build>=1.2", "twine>=6.0"]
diff --git a/uv.lock b/uv.lock
index 62bb22a..41a5a29 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1547,6 +1547,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
+[[package]]
+name = "mkdocs-redirects"
+version = "1.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+ { name = "properdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/25/49725f78ca5d3026b09973f7a2b3a8b179cc2e8c15e43d5a13bc79f6b274/mkdocs_redirects-1.2.3.tar.gz", hash = "sha256:5e980330999299729a2d6a125347d1af78023d68a23681a4de3053ce7dfe2e51", size = 7712, upload-time = "2026-03-28T13:57:41.766Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/90/871b1cddc01d2ba1637b858eeeabc2e3013dc8df591306b5567b98ef0870/mkdocs_redirects-1.2.3-py3-none-any.whl", hash = "sha256:ec7312fff462d03ec16395d0c001006a418f8d0c21cdf2b47ff11cf839dc3ce0", size = 6245, upload-time = "2026-03-28T13:57:40.466Z" },
+]
+
[[package]]
name = "mkdocstrings"
version = "1.0.6"
@@ -1830,6 +1843,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
+[[package]]
+name = "properdocs"
+version = "1.6.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "markdown", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "markupsafe" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "platformdirs", version = "4.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/29/f27a4e1eddf72ed3db6e47818fbafe6debbf09fd7051f9c1a007239b46ef/properdocs-1.6.7.tar.gz", hash = "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e", size = 276141, upload-time = "2026-03-20T20:07:48.167Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/4d/fc923f5c85318ee8cc903566dc4e0ebe41b2dfc1d2ecf5546db232397ed6/properdocs-1.6.7-py3-none-any.whl", hash = "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd", size = 225406, upload-time = "2026-03-20T20:07:46.875Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.11"
@@ -1964,6 +2002,7 @@ dev = [
docs = [
{ name = "mkdocs-material", version = "9.7.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "mkdocs-material", version = "9.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "mkdocs-redirects" },
{ name = "mkdocstrings", version = "1.0.6", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version < '3.11'" },
{ name = "mkdocstrings", version = "1.0.8", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version >= '3.11'" },
]
@@ -1985,6 +2024,7 @@ dev = [
]
docs = [
{ name = "mkdocs-material", specifier = ">=9.5" },
+ { name = "mkdocs-redirects", specifier = ">=1.2.3" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" },
]
release = [
From 20335fec040e4c74465191bb1486f4bf54b1ad0a Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:22:22 +0300
Subject: [PATCH 02/10] Move project pages, add the features landing page, new
navigation and redirects
---
docs/api/index.md | 59 ++++++++++---
docs/features/index.md | 63 ++++++++++++++
docs/{ => project}/changelog.md | 0
docs/{ => project}/limitations.md | 0
docs/{ => project}/migration.md | 0
docs/{ => project}/performance.md | 0
mkdocs.yml | 133 +++++++++++++++++++++---------
scripts/relink_docs.py | 132 +++++++++++++++++++++++++++++
8 files changed, 338 insertions(+), 49 deletions(-)
create mode 100644 docs/features/index.md
rename docs/{ => project}/changelog.md (100%)
rename docs/{ => project}/limitations.md (100%)
rename docs/{ => project}/migration.md (100%)
rename docs/{ => project}/performance.md (100%)
create mode 100644 scripts/relink_docs.py
diff --git a/docs/api/index.md b/docs/api/index.md
index 64c75d9..1d60544 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -1,19 +1,58 @@
# API reference
-Everything is importable from the top-level `python_wrap` package. These pages are generated from the docstrings, so they always match the code.
+These pages are generated from the docstrings, so they always match the code. Almost
+everything is importable from the top-level `python_wrap` package; the exceptions are the
+helpers that live in a submodule (`python_wrap.testing`, `python_wrap.events`, and
+`remaining`/`check` in `python_wrap.deadline`).
+
+For explanations and examples, start from the matching [feature page](../features/index.md).
+
+## Resilience
| Module | Contents |
| --- | --- |
-| [errors](errors.md) | `PythonWrapError`, `TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `ValidationError`, `ArgumentTypeError`, `BulkheadFull` |
-| [retry](retry.md) | `retry`, `fallback`, `suppress`, `retry_on_exception` |
-| [circuit](circuit.md) | `CircuitBreaker`, `circuit_breaker`, `CircuitState`, `CircuitStats` |
+| [retry](retry.md) | `retry`, `retrying`, `retry_stats`, `retry_after_header`, `fallback`, `suppress`, `retry_on_exception`, `Attempt`, `RetryState`, `RetryStats`, `Retrying` |
+| [circuit](circuit.md) | `CircuitBreaker`, `circuit_breaker`, `CircuitState`, `CircuitStats`, `CircuitProtected` |
| [deadline](deadline.md) | `deadline`, `remaining`, `check`, `Deadline` |
-| [caching](caching.md) | `memoize`, `cache`, `once` |
-| [concurrency](concurrency.md) | `timeout`, `Timeout`, `rate_limit`, `RateLimiter`, `throttle`, `debounce`, `singleflight`, `run_in_thread` |
+| [hedging](hedging.md) | `hedge` |
+| [policy](policy.md) | `policy`, `Policy`, `ROLES` |
+
+## Caching and batching
+
+| Module | Contents |
+| --- | --- |
+| [caching](caching.md) | `memoize`, `cache`, `once`, `cached_property`, `invalidate`, `CacheInfo`, `Memoized`, `Once`, `CachedProperty` |
+| [batching](batching.md) | `batch_calls`, `Batched`, `AsyncBatched` |
+
+## Concurrency and limits
+
+| Module | Contents |
+| --- | --- |
+| [concurrency](concurrency.md) | `timeout`, `Timeout`, `rate_limit`, `RateLimiter`, `Limit`, `limiter_of`, `throttle`, `debounce`, `singleflight`, `run_in_thread` |
| [aio](aio.md) | `gather_limit`, `map_async`, `amap_iter`, `race`, `RaceFailed`, `run_sync` |
| [bulkhead](bulkhead.md) | `Bulkhead`, `limit_concurrency`, `Limited` |
-| [timing](timing.md) | `timing`, `benchmark`, `benchmark_async`, `measure_memory`, `profile` |
-| [logs](logs.md) | `log_args`, `log_return`, `audit`, `trace`, `DEFAULT_REDACT` |
+
+## Observability
+
+| Module | Contents |
+| --- | --- |
+| [logs](logs.md) | `log_args`, `log_return`, `trace`, `audit`, `AuditRecord`, `log_context`, `current_log_context`, `DEFAULT_REDACT` |
+| [timing](timing.md) | `timing`, `benchmark`, `benchmark_async`, `measure_memory`, `profile`, `TimingRecord`, `BenchmarkResult`, `MemoryRecord` |
+| [metrics](metrics.md) | `count_calls`, `CallStats`, `Counted` |
+| [events](events.md) | `subscribe`, `listening`, `filter_events`, `Event`, `EVENT_NAMES` |
+
+## Correctness
+
+| Module | Contents |
+| --- | --- |
| [validation](validation.md) | `validate_args`, `check_type`, `enforce_types` |
-| [state](state.md) | `revert_on_failure`, `Transaction`, `transactional` |
-| [devtools](devtools.md) | `deprecate`, `mock_data`, `compose`, `no_debug` |
+| [state](state.md) | `revert_on_failure`, `Transaction`, `transactional`, `INJECTED`, `current_transaction`, `require_transaction` |
+
+## Everything else
+
+| Module | Contents |
+| --- | --- |
+| [config](config.md) | `configure`, `configured`, `get_config`, `reset_config` |
+| [devtools](devtools.md) | `deprecate`, `mock_data`, `compose`, `wrap_methods`, `no_debug` |
+| [testing](testing.md) | `fast_mode`, `fake_clock`, `FakeClock`, `disable_retries`, `disable_rate_limits`, `disable_caching`, `overrides`, `spy`, `spy_on`, `eventually`, `eventually_sync` |
+| [errors](errors.md) | `PythonWrapError`, `TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, `BatchError`, `ValidationError`, `ArgumentTypeError` |
diff --git a/docs/features/index.md b/docs/features/index.md
new file mode 100644
index 0000000..db914f7
--- /dev/null
+++ b/docs/features/index.md
@@ -0,0 +1,63 @@
+# Features
+
+Every feature works on plain functions, `async def` functions and methods, keeps the
+wrapped function's signature for your type checker, and has no dependencies. Pick the
+area that matches your problem.
+
+
+
+- :material-shield-refresh-outline:{ .lg .middle } __[Resilience](resilience/index.md)__
+
+ ---
+
+ Survive flaky dependencies: retry with backoff and `Retry-After`, circuit breaker,
+ timeouts and deadlines, fallbacks, hedged requests, and policies that combine them.
+
+- :material-cached:{ .lg .middle } __[Caching and batching](caching/index.md)__
+
+ ---
+
+ Do less work: `memoize` with TTL and LRU, `once`, `cached_property`, collapse duplicate
+ calls with `singleflight`, and turn many lookups into one with `batch_calls`.
+
+- :material-speedometer:{ .lg .middle } __[Concurrency and limits](concurrency/index.md)__
+
+ ---
+
+ Stay within limits: rate limits, throttle and debounce, bulkheads, bounded async
+ helpers (`gather_limit`, `map_async`, `race`) and `run_in_thread`.
+
+- :material-chart-timeline-variant:{ .lg .middle } __[Observability](observability/index.md)__
+
+ ---
+
+ See what your code does: argument and result logging with redaction, audit records,
+ timing and profiling, call metrics, and one event hook for Prometheus or OpenTelemetry.
+
+- :material-check-decagram-outline:{ .lg .middle } __[Correctness](correctness/index.md)__
+
+ ---
+
+ Catch bad input and keep state consistent: argument validation, runtime type checks,
+ rollback on failure and transactions.
+
+- :material-tools:{ .lg .middle } __[Developer tools](devtools.md)__
+
+ ---
+
+ `deprecate`, `mock_data`, `compose`, `wrap_methods` and `no_debug` for everyday
+ library and application work.
+
+- :material-tune-variant:{ .lg .middle } __[Configuration](configuration.md)__
+
+ ---
+
+ Set project-wide defaults once: the logger, retry options and redaction lists.
+
+- :material-test-tube:{ .lg .middle } __[Testing](../testing/index.md)__
+
+ ---
+
+ Make code that retries, sleeps and rate-limits instant and deterministic in tests.
+
+
diff --git a/docs/changelog.md b/docs/project/changelog.md
similarity index 100%
rename from docs/changelog.md
rename to docs/project/changelog.md
diff --git a/docs/limitations.md b/docs/project/limitations.md
similarity index 100%
rename from docs/limitations.md
rename to docs/project/limitations.md
diff --git a/docs/migration.md b/docs/project/migration.md
similarity index 100%
rename from docs/migration.md
rename to docs/project/migration.md
diff --git a/docs/performance.md b/docs/project/performance.md
similarity index 100%
rename from docs/performance.md
rename to docs/project/performance.md
diff --git a/mkdocs.yml b/mkdocs.yml
index 8cbf73a..eebada6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -70,6 +70,28 @@ extra:
plugins:
- search
+ - redirects:
+ redirect_maps:
+ choosing.md: get-started/choosing.md
+ async.md: get-started/async-and-methods.md
+ stacking.md: get-started/stacking.md
+ testing.md: testing/index.md
+ performance.md: project/performance.md
+ limitations.md: project/limitations.md
+ migration.md: project/migration.md
+ changelog.md: project/changelog.md
+ guides/resilience.md: features/resilience/index.md
+ guides/policies.md: features/resilience/policies.md
+ guides/configuration.md: features/configuration.md
+ guides/caching.md: features/caching/index.md
+ guides/batching.md: features/caching/batching.md
+ guides/concurrency.md: features/concurrency/index.md
+ guides/async-toolkit.md: features/concurrency/async-helpers.md
+ guides/observability.md: features/observability/index.md
+ guides/metrics.md: features/observability/metrics.md
+ guides/validation.md: features/correctness/validation.md
+ guides/state.md: features/correctness/state.md
+ guides/devtools.md: features/devtools.md
- mkdocstrings:
handlers:
python:
@@ -114,20 +136,51 @@ markdown_extensions:
nav:
- Home: index.md
- - Choose a decorator: choosing.md
- - Guides:
- - Resilience: guides/resilience.md
- - Caching: guides/caching.md
- - Concurrency and rate limits: guides/concurrency.md
- - Async toolkit and bulkheads: guides/async-toolkit.md
- - Batching and hedging: guides/batching.md
- - Observability: guides/observability.md
- - Metrics and events: guides/metrics.md
- - Validation: guides/validation.md
- - State safety: guides/state.md
- - Policies: guides/policies.md
- - Developer tools: guides/devtools.md
- - Project-wide configuration: guides/configuration.md
+ - Get started:
+ - get-started/index.md
+ - Installation: get-started/installation.md
+ - Quickstart: get-started/quickstart.md
+ - Choose a decorator: get-started/choosing.md
+ - Async and methods: get-started/async-and-methods.md
+ - Stacking decorators: get-started/stacking.md
+ - Errors: get-started/errors.md
+ - Features:
+ - features/index.md
+ - Resilience:
+ - features/resilience/index.md
+ - Retry: features/resilience/retry.md
+ - Circuit breaker: features/resilience/circuit-breaker.md
+ - Timeout and deadline: features/resilience/timeout.md
+ - Fallback and suppress: features/resilience/fallback.md
+ - Hedged requests: features/resilience/hedge.md
+ - Policies: features/resilience/policies.md
+ - Caching and batching:
+ - features/caching/index.md
+ - Memoize: features/caching/memoize.md
+ - Once: features/caching/once.md
+ - Cached property: features/caching/cached-property.md
+ - Singleflight: features/caching/singleflight.md
+ - Batching: features/caching/batching.md
+ - Concurrency and limits:
+ - features/concurrency/index.md
+ - Rate limits: features/concurrency/rate-limits.md
+ - Throttle and debounce: features/concurrency/throttle-debounce.md
+ - Bulkheads: features/concurrency/bulkheads.md
+ - Async helpers: features/concurrency/async-helpers.md
+ - Run in a thread: features/concurrency/run-in-thread.md
+ - Observability:
+ - features/observability/index.md
+ - Logging: features/observability/logging.md
+ - Audit: features/observability/audit.md
+ - Timing and profiling: features/observability/timing.md
+ - Metrics and events: features/observability/metrics.md
+ - Correctness:
+ - features/correctness/index.md
+ - Validation: features/correctness/validation.md
+ - State and transactions: features/correctness/state.md
+ - Developer tools: features/devtools.md
+ - Configuration: features/configuration.md
+ - Testing: testing/index.md
- Cookbook:
- cookbook/index.md
- Resilient HTTP client: cookbook/resilient-http-client.md
@@ -138,33 +191,35 @@ nav:
- DataLoader-style batching: cookbook/dataloader-batching.md
- Testing your code: cookbook/testing-your-code.md
- Observability: cookbook/observability.md
- - Concepts:
- - Async support: async.md
- - Stacking decorators: stacking.md
- - Testing: testing.md
- - Limitations: limitations.md
- - Performance: performance.md
- - Migrating to 0.2: migration.md
- API reference:
- api/index.md
- - Errors: api/errors.md
- - Retry and fallback: api/retry.md
- - Circuit breaker: api/circuit.md
- - Deadline: api/deadline.md
+ - Resilience:
+ - Retry and fallback: api/retry.md
+ - Circuit breaker: api/circuit.md
+ - Deadline: api/deadline.md
+ - Hedging: api/hedging.md
+ - Policies: api/policy.md
+ - Caching and batching:
+ - Caching: api/caching.md
+ - Batching: api/batching.md
+ - Concurrency and limits:
+ - Concurrency: api/concurrency.md
+ - Async helpers: api/aio.md
+ - Bulkhead: api/bulkhead.md
+ - Observability:
+ - Logging and audit: api/logs.md
+ - Timing and profiling: api/timing.md
+ - Metrics: api/metrics.md
+ - Events: api/events.md
+ - Correctness:
+ - Validation: api/validation.md
+ - State: api/state.md
- Configuration: api/config.md
- - Caching: api/caching.md
- - Concurrency: api/concurrency.md
- - Async helpers: api/aio.md
- - Bulkhead: api/bulkhead.md
- - Batching: api/batching.md
- - Hedging: api/hedging.md
- - Timing and profiling: api/timing.md
- - Logging and audit: api/logs.md
- - Metrics: api/metrics.md
- - Events: api/events.md
- - Validation: api/validation.md
- - State: api/state.md
- - Policies: api/policy.md
- Developer tools: api/devtools.md
- Testing helpers: api/testing.md
- - Changelog: changelog.md
+ - Errors: api/errors.md
+ - Project:
+ - Performance: project/performance.md
+ - Limitations: project/limitations.md
+ - Migrating to 0.2: project/migration.md
+ - Changelog: project/changelog.md
diff --git a/scripts/relink_docs.py b/scripts/relink_docs.py
new file mode 100644
index 0000000..5bb1d69
--- /dev/null
+++ b/scripts/relink_docs.py
@@ -0,0 +1,132 @@
+"""One-off helper for the docs restructure: rewrite links to moved pages.
+
+Usage: ``python scripts/relink_docs.py docs/a.md docs/b.md ...`` (default: every page).
+Each relative link to an old page is replaced by a link to its new home, computed
+relative to the page that contains it. An anchor is kept only when the target page has
+a heading that produces it; otherwise the link points at the page itself.
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+import sys
+from pathlib import Path
+
+DOCS = Path(__file__).resolve().parent.parent / "docs"
+
+# old page -> new page, with anchor-specific overrides as "old.md#anchor".
+MOVES: dict[str, str] = {
+ "choosing.md": "get-started/choosing.md",
+ "async.md": "get-started/async-and-methods.md",
+ "stacking.md": "get-started/stacking.md",
+ "testing.md": "testing/index.md",
+ "performance.md": "project/performance.md",
+ "limitations.md": "project/limitations.md",
+ "migration.md": "project/migration.md",
+ "changelog.md": "project/changelog.md",
+ "guides/resilience.md": "features/resilience/index.md",
+ "guides/resilience.md#retry": "features/resilience/retry.md",
+ "guides/resilience.md#fallback": "features/resilience/fallback.md",
+ "guides/resilience.md#suppress": "features/resilience/fallback.md",
+ "guides/resilience.md#circuit-breaker": "features/resilience/circuit-breaker.md",
+ "guides/resilience.md#timeout": "features/resilience/timeout.md",
+ "guides/resilience.md#deadline": "features/resilience/timeout.md",
+ "guides/policies.md": "features/resilience/policies.md",
+ "guides/configuration.md": "features/configuration.md",
+ "guides/caching.md": "features/caching/index.md",
+ "guides/caching.md#memoize": "features/caching/memoize.md",
+ "guides/caching.md#cache": "features/caching/memoize.md",
+ "guides/caching.md#once": "features/caching/once.md",
+ "guides/caching.md#cached_property": "features/caching/cached-property.md",
+ "guides/batching.md": "features/caching/batching.md",
+ "guides/batching.md#batch_calls": "features/caching/batching.md",
+ "guides/batching.md#hedge": "features/resilience/hedge.md",
+ "guides/concurrency.md": "features/concurrency/index.md",
+ "guides/concurrency.md#rate_limit": "features/concurrency/rate-limits.md",
+ "guides/concurrency.md#throttle": "features/concurrency/throttle-debounce.md",
+ "guides/concurrency.md#debounce": "features/concurrency/throttle-debounce.md",
+ "guides/concurrency.md#singleflight": "features/caching/singleflight.md",
+ "guides/concurrency.md#run_in_thread": "features/concurrency/run-in-thread.md",
+ "guides/async-toolkit.md": "features/concurrency/async-helpers.md",
+ "guides/async-toolkit.md#bulkhead": "features/concurrency/bulkheads.md",
+ "guides/async-toolkit.md#limit_concurrency": "features/concurrency/bulkheads.md",
+ "guides/observability.md": "features/observability/index.md",
+ "guides/observability.md#configuring-logging": "features/observability/logging.md",
+ "guides/observability.md#log_args-and-log_return": "features/observability/logging.md",
+ "guides/observability.md#trace": "features/observability/logging.md",
+ "guides/observability.md#log_context": "features/observability/logging.md",
+ "guides/observability.md#audit": "features/observability/audit.md",
+ "guides/observability.md#timing": "features/observability/timing.md",
+ "guides/observability.md#benchmark": "features/observability/timing.md",
+ "guides/observability.md#measure_memory": "features/observability/timing.md",
+ "guides/observability.md#profile": "features/observability/timing.md",
+ "guides/metrics.md": "features/observability/metrics.md",
+ "guides/validation.md": "features/correctness/validation.md",
+ "guides/state.md": "features/correctness/state.md",
+ "guides/devtools.md": "features/devtools.md",
+}
+
+# Pages that moved themselves: their links were written relative to the old folder.
+ORIGIN: dict[str, str] = {
+ "project/performance.md": "",
+ "project/limitations.md": "",
+ "project/migration.md": "",
+ "project/changelog.md": "",
+}
+
+LINK = re.compile(r"\]\((?!https?:|mailto:|#)([^)\s]+?\.md)(#[^)\s]*)?\)")
+
+
+def slugs(page: Path) -> set[str]:
+ """Anchors mkdocs generates for the page's headings (toc default slugify)."""
+ found: set[str] = set()
+ if not page.exists():
+ return found
+ for line in page.read_text(encoding="utf-8").splitlines():
+ m = re.match(r"#{1,6}\s+(.*?)\s*(\{[^}]*\})?\s*$", line)
+ if m:
+ text = re.sub(r"[`*_\[\]()]", lambda c: "_" if c.group() == "_" else "", m.group(1))
+ text = re.sub(r"[^\w\s-]", "", text).strip().lower()
+ found.add(re.sub(r"[\s]+", "-", text))
+ for a in re.findall(r"\{[^}]*#([\w-]+)[^}]*\}", line):
+ found.add(a)
+ return found
+
+
+def rewrite(page: Path) -> int:
+ rel = page.relative_to(DOCS).as_posix()
+ rel_dir = posixpath.dirname(rel)
+ old_dir = ORIGIN.get(rel, rel_dir)
+ text = page.read_text(encoding="utf-8")
+ changes = 0
+
+ def sub(m: re.Match[str]) -> str:
+ nonlocal changes
+ target, anchor = m.group(1), m.group(2) or ""
+ old = posixpath.normpath(posixpath.join(old_dir, target))
+ new = MOVES.get(old + anchor) or MOVES.get(old) or old
+ if new != old and anchor and anchor[1:] not in slugs(DOCS / new):
+ anchor = ""
+ link = f"]({posixpath.relpath(new, rel_dir or '.')}{anchor})"
+ if link != m.group(0):
+ changes += 1
+ return link
+
+ new_text = LINK.sub(sub, text)
+ if new_text != text:
+ page.write_text(new_text, encoding="utf-8")
+ return changes
+
+
+def main(argv: list[str]) -> int:
+ pages = [Path(a).resolve() for a in argv] or sorted(DOCS.rglob("*.md"))
+ for page in pages:
+ n = rewrite(page)
+ if n:
+ print(f"{page.relative_to(DOCS)}: {n} link(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
From 2d9ad7d99f3e807fc796518c51e6818c2858a74c Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:23:05 +0300
Subject: [PATCH 03/10] Docs: split caching guide into per-feature pages
---
docs/features/caching/batching.md | 194 +++++++++++++++++
docs/features/caching/cached-property.md | 67 ++++++
docs/features/caching/index.md | 55 +++++
docs/features/caching/memoize.md | 252 +++++++++++++++++++++++
docs/features/caching/once.md | 60 ++++++
docs/features/caching/singleflight.md | 74 +++++++
6 files changed, 702 insertions(+)
create mode 100644 docs/features/caching/batching.md
create mode 100644 docs/features/caching/cached-property.md
create mode 100644 docs/features/caching/index.md
create mode 100644 docs/features/caching/memoize.md
create mode 100644 docs/features/caching/once.md
create mode 100644 docs/features/caching/singleflight.md
diff --git a/docs/features/caching/batching.md b/docs/features/caching/batching.md
new file mode 100644
index 0000000..d1241bf
--- /dev/null
+++ b/docs/features/caching/batching.md
@@ -0,0 +1,194 @@
+# Batching
+
+Fold many single-item calls made close together into one bulk call.
+
+You often have code that loads one thing at a time (`await get_user(id)` in many places, many tasks) while the backend is much faster when asked for many things at once (`SELECT ... WHERE id IN (...)`, a bulk API endpoint). `batch_calls` is the DataLoader pattern: write the bulk function once, and call it with one item from everywhere.
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap.batching import batch_calls
+
+ queries = []
+
+ @batch_calls(max_size=100, max_wait=0.005)
+ async def get_user(ids: list[int]) -> dict[int, str]:
+ queries.append(ids) # one round trip for the whole batch
+ return {i: f"user{i}" for i in ids}
+
+ async def main() -> None:
+ users = await asyncio.gather(get_user(1), get_user(2), get_user(1))
+ assert users == ["user1", "user2", "user1"]
+ assert queries == [[1, 2]] # one call; the duplicate 1 was sent once
+
+ asyncio.run(main())
+ ```
+
+=== "Sync (threads)"
+
+ ```python
+ from concurrent.futures import ThreadPoolExecutor
+ from python_wrap.batching import batch_calls
+
+ queries = []
+
+ @batch_calls(max_size=4, max_wait=0.05)
+ def get_price(skus: list[str]) -> list[float]:
+ queries.append(sorted(skus))
+ return [float(len(s)) for s in skus] # same order as the input
+
+ with ThreadPoolExecutor(4) as pool:
+ prices = list(pool.map(get_price, ["a", "bb", "ccc", "dddd"]))
+
+ assert prices == [1.0, 2.0, 3.0, 4.0]
+ assert queries == [["a", "bb", "ccc", "dddd"]] # the 4th caller filled the batch
+ ```
+
+## When to use it
+
+- Many tasks or threads each look up one item, and the backend has a bulk form.
+- Not when the work is identical per call and you only want to share it: use [`singleflight`](singleflight.md).
+- Not for a single sequential caller: batching needs concurrency.
+
+## When a batch is sent
+
+A **window** opens with the first call. The batch is sent when either happens first:
+
+* `max_wait` seconds have passed since the window opened (default 10 ms), or
+* the batch holds `max_size` distinct items (default 100).
+
+The next call after that opens a new window. Duplicate items in one window (same key) are sent once and their callers share the result.
+
+## The output contract
+
+The batch function receives a `list` of the **distinct** items, in the order they first arrived. It must return one of:
+
+| Return value | How results are matched |
+| --- | --- |
+| A sequence (`list`, `tuple`, ...) | Position: result `i` belongs to item `i`. The length must equal the number of items. |
+| A mapping (`dict`, ...) | By key: `key(item)` if you passed `key=`, otherwise the item itself. Extra entries are ignored. |
+
+Anything else is a contract violation. What callers see:
+
+| What the batch function did | Who gets what |
+| --- | --- |
+| Returned a valid result | Each caller gets its own item's result. |
+| Raised an exception | **Every** caller in the batch gets that exception. |
+| Returned a sequence of the wrong length | **Every** caller gets `BatchError`. |
+| Returned a mapping missing some keys | Only callers of the missing items get `BatchError`; the rest get results. |
+| Returned something else (`None`, a `str`, a generator, ...) | **Every** caller gets `BatchError`. |
+
+`str` and `bytes` are not accepted as sequences, and iterators are not consumed: return a `list`. `BatchError` is a `PythonWrapError`.
+
+```python
+import asyncio
+from python_wrap.batching import batch_calls
+from python_wrap.errors import BatchError
+
+@batch_calls()
+async def lookup(ids: list[int]) -> dict[int, str]:
+ return {i: "found" for i in ids if i != 404}
+
+async def main() -> None:
+ found, missing = await asyncio.gather(lookup(1), lookup(404), return_exceptions=True)
+ assert found == "found"
+ assert isinstance(missing, BatchError) # only this caller fails
+
+asyncio.run(main())
+```
+
+## Keys: deduplication and unhashable items
+
+Items must be hashable, or you pass `key=` to say what identifies one. The key is used to deduplicate within a window and to look results up in a returned mapping.
+
+```python
+import asyncio
+from python_wrap.batching import batch_calls
+
+@batch_calls(key=lambda req: req["id"])
+async def resolve(requests: list[dict]) -> dict[int, str]:
+ return {r["id"]: r["name"].title() for r in requests}
+
+async def main() -> None:
+ assert await resolve({"id": 7, "name": "ada"}) == "Ada"
+
+asyncio.run(main())
+```
+
+## Flushing and inspecting
+
+* `.pending`: how many distinct items are queued and not yet sent.
+* `.flush()` (sync): send every queued batch now, in the calling thread.
+* `await .aflush()` (async): send the batches queued on the running loop now, and wait until every batch on that loop has finished. Useful at the end of a request or before shutdown.
+
+```python
+import asyncio
+from python_wrap.batching import batch_calls
+
+@batch_calls(max_wait=60) # a long window...
+async def load(ids: list[int]) -> list[int]:
+ return [i * 10 for i in ids]
+
+async def main() -> None:
+ tasks = [asyncio.create_task(load(i)) for i in range(3)]
+ await asyncio.sleep(0) # let the tasks queue their items
+ assert load.pending == 3
+ await load.aflush() # ...cut short
+ assert [t.result() for t in tasks] == [0, 10, 20]
+
+asyncio.run(main())
+```
+
+## Async and methods
+
+**Async details**
+
+* Callers on the **same event loop** are batched together; batches never span loops.
+* The batch runs in its own task. Cancelling one caller does not cancel the batch for the others.
+* A caller cancelled **before** its batch is sent removes its item from the batch, unless another caller is waiting for the same key. If every caller goes away, the batch is never sent.
+* No tasks are left behind once the batch has been delivered.
+* When a call fills the batch to `max_size` and its caller is the batch's only waiter (always the case with `max_size=1`), the batch runs right in the calling task: no extra task, no event-loop round trip.
+
+**Sync details**
+
+* Callers are threads; each blocks until its result is ready.
+* There is no timer thread. The **first caller of a window** keeps time: it waits up to `max_wait` and then runs the batch in its own thread. A caller that fills the batch to `max_size` runs it immediately in its thread. So the batch function always runs in one of the calling threads (or the thread calling `.flush()`).
+* A single thread calling the loader in a loop waits up to `max_wait` per call. Do not call a sync loader from `async` code; it blocks the event loop. Decorate an `async def` instead.
+
+**Methods**
+
+On a method the batch function is called as `func(self, items)`, and each instance gets its own batches (for a class method, each class). Calls on two instances are never mixed in one batch.
+
+```python
+import asyncio
+from python_wrap.batching import batch_calls
+
+class UserRepo:
+ def __init__(self, table: str) -> None:
+ self.table = table
+
+ @batch_calls()
+ async def get(self, ids: list[int]) -> list[str]:
+ return [f"{self.table}:{i}" for i in ids]
+
+async def main() -> None:
+ a, b = UserRepo("a"), UserRepo("b")
+ assert await asyncio.gather(a.get(1), b.get(2)) == ["a:1", "b:2"]
+
+asyncio.run(main())
+```
+
+## Gotchas
+
+!!! warning "A sync loader is not awaitable"
+ Decorating a plain `def` gives a plain function: `loader(1)` blocks and returns the value itself. So `await loader(1)` and `asyncio.create_task(loader(1))` fail (with `TypeError`, after the loop was already blocked for the batch window). The first time a sync loader is called from a running event loop it emits a `RuntimeWarning` saying so. In async code, decorate an `async def` batch function.
+
+!!! note "Static typing"
+ The wrapper is typed as `Batched[[Item], Result]` (or `AsyncBatched`), with `.pending`, `.flush()` / `.aflush()`. Pyright types instance, class and static methods exactly. mypy types instance and class methods correctly but binds the first parameter too eagerly for a `@staticmethod` stacked on top, and for unbound access such as `Repo.get(repo, 1)`; prefer a module-level loader there.
+
+## See also
+
+- [Singleflight](singleflight.md), [Memoize](memoize.md)
+- [Hedge](../resilience/hedge.md)
+- [API reference](../../api/batching.md)
diff --git a/docs/features/caching/cached-property.md b/docs/features/caching/cached-property.md
new file mode 100644
index 0000000..0376c7d
--- /dev/null
+++ b/docs/features/caching/cached-property.md
@@ -0,0 +1,67 @@
+# Cached property
+
+Like `functools.cached_property`, plus `ttl`, per-instance locking, `async def` getters and explicit invalidation.
+
+```python
+from python_wrap import cached_property, invalidate
+
+class Report:
+ loads = 0
+
+ @cached_property(ttl=60)
+ def totals(self) -> int:
+ Report.loads += 1
+ return 42
+
+r = Report()
+assert r.totals == 42 and r.totals == 42
+assert Report.loads == 1
+
+del r.totals # invalidate; the next access recomputes
+invalidate(r, "totals") # same, by name (no names: every property)
+assert r.totals == 42 and Report.loads == 2
+```
+
+## When to use it
+
+- An expensive attribute of one object, computed on first access.
+- A method of a class with unhashable instances, where [`memoize`](memoize.md) would fail.
+- Not for results that depend on arguments: use [`memoize`](memoize.md).
+
+## Async and methods
+
+An `async def` getter is awaited once; concurrent accesses share one task. Type checkers infer `Awaitable[T]` for these getters.
+
+```python
+import asyncio
+from python_wrap import cached_property
+
+class Client:
+ calls = 0
+
+ @cached_property
+ async def profile(self) -> dict:
+ Client.calls += 1
+ await asyncio.sleep(0.01)
+ return {"name": "ada"}
+
+async def main() -> None:
+ c = Client()
+ a, b = await asyncio.gather(c.profile, c.profile) # one shared task
+ assert a == b == {"name": "ada"} and Client.calls == 1
+
+asyncio.run(main())
+```
+
+## Gotchas
+
+!!! warning "`__slots__` classes"
+ The value is stored in the instance `__dict__`, so a `__slots__` class must list `"__dict__"`; otherwise access raises `TypeError`.
+
+!!! note "Read-only, not copied"
+ The attribute is read-only (assigning raises `AttributeError`); use `del` or `invalidate`. Copies and pickles start with an empty cache. Type checkers infer the getter's return type.
+
+## See also
+
+- [Memoize](memoize.md), [Once](once.md)
+- [API reference](../../api/caching.md)
diff --git a/docs/features/caching/index.md b/docs/features/caching/index.md
new file mode 100644
index 0000000..f65863b
--- /dev/null
+++ b/docs/features/caching/index.md
@@ -0,0 +1,55 @@
+# Caching and deduplication
+
+These decorators avoid doing the same work twice: remembering a result, running setup once, computing an attribute lazily, sharing one in-flight call between duplicate callers, or turning many small lookups into one bulk call.
+
+
+
+- :material-memory:{ .lg .middle } **[Memoize](memoize.md)**
+
+ ---
+
+ Cache a function's result per argument values, with LRU size limits, TTL and cache control.
+
+- :material-numeric-1-circle-outline:{ .lg .middle } **[Once](once.md)**
+
+ ---
+
+ Run a function a single time; later calls return the first result.
+
+- :material-tag-text-outline:{ .lg .middle } **[Cached property](cached-property.md)**
+
+ ---
+
+ A per-instance lazy attribute with TTL, async getters and invalidation.
+
+- :material-package-variant-closed:{ .lg .middle } **[Batching](batching.md)**
+
+ ---
+
+ Many single-item calls made close together become one bulk call.
+
+- :material-call-merge:{ .lg .middle } **[Singleflight](singleflight.md)**
+
+ ---
+
+ Identical concurrent calls share one execution.
+
+
+
+## Which one?
+
+| You want | Use |
+| --- | --- |
+| Same arguments give the same answer (a pure function) | [`@memoize`](memoize.md) (add `maxsize` so it cannot grow without bound) |
+| The answer goes stale after a while | [`@memoize(ttl=...)`](memoize.md#expiry-and-size) |
+| One result regardless of arguments (init, setup, config load) | [`@once`](once.md) |
+| An expensive attribute of one object | [`@cached_property`](cached-property.md) |
+| Collapse duplicate concurrent calls (no cache) | [`@singleflight`](singleflight.md) |
+| Many single lookups folded into one bulk call | [`@batch_calls`](batching.md) |
+
+They combine: for example, stack `singleflight` under `memoize` so that two threads missing the same key compute the value only once.
+
+## See also
+
+- [API reference](../../api/caching.md)
+- [Metrics and events](../observability/metrics.md): `cache.hit`, `cache.miss` and `cache.evicted`
diff --git a/docs/features/caching/memoize.md b/docs/features/caching/memoize.md
new file mode 100644
index 0000000..63a6336
--- /dev/null
+++ b/docs/features/caching/memoize.md
@@ -0,0 +1,252 @@
+# Memoize
+
+Cache a function's result per argument values, so repeated calls with the same arguments skip the work.
+
+```python
+from python_wrap import memoize
+
+calls = []
+
+@memoize
+def square(n: int) -> int:
+ calls.append(n)
+ return n * n
+
+assert square(4) == 16
+assert square(n=4) == 16 # arguments are bound to the signature: same entry
+assert calls == [4]
+assert square.cache_info().hits == 1
+```
+
+## When to use it
+
+- A function is pure (or close enough) and called repeatedly with the same arguments.
+- An answer is good for a while: add `ttl`.
+- Not for one result regardless of arguments: use [`once`](once.md).
+- Not for a per-object attribute: use [`cached_property`](cached-property.md).
+- Not when you only want to merge calls that are running right now: use [`singleflight`](singleflight.md).
+
+## How keys work
+
+- Arguments are bound to the function signature and defaults are applied, so `f(1)`, `f(x=1)` and `f(1, y=)` share one entry.
+- Lists, dicts and sets (also nested) are converted to hashable forms automatically, and `[1]` and `(1,)` do not collide.
+- Any other unhashable argument raises `TypeError` at call time. Pass `key=` to build the key yourself.
+- Exceptions are not cached by default; see [Caching exceptions](#caching-exceptions).
+
+```python
+from python_wrap import memoize
+
+@memoize
+def total(items: list[int]) -> int:
+ return sum(items)
+
+assert total([1, 2, 3]) == 6
+assert total([1, 2, 3]) == 6
+assert total.cache_info().hits == 1
+```
+
+### A custom key
+
+Ignore arguments that do not change the answer, or accept arguments that are not hashable:
+
+```python
+from python_wrap import memoize
+
+class Client: ...
+
+@memoize(key=lambda client, user_id: user_id) # ignore the client object
+def fetch_user(client: Client, user_id: int) -> dict:
+ return {"id": user_id}
+
+fetch_user(Client(), 1)
+fetch_user(Client(), 1)
+assert fetch_user.cache_info().hits == 1
+```
+
+## Expiry and size
+
+```python
+import time
+from python_wrap import memoize
+
+calls = []
+
+@memoize(maxsize=2, ttl=0.02)
+def load(key: str) -> str:
+ calls.append(key)
+ return key.upper()
+
+load("a"); load("b"); load("c") # "a" is evicted (least recently used)
+assert load.cache_info().currsize == 2
+assert load.cache_info().maxsize == 2
+
+time.sleep(0.03) # entries expire after ttl
+assert load.cache_info().currsize == 0
+load("c")
+assert calls == ["a", "b", "c", "c"]
+```
+
+| Option | Meaning |
+| --- | --- |
+| `maxsize` | Maximum entries; least recently used are evicted. `None` is unbounded. |
+| `ttl` | Seconds (or a `datetime.timedelta`) an entry stays valid. `None` never expires. |
+| `typed` | If `True`, `f(1)` and `f(1.0)` are cached separately. |
+| `key` | Custom `key(*args, **kwargs)` returning a hashable key. |
+| `cache_exceptions` | Exception class (or tuple) to cache like a result. |
+| `exception_ttl` | Seconds a cached exception stays valid (defaults to `ttl`). |
+
+### Per-item TTL
+
+`ttl` may also be a function of the result, evaluated when the result is stored. Return `None` for "never expires"; a zero or negative value means "do not store".
+
+```python
+from python_wrap import memoize
+
+@memoize(ttl=lambda token: token["expires_in"])
+def fetch_token(scope: str) -> dict:
+ return {"scope": scope, "expires_in": 3600}
+
+fetch_token("read")
+assert fetch_token.cache_contains("read")
+```
+
+## Caching exceptions
+
+List the exceptions that are a stable answer (a missing record, a rejected input) in `cache_exceptions`. They are stored for `exception_ttl` seconds (default: `ttl`) and re-raised on every hit as the **same exception instance**, so its traceback grows with each raise. `asyncio.CancelledError` is never cached. When `ttl` is a function you must pass `exception_ttl` too.
+
+```python
+from python_wrap import memoize
+
+calls = []
+
+@memoize(cache_exceptions=KeyError, exception_ttl=30)
+def lookup(key: str) -> str:
+ calls.append(key)
+ raise KeyError(key)
+
+for _ in range(3):
+ try:
+ lookup("x")
+ except KeyError:
+ pass
+assert calls == ["x"]
+```
+
+## Controlling the cache
+
+Every memoized function has these helpers:
+
+```python
+from python_wrap import memoize
+
+@memoize
+def double(n: int) -> int:
+ return n * 2
+
+double(1); double(2)
+assert double.cache_info().currsize == 2 # CacheInfo(hits, misses, maxsize, currsize)
+assert double.cache_delete(1) is True # drop one entry
+assert double.cache_delete(1) is False # it was not there
+double.cache_clear() # drop everything and reset the statistics
+assert double.cache_info() == (0, 0, None, 0)
+```
+
+Inspect without side effects (nothing is computed, counted or moved in the LRU order):
+
+```python
+from python_wrap import memoize
+
+@memoize(maxsize=8)
+def triple(n: int) -> int:
+ return n * 3
+
+assert not triple.cache_contains(2)
+triple(2)
+assert triple.cache_contains(2)
+assert triple.cache_get(2) == 6 # raises KeyError when absent
+assert triple.cache_parameters()["maxsize"] == 8
+```
+
+`cache_get` is typed `Any` because for `async def` functions it returns the awaited result, not a coroutine.
+
+## Async and methods
+
+**Async.** Concurrent awaits of a missing key share one in-flight task per event loop, so the function runs once.
+
+```python
+import asyncio
+from python_wrap import memoize
+
+calls = []
+
+@memoize
+async def square(n: int) -> int:
+ calls.append(n)
+ await asyncio.sleep(0.01)
+ return n * n
+
+async def main() -> None:
+ results = await asyncio.gather(square(4), square(4), square(n=4))
+ assert results == [16, 16, 16]
+ assert calls == [4]
+
+asyncio.run(main())
+```
+
+**Methods.** `self` is part of the key, so results are per instance. On a method, `cache_delete`, `cache_contains` and `cache_get` take the instance as the first argument, because `obj.method` is an ordinary bound method and its attributes are not bound: `obj.method.cache_get(obj, x)`.
+
+```python
+from python_wrap import memoize
+
+class Repo:
+ @memoize
+ def load(self, key: int) -> int:
+ return key * 2
+
+repo = Repo()
+repo.load(42)
+assert repo.load.cache_contains(repo, 42)
+assert repo.load.cache_get(repo, 42) == 84
+assert repo.load.cache_delete(repo, 42) is True
+```
+
+## The `cache` alias
+
+`cache(duration, maxsize=None)` is v0.1's name for `memoize(ttl=duration, maxsize=maxsize)`, kept for compatibility and soft-deprecated. Prefer `memoize(ttl=...)` in new code (`cache` also clashes with `functools.cache`).
+
+```python
+import datetime
+from python_wrap import cache
+
+@cache(datetime.timedelta(minutes=5))
+def load() -> int:
+ return 42
+
+assert load() == 42
+```
+
+Unlike v0.1, every decorated function gets its own store, even when one `cache(...)` result is applied to several functions.
+
+## Events
+
+`memoize` (and `cache`) emit `cache.hit`, `cache.miss` and `cache.evicted` (field `reason`: `"lru"` or `"ttl"`) to `python_wrap.events` listeners, with the function's qualified name. With no listener registered this costs one list check. See [Metrics](../observability/metrics.md) for recipes.
+
+## Testing
+
+Use `python_wrap.testing.disable_caching()` to bypass every cache in tests.
+
+## Gotchas
+
+!!! warning "Two threads can compute the same key"
+ The cache is guarded by a lock, but the function is never called while holding it. For sync functions, two threads that miss on the same key at the same moment may both compute the value, and the last result stored wins. If that is not acceptable, stack [`singleflight`](singleflight.md) under `memoize`.
+
+!!! warning "Methods keep instances alive"
+ `self` is part of the key and the cache holds a strong reference to every instance it has seen, which keeps them alive. Use `maxsize` or `ttl` (or a `key=` that ignores `self`) on methods of many short-lived objects. See [Limitations](../../project/limitations.md).
+
+!!! warning "Unhashable instances"
+ A method of a class whose instances are unhashable (for example an `eq=True` dataclass) fails with a `TypeError` naming `self`. `id(self)` is deliberately not used as a fallback because ids are reused after garbage collection. Pass `key=` or use [`cached_property`](cached-property.md) instead.
+
+## See also
+
+- [Once](once.md), [Cached property](cached-property.md), [Singleflight](singleflight.md)
+- [API reference](../../api/caching.md)
diff --git a/docs/features/caching/once.md b/docs/features/caching/once.md
new file mode 100644
index 0000000..1e2efc3
--- /dev/null
+++ b/docs/features/caching/once.md
@@ -0,0 +1,60 @@
+# Once
+
+Run a function a single time; later calls return the first result and ignore their arguments.
+
+```python
+from python_wrap import once
+
+@once
+def setup() -> object:
+ return object()
+
+assert setup() is setup()
+assert setup.called is True
+
+setup.reset() # forget the result; the next call runs again
+assert setup.called is False
+```
+
+## When to use it
+
+- Initialisation, setup or a config load that must happen one time.
+- Not when the result depends on arguments: use [`memoize`](memoize.md).
+- Not for a per-object attribute: use [`cached_property`](cached-property.md).
+
+## Failures
+
+If the first call raises, the function is not marked done and the next call tries again.
+
+## Async and methods
+
+`once` is thread-safe. For `async def` functions, concurrent first awaits share one run, even across threads that each run their own event loop: it runs once per process.
+
+```python
+import asyncio
+from python_wrap import once
+
+runs = []
+
+@once
+async def connect() -> str:
+ runs.append(1)
+ await asyncio.sleep(0.01)
+ return "connection"
+
+async def main() -> None:
+ a, b = await asyncio.gather(connect(), connect()) # share one task
+ assert a == b == "connection"
+ assert runs == [1]
+
+asyncio.run(main())
+```
+
+## Testing
+
+While `python_wrap.testing.disable_caching()` is active, `once` runs the function on every call and stores nothing. Use `.reset()` to re-arm it between tests.
+
+## See also
+
+- [Memoize](memoize.md), [Cached property](cached-property.md)
+- [API reference](../../api/caching.md)
diff --git a/docs/features/caching/singleflight.md b/docs/features/caching/singleflight.md
new file mode 100644
index 0000000..8e1fb7a
--- /dev/null
+++ b/docs/features/caching/singleflight.md
@@ -0,0 +1,74 @@
+# Singleflight
+
+While a call for a given key is running, further calls with the same key wait and receive **the same result or exception**. Once it finishes, the next call runs fresh.
+
+This de-duplicates work in flight; it is not a cache. Use it to stop a thundering herd of identical requests (a cache miss that every request tries to fill).
+
+=== "Sync"
+
+ ```python
+ import time
+ from concurrent.futures import ThreadPoolExecutor
+ from python_wrap import singleflight
+
+ calls = []
+
+ @singleflight
+ def load(path: str) -> str:
+ calls.append(path)
+ time.sleep(0.03)
+ return f"contents of {path}"
+
+ with ThreadPoolExecutor(3) as pool:
+ results = list(pool.map(load, ["a.txt"] * 3))
+
+ assert results == ["contents of a.txt"] * 3
+ assert len(calls) == 1
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import singleflight
+
+ calls = []
+
+ @singleflight
+ async def load(path: str) -> str:
+ calls.append(path)
+ await asyncio.sleep(0.01)
+ return f"contents of {path}"
+
+ async def main() -> None:
+ results = await asyncio.gather(*(load("a.txt") for _ in range(3)))
+ assert results == ["contents of a.txt"] * 3
+ assert calls == ["a.txt"]
+
+ asyncio.run(main())
+ ```
+
+## When to use it
+
+- Many callers ask for the same expensive thing at the same moment.
+- Stacked under [`memoize`](memoize.md), so a cache miss is filled once rather than by every concurrent caller.
+- Not to remember results after the call finishes: use [`memoize`](memoize.md).
+- Not to merge different items into one bulk call: use [`batch_calls`](batching.md).
+
+## Keys
+
+The default key is the positional arguments plus sorted keyword arguments (including `self` for methods), which must be hashable. Pass `key=` to choose what counts as "the same call", for example `key=lambda path, **_: path`.
+
+## Async and methods
+
+Sync functions de-duplicate across threads. Async functions de-duplicate across tasks on the same event loop; the work runs in a shared task, so cancelling one waiting caller does not cancel it for the others.
+
+## Gotchas
+
+!!! warning "State on methods"
+ On a **method**, `singleflight` state belongs to the decorated function and is shared by every instance. The default key includes `self`, which separates instances; if you pass `key=`, include whatever must keep instances apart. See [Limitations](../../project/limitations.md).
+
+## See also
+
+- [Memoize](memoize.md), [Batching](batching.md)
+- [API reference](../../api/concurrency.md)
From aeeca338598ea5a4e95b6c9ac3c0428a3a70adfe Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:23:11 +0300
Subject: [PATCH 04/10] Docs: concurrency feature pages
---
docs/features/concurrency/async-helpers.md | 208 ++++++++++++++++
docs/features/concurrency/bulkheads.md | 109 +++++++++
docs/features/concurrency/index.md | 58 +++++
docs/features/concurrency/rate-limits.md | 230 ++++++++++++++++++
docs/features/concurrency/run-in-thread.md | 45 ++++
.../features/concurrency/throttle-debounce.md | 160 ++++++++++++
6 files changed, 810 insertions(+)
create mode 100644 docs/features/concurrency/async-helpers.md
create mode 100644 docs/features/concurrency/bulkheads.md
create mode 100644 docs/features/concurrency/index.md
create mode 100644 docs/features/concurrency/rate-limits.md
create mode 100644 docs/features/concurrency/run-in-thread.md
create mode 100644 docs/features/concurrency/throttle-debounce.md
diff --git a/docs/features/concurrency/async-helpers.md b/docs/features/concurrency/async-helpers.md
new file mode 100644
index 0000000..d2e8b0b
--- /dev/null
+++ b/docs/features/concurrency/async-helpers.md
@@ -0,0 +1,208 @@
+# Async helpers
+
+Small helpers for the things every asyncio project ends up hand-writing: bounded `gather`, bounded `map`, racing, and calling async code from sync code. They live in `python_wrap.aio`.
+
+```python
+import asyncio
+from python_wrap.aio import gather_limit
+
+async def download(n: int) -> str:
+ await asyncio.sleep(0)
+ return f"file-{n}"
+
+async def main() -> None:
+ files = await gather_limit(*(download(n) for n in range(20)), limit=5)
+ assert files[0] == "file-0"
+
+asyncio.run(main())
+```
+
+## When to use it
+
+| Helper | One-line behaviour |
+| --- | --- |
+| [`gather_limit`](#gather_limit) | `asyncio.gather`, but at most `limit` run at once. |
+| [`map_async`](#map_async-and-amap_iter) | Apply an async function to every item, `limit` at a time; get a list. |
+| [`amap_iter`](#map_async-and-amap_iter) | Same, as an async generator: results stream out. |
+| [`race`](#race) | First success wins; the others are cancelled. |
+| [`run_sync`](#run_sync) | Run a coroutine from sync code (never inside a running loop). |
+
+To cap calls to one function or resource across your whole program, use a [Bulkhead](bulkheads.md) instead.
+
+!!! note "No task is left behind"
+ Every helper owns the tasks it starts. When it returns, raises, is cancelled or (for `amap_iter`) is closed early, all of them have been cancelled *and awaited*. Nothing keeps running in the background, and you never see "Task exception was never retrieved" or "coroutine was never awaited" warnings from them.
+
+## gather_limit
+
+Like `asyncio.gather`, but coroutines are started only when one of the `limit` slots is free. Results come back in input order.
+
+```python
+import asyncio
+from python_wrap.aio import gather_limit
+
+running = 0
+peak = 0
+
+async def download(n: int) -> str:
+ global running, peak
+ running += 1
+ peak = max(peak, running)
+ await asyncio.sleep(0.001)
+ running -= 1
+ return f"file-{n}"
+
+async def main() -> None:
+ files = await gather_limit(*(download(n) for n in range(20)), limit=5)
+ assert files[:2] == ["file-0", "file-1"]
+ assert peak == 5
+
+asyncio.run(main())
+```
+
+On the first exception the other awaitables are cancelled, coroutines that never started are closed, and the exception is raised. Pass `return_exceptions=True` to collect errors in the list instead:
+
+```python
+import asyncio
+from python_wrap.aio import gather_limit
+
+async def parse(text: str) -> int:
+ return int(text)
+
+async def main() -> None:
+ results = await gather_limit(parse("1"), parse("x"), limit=2, return_exceptions=True)
+ assert results[0] == 1
+ assert isinstance(results[1], ValueError)
+
+asyncio.run(main())
+```
+
+Tasks and futures are accepted too, but they are already running when you pass them, so `limit` cannot delay them; pass coroutines to get the cap.
+
+## map_async and amap_iter
+
+`map_async(fn, items, limit=...)` calls `fn` on every item with at most `limit` calls in flight, and returns the list of results. `items` may be a normal or an async iterable, and it is read **lazily**: an item is pulled only when a slot is free, so a generator of a million rows never sits in memory as a million tasks.
+
+```python
+import asyncio
+from python_wrap.aio import map_async
+
+async def rows():
+ for i in range(100):
+ yield {"id": i}
+
+async def save(row: dict[str, int]) -> int:
+ await asyncio.sleep(0)
+ return row["id"]
+
+async def main() -> None:
+ ids = await map_async(save, rows(), limit=8)
+ assert ids == list(range(100))
+
+asyncio.run(main())
+```
+
+`ordered=False` returns results in completion order instead. If a call fails, the others are cancelled and the error is raised (unless `return_exceptions=True`).
+
+`amap_iter` is the streaming version: it yields results as they are ready, which also works with infinite inputs.
+
+```python
+import asyncio
+from contextlib import aclosing
+from itertools import count
+from python_wrap.aio import amap_iter
+
+async def square(n: int) -> int:
+ await asyncio.sleep(0)
+ return n * n
+
+async def main() -> None:
+ found = []
+ async with aclosing(amap_iter(square, count(), limit=4)) as squares:
+ async for value in squares:
+ if value > 50:
+ break
+ found.append(value)
+ assert found == [0, 1, 4, 9, 16, 25, 36, 49]
+
+asyncio.run(main())
+```
+
+* `ordered=True` (default) yields in input order. Results that finish early wait in a buffer, and buffered results count towards `limit`, so memory stays bounded; a slow item holds back the ones after it. An error is raised when its turn comes.
+* `ordered=False` yields each result as soon as it finishes.
+
+Breaking out of the loop, calling `aclose()` or cancelling the consumer cancels and awaits every call still in flight. Wrap the generator in `contextlib.aclosing` (as above) so this happens immediately instead of whenever the generator is garbage-collected. The input iterable is yours: `amap_iter` does not close it.
+
+## race
+
+Start every awaitable and return the first **successful** result. The losers are cancelled and awaited before `race` returns. A contestant that raises does not end the race; the others keep going.
+
+```python
+import asyncio
+from python_wrap.aio import race
+
+async def from_cache() -> str:
+ raise LookupError("cache miss")
+
+async def from_replica() -> str:
+ await asyncio.sleep(0.01)
+ return "replica"
+
+async def from_primary() -> str:
+ await asyncio.sleep(1)
+ return "primary"
+
+async def main() -> None:
+ assert await race(from_cache(), from_replica(), from_primary(), timeout=5) == "replica"
+
+asyncio.run(main())
+```
+
+* With `timeout=`, `TimeoutExpired` (a `TimeoutError`) is raised if nobody succeeds in time. The timeout runs on **real time**: `fast_mode()` and `fake_clock()` do not shorten it.
+* If every contestant fails, `RaceFailed` is raised with all the errors, in input order, in `.exceptions`. On Python 3.11+ `RaceFailed` is also an `ExceptionGroup`, so `except* LookupError:` works; on 3.10 it is a plain `PythonWrapError`. Catching `RaceFailed` works on every version.
+
+```python
+import asyncio
+from python_wrap.aio import RaceFailed, race
+
+async def broken(name: str) -> str:
+ raise ConnectionError(name)
+
+async def main() -> None:
+ try:
+ await race(broken("a"), broken("b"))
+ except RaceFailed as exc:
+ assert [str(e) for e in exc.exceptions] == ["a", "b"]
+
+asyncio.run(main())
+```
+
+!!! warning "Use race for idempotent work"
+ The losers may already have done part of their job when they are cancelled. For staggered, retry-style parallelism on one function, see [Hedge](../resilience/hedge.md).
+
+## run_sync
+
+Run async code from a sync entry point (a CLI command, a script, a test helper). It is `asyncio.run` with a friendlier call form:
+
+```python
+import asyncio
+from python_wrap.aio import run_sync
+
+async def fetch_total(a: int, b: int) -> int:
+ await asyncio.sleep(0)
+ return a + b
+
+assert run_sync(fetch_total, 2, 3) == 5
+assert run_sync(fetch_total(4, 5)) == 9
+```
+
+There is deliberately **no nested-event-loop magic**. Called while an event loop is running in the current thread, `run_sync` raises `RuntimeError` (and closes the coroutine so you get no "never awaited" warning). Inside async code, `await` instead; from sync code that runs inside a loop, move the sync code to a thread with `asyncio.to_thread`.
+
+## Testing
+
+The `race` timeout runs on real time even under `fast_mode()` and `fake_clock()`. The helpers add no events of their own.
+
+## See also
+
+- [Bulkheads](bulkheads.md) (including the warning about bulkheads inside a global limit), [run_in_thread](run-in-thread.md), [Hedge](../resilience/hedge.md)
+- [Async and methods](../../get-started/async-and-methods.md)
+- [API reference](../../api/aio.md)
diff --git a/docs/features/concurrency/bulkheads.md b/docs/features/concurrency/bulkheads.md
new file mode 100644
index 0000000..b5b3609
--- /dev/null
+++ b/docs/features/concurrency/bulkheads.md
@@ -0,0 +1,109 @@
+# Bulkheads
+
+Cap how many calls use a limited resource at once, and refuse to build an unbounded queue in front of it.
+
+```python
+import asyncio
+from python_wrap.bulkhead import Bulkhead
+from python_wrap.errors import BulkheadFull
+
+payments = Bulkhead(2, max_waiting=1, name="payments")
+
+@payments
+async def charge(amount: int) -> int:
+ await asyncio.sleep(0.01)
+ return amount
+
+async def main() -> None:
+ results = await asyncio.gather(*(charge(n) for n in range(4)), return_exceptions=True)
+ assert results[:3] == [0, 1, 2] # two ran at once, one waited
+ assert isinstance(results[3], BulkheadFull) # the queue was full
+
+asyncio.run(main())
+```
+
+## When to use it
+
+- A dependency has limited capacity: a connection pool, a rate-limited API, a CPU-heavy section.
+- You prefer failing fast (`BulkheadFull`) to piling up waiting callers.
+- For "at most N of these at once" with no queue limit, use [`limit_concurrency`](#limit_concurrency).
+- To limit calls per time window instead, use [Rate limits](rate-limits.md). To bound a batch of awaitables you are starting yourself, use [`gather_limit` or `map_async`](async-helpers.md).
+
+## Bulkhead
+
+| Argument | Meaning |
+| --- | --- |
+| `max_concurrent` | Calls allowed to run at once. |
+| `max_waiting` | Callers allowed to queue for a slot. `None` = unlimited, `0` = never wait. |
+| `timeout` | Seconds a queued caller waits before giving up with `BulkheadFull`. |
+| `name` | Shown in error messages and `repr`. |
+
+One bulkhead can guard several functions and blocks at once. Use it as a decorator (sync or `async def`), as `with bulkhead:` in threaded code, or as `async with bulkhead:` in async code. `bulkhead.active` and `bulkhead.waiting` report the current load.
+
+```python
+from concurrent.futures import ThreadPoolExecutor
+from python_wrap.bulkhead import Bulkhead
+
+db = Bulkhead(3, timeout=5, name="db")
+
+def query(n: int) -> int:
+ with db:
+ return n * 2
+
+with ThreadPoolExecutor(10) as pool:
+ assert list(pool.map(query, range(10))) == [n * 2 for n in range(10)]
+assert db.active == 0
+```
+
+`BulkheadFull` is a `PythonWrapError`, so it composes with the other decorators, for example [`fallback`](../resilience/fallback.md) to serve a degraded answer when the bulkhead is full, or [`retry`](../resilience/retry.md) to try again a little later.
+
+## limit_concurrency
+
+The decorator form for the common case "at most N of these at once": each decorated function gets its own `Bulkhead(n)` with no queue limit and no timeout, available as `.bulkhead`.
+
+```python
+import asyncio
+from python_wrap.bulkhead import limit_concurrency
+
+@limit_concurrency(3)
+async def crawl(url: str) -> str:
+ await asyncio.sleep(0)
+ return url.upper()
+
+async def main() -> None:
+ pages = await asyncio.gather(*(crawl(f"site{i}") for i in range(10)))
+ assert pages[0] == "SITE0"
+ assert crawl.bulkhead.max_concurrent == 3
+
+asyncio.run(main())
+```
+
+It works on methods too, including under a `@classmethod` stacked above it, and type checkers (mypy and pyright) keep the signature and the `.bulkhead` attribute there. On a method, the limit is shared by every instance (it belongs to the function). For a limit shared by several functions, or for `max_waiting` / `timeout`, create a `Bulkhead` yourself.
+
+## Events
+
+| Event | Fields | Emitted when |
+| --- | --- | --- |
+| `bulkhead.rejected` | `active`, `waiting` | A call is refused with `BulkheadFull`; the fields are the counts at that moment. |
+
+See [Metrics](../observability/metrics.md).
+
+## Testing
+
+Waiting for a slot (the bulkhead `timeout`) runs on **real time**. `fast_mode()` and `fake_clock()` do not shorten it, on either the sync or the async side.
+
+## Gotchas
+
+!!! warning "Threads and event loops have separate limits"
+ Sync callers share a `threading.BoundedSemaphore`; async callers use one `asyncio.Semaphore` per event loop (asyncio primitives cannot be shared across loops). So the cap applies separately to threads and to each loop. If one program uses both sides, give each side its own bulkhead. Never use `with bulkhead:` inside a coroutine: waiting for a slot there would block the event loop.
+
+!!! warning "A bulkhead inside a global limit can starve"
+ Suppose `map_async(fetch, urls, limit=10)` calls a function that waits for a per-host `Bulkhead(2)`. A task holds one of the 10 global slots *while it queues for the bulkhead*. If most URLs hit the same host, ten tasks sit on global slots, two work and eight wait, so requests to every other host are blocked behind them (and with a `timeout` or `max_waiting` the waiters may be rejected instead). Two ways out:
+
+ * Put the bulkhead outermost per host: acquire it *before* starting the work that takes a global slot, or group the items by host and run one `map_async` per host (each with `limit` equal to that host's bulkhead size).
+ * Size the limits so each per-host limit is at most the global one and the global limit is at least the sum of all the per-host limits, so a global slot is never held for a bulkhead slot that cannot be free.
+
+## See also
+
+- [Async helpers](async-helpers.md), [Rate limits](rate-limits.md)
+- [API reference](../../api/bulkhead.md)
diff --git a/docs/features/concurrency/index.md b/docs/features/concurrency/index.md
new file mode 100644
index 0000000..85f9828
--- /dev/null
+++ b/docs/features/concurrency/index.md
@@ -0,0 +1,58 @@
+# Concurrency
+
+Control how often a function runs, how many calls run at once, and how you run many awaitables or blocking calls without hand-writing the plumbing. Everything here is built for both threads and `asyncio`.
+
+
+
+- :material-speedometer:{ .lg .middle } **[Rate limits](rate-limits.md)**
+
+ ---
+
+ `rate_limit` and `RateLimiter`: at most N calls per time window, reject or wait.
+
+- :material-timer-sand:{ .lg .middle } **[Throttle and debounce](throttle-debounce.md)**
+
+ ---
+
+ Collapse bursts of calls: run at most once per interval, or once after the calls stop.
+
+- :material-gate:{ .lg .middle } **[Bulkheads](bulkheads.md)**
+
+ ---
+
+ `Bulkhead` and `limit_concurrency`: cap concurrent calls and bound the queue.
+
+- :material-sync:{ .lg .middle } **[Async helpers](async-helpers.md)**
+
+ ---
+
+ `gather_limit`, `map_async`, `amap_iter`, `race` and `run_sync`.
+
+- :material-run-fast:{ .lg .middle } **[run_in_thread](run-in-thread.md)**
+
+ ---
+
+ Start a blocking call in a thread and get a `Future` back.
+
+
+
+## Which one?
+
+| You want to... | Use |
+| --- | --- |
+| Allow at most N calls per time window (an API quota) | [Rate limits](rate-limits.md) |
+| Share one quota between several functions | [`RateLimiter`](rate-limits.md#one-budget-for-several-functions-ratelimiter) |
+| Skip calls that arrive too soon after the last one | [`throttle`](throttle-debounce.md#throttle) |
+| Run once, after a burst of calls has stopped | [`debounce`](throttle-debounce.md#debounce) |
+| Cap how many calls run at the same time | [Bulkheads](bulkheads.md) |
+| Run many awaitables or items with a bound | [Async helpers](async-helpers.md) |
+| Take the first successful result of several awaitables | [`race`](async-helpers.md#race) |
+| Call blocking code without waiting for it | [`run_in_thread`](run-in-thread.md) |
+| Make identical concurrent calls share one execution | [`singleflight`](../caching/singleflight.md) |
+
+A rate limit counts calls over **time**; a bulkhead counts calls **in flight**. Use both when a dependency has a quota and a connection limit.
+
+## See also
+
+- [Timeout](../resilience/timeout.md) and [Hedge](../resilience/hedge.md) for deadlines and parallel retries.
+- [Testing](../../testing/index.md) for `fast_mode()`, `fake_clock()` and `disable_rate_limits()`.
diff --git a/docs/features/concurrency/rate-limits.md b/docs/features/concurrency/rate-limits.md
new file mode 100644
index 0000000..a313bd2
--- /dev/null
+++ b/docs/features/concurrency/rate-limits.md
@@ -0,0 +1,230 @@
+# Rate limits
+
+Allow at most N calls per time window, and either reject or delay the rest. This keeps you inside an API quota.
+
+```python
+from python_wrap import RateLimitExceeded, rate_limit
+
+@rate_limit(2, 60)
+def send(text: str) -> str:
+ return text
+
+send("one")
+send("two")
+try:
+ send("three")
+except RateLimitExceeded as exc:
+ assert 0 < exc.retry_after <= 60 # seconds until a slot frees
+else:
+ raise AssertionError("expected the limit to apply")
+```
+
+## When to use it
+
+- A third-party API allows N requests per second, minute or hour.
+- You want to protect your own service from one caller that sends too much (use `key=`).
+- Several functions call the same service and must share one budget ([`RateLimiter`](#one-budget-for-several-functions-ratelimiter)).
+- Not for skipping excess calls silently: see [`throttle`](throttle-debounce.md#throttle). Not for capping calls in flight: see [Bulkheads](bulkheads.md).
+
+## How it counts
+
+`rate_limit(calls, period)` allows at most `calls` calls per `period` seconds, using a sliding window. Unlike a fixed window, it never lets a burst of `2 * calls` through at a window boundary.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import RateLimitExceeded, rate_limit
+
+ @rate_limit(2, 60)
+ def send(text: str) -> str:
+ return text
+
+ send("one")
+ send("two")
+ try:
+ send("three")
+ except RateLimitExceeded as exc:
+ assert 0 < exc.retry_after <= 60
+ else:
+ raise AssertionError("expected the limit to apply")
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import RateLimitExceeded, rate_limit
+
+ @rate_limit(1, 60)
+ async def send(text: str) -> str:
+ return text
+
+ async def main() -> None:
+ await send("one")
+ try:
+ await send("two")
+ except RateLimitExceeded:
+ pass
+ else:
+ raise AssertionError("expected the limit to apply")
+
+ asyncio.run(main())
+ ```
+
+By default an over-limit call raises `RateLimitExceeded`; its `retry_after` attribute says how many seconds until a slot frees. Rejected calls are not counted.
+
+## Waiting instead of rejecting
+
+With `block=True` the call waits until it is allowed. Async functions wait with `await`, so the event loop is never blocked.
+
+```python
+from python_wrap import rate_limit
+
+@rate_limit(1, 0.02, block=True)
+def tick() -> int:
+ return 1
+
+assert tick() + tick() == 2 # the second call waits about 20 ms
+```
+
+## One limit per user
+
+`key=` receives the call's arguments (including `self` for methods) and returns a hashable bucket key. Each key gets its own limit.
+
+```python
+from python_wrap import RateLimitExceeded, rate_limit
+
+@rate_limit(1, 60, key=lambda user, *_: user)
+def send(user: str, text: str) -> None: ...
+
+send("ana", "hi")
+send("ben", "hi") # a different bucket
+try:
+ send("ana", "again")
+except RateLimitExceeded:
+ pass
+else:
+ raise AssertionError("ana is over her limit")
+```
+
+!!! tip "Per-instance limits on methods"
+ Use `key=lambda self, *a, **kw: id(self)` (or an identifying attribute) to give each instance its own limit.
+
+## Several limits at once
+
+APIs often have more than one quota, such as "5 per second and 600 per 15 minutes". Pass them all with `limits=`. A call goes through only when **every** limit allows it, and a rejected call uses up none of them. `retry_after` (and the wait with `block=True`) is the time until all of them allow a call.
+
+```python
+from python_wrap import RateLimitExceeded, rate_limit
+
+@rate_limit(limits=[(5, 1.0), (600, 900.0)])
+def strava(activity: int) -> int:
+ return activity
+
+for n in range(5):
+ strava(n)
+try:
+ strava(5) # the per-second limit is used up
+except RateLimitExceeded as exc:
+ assert 0 < exc.retry_after <= 1
+else:
+ raise AssertionError("expected the limit to apply")
+```
+
+`limits=` can also be combined with the positional pair: `rate_limit(5, 1.0, limits=[(600, 900.0)])`.
+
+## One budget for several functions: RateLimiter
+
+`rate_limit` gives each decorated function its own budget. When several functions call the same service, create one `RateLimiter` and use it everywhere: as a decorator, as `with limiter:` / `async with limiter:` around a block, or through its methods.
+
+```python
+from python_wrap import RateLimitExceeded
+from python_wrap.concurrency import RateLimiter
+
+github = RateLimiter(2, 60, name="github")
+
+@github
+def get_repo(name: str) -> str:
+ return name
+
+@github
+def get_user(name: str) -> str:
+ return name
+
+get_repo("python-wrap")
+get_user("ana") # same budget: 2 of 2 used
+try:
+ with github: # a plain block counts against it too
+ pass
+except RateLimitExceeded as exc:
+ assert "github rate limit" in str(exc)
+else:
+ raise AssertionError("expected the shared limit to apply")
+```
+
+`RateLimiter(calls, period, *, limits=None, block=False, name=None)` takes the same limits as `rate_limit`. `block` decides what the decorator and `with` do when over the limit (raise, or wait). For explicit control:
+
+| Method | Behaviour |
+| --- | --- |
+| `try_acquire()` | Take a slot if one is free now; returns `True` or `False`. |
+| `acquire(block=True, timeout=None)` | Wait in this thread for a slot. With `timeout`, returns `False` as soon as the next slot is further away than that. |
+| `await acquire_async(block=True, timeout=None)` | The same for async code; never blocks the event loop. |
+| `reset()` | Forget every recorded call, for every key. |
+
+```python
+import asyncio
+from python_wrap.concurrency import RateLimiter
+
+limiter = RateLimiter(1, 0.02, block=True)
+
+async def main() -> None:
+ async with limiter:
+ pass
+ async with limiter: # waits about 20 ms, without blocking the loop
+ pass
+ assert not limiter.try_acquire()
+ assert await limiter.acquire_async(timeout=1)
+
+asyncio.run(main())
+```
+
+## Events
+
+| Event | Fields | Emitted when |
+| --- | --- | --- |
+| `rate_limit.rejected` | `retry_after` | A call is refused. |
+| `rate_limit.waited` | `seconds` | A `block=True` call had to wait. |
+
+See [Metrics](../observability/metrics.md).
+
+## Testing
+
+- `python_wrap.testing.disable_rate_limits()` makes `rate_limit`, `RateLimiter` and `throttle` let every call through. The switch is read at call time, so it also works on functions decorated at import.
+- Under `fast_mode()` limiters keep counting: a non-blocking call over the limit is still rejected, and a `block=True` call that would wait **goes through at once** instead of sleeping (and is recorded).
+- To keep limits on but start each test with a fresh budget, call `RateLimiter.reset()`. Use a shared `RateLimiter` you can reach from the test, or get the limiter of a `rate_limit`-decorated function with `limiter_of(func)`. It returns `None` for a function without one; `throttle` has no reset.
+
+```python
+from python_wrap.concurrency import limiter_of, rate_limit
+
+@rate_limit(1, 60)
+def send() -> str:
+ return "sent"
+
+assert send() == "sent"
+limiter = limiter_of(send)
+assert limiter is not None
+limiter.reset() # e.g. in a fixture, between tests
+assert send() == "sent"
+```
+
+See [Testing](../../testing/index.md).
+
+## Gotchas
+
+!!! warning "State belongs to the function"
+ The limit belongs to the decorated function. On a **method** it is shared by every instance. Use `key=` to split it, as shown above. See [Limitations](../../project/limitations.md#shared-state-on-methods).
+
+## See also
+
+- [Throttle and debounce](throttle-debounce.md), [Bulkheads](bulkheads.md)
+- [API reference](../../api/concurrency.md)
diff --git a/docs/features/concurrency/run-in-thread.md b/docs/features/concurrency/run-in-thread.md
new file mode 100644
index 0000000..367384d
--- /dev/null
+++ b/docs/features/concurrency/run-in-thread.md
@@ -0,0 +1,45 @@
+# run_in_thread
+
+Start each call in a new thread and get a `concurrent.futures.Future` back. This lets you run blocking code without waiting for it.
+
+```python
+from python_wrap import run_in_thread
+
+@run_in_thread
+def add(a: int, b: int) -> int:
+ return a + b
+
+future = add(1, 2)
+assert future.result() == 3 # waits; re-raises the function's exception, if any
+```
+
+## When to use it
+
+- You want to fire off blocking work and collect the result later.
+- You want a `Future` you can pass around, wait on with a timeout, or attach callbacks to.
+- To await blocking code from `async` code, `asyncio.to_thread` is usually a better fit: `run_in_thread` returns a plain `Future`, not an awaitable.
+
+## Behaviour
+
+- `future.result()` is the equivalent of v0.1's `thread.join()`, plus the return value.
+- `async def` functions run with `asyncio.run` in the new thread (their own event loop), and the wrapper itself is a plain function returning a `Future`.
+- The caller's `contextvars` are copied into the thread.
+- Options: `daemon=True` (do not keep the interpreter alive) and `name=` (thread name).
+
+```python
+import asyncio
+from python_wrap import run_in_thread
+
+@run_in_thread(daemon=True, name="worker")
+async def fetch() -> str:
+ await asyncio.sleep(0.01)
+ return "done"
+
+assert fetch().result() == "done"
+```
+
+## See also
+
+- [Async helpers](async-helpers.md), [Bulkheads](bulkheads.md)
+- [Async and methods](../../get-started/async-and-methods.md)
+- [API reference](../../api/concurrency.md)
diff --git a/docs/features/concurrency/throttle-debounce.md b/docs/features/concurrency/throttle-debounce.md
new file mode 100644
index 0000000..f42d317
--- /dev/null
+++ b/docs/features/concurrency/throttle-debounce.md
@@ -0,0 +1,160 @@
+# Throttle and debounce
+
+Collapse bursts of calls. `throttle` runs the function at most once per interval; `debounce` runs it once, after the calls have stopped.
+
+```python
+from python_wrap import throttle
+
+runs = []
+
+@throttle(60)
+def refresh() -> int:
+ runs.append(1)
+ return len(runs)
+
+assert refresh() == 1
+assert refresh() == 1 # skipped: returns the previous result
+assert len(runs) == 1
+```
+
+## When to use it
+
+- `throttle`: "update the progress bar", "sync at most once a minute". The first call runs now; later calls inside the window are skipped.
+- `debounce`: "save after the user stops typing". Only the final call of a burst runs, after a quiet period.
+- To reject or delay excess calls instead of skipping them silently, use [`rate_limit`](rate-limits.md).
+
+## Throttle
+
+Run at most once per `interval` seconds. The first call runs and opens a window; calls inside the window do not run and return **the result of the last call that did run** (`None` if none has finished yet, or it raised).
+
+=== "Sync"
+
+ ```python
+ from python_wrap import throttle
+
+ runs = []
+
+ @throttle(60)
+ def refresh() -> int:
+ runs.append(1)
+ return len(runs)
+
+ assert refresh() == 1
+ assert refresh() == 1 # skipped: returns the previous result
+ assert len(runs) == 1
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import throttle
+
+ runs = []
+
+ @throttle(60)
+ async def refresh() -> int:
+ runs.append(1)
+ return len(runs)
+
+ async def main() -> None:
+ assert await refresh() == 1
+ assert await refresh() == 1
+ assert len(runs) == 1
+
+ asyncio.run(main())
+ ```
+
+A call that raises still uses up its window, and the error goes only to its own caller. The wrapped function returns `R | None` for the type checker.
+
+## Debounce
+
+Postpone running until `wait` seconds have passed since the **last** call. Each call restarts the countdown and replaces the pending arguments, so only the final call of a burst runs.
+
+=== "Sync"
+
+ ```python
+ import time
+ from python_wrap import debounce
+
+ saved = []
+
+ @debounce(0.02)
+ def save(text: str) -> None:
+ saved.append(text)
+
+ save("a")
+ save("ab")
+ save("abc") # each call returns None immediately
+ time.sleep(0.04) # the timer thread runs the last call
+ assert saved == ["abc"]
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import debounce
+
+ saved = []
+
+ @debounce(0.02)
+ async def save(text: str) -> str:
+ saved.append(text)
+ return text
+
+ async def main() -> None:
+ # Every caller in a burst awaits the same outcome: the final call's result.
+ results = await asyncio.gather(save("a"), save("ab"), save("abc"))
+ assert results == ["abc", "abc", "abc"]
+ assert saved == ["abc"]
+
+ asyncio.run(main())
+ ```
+
+### Cancel and flush
+
+The wrapper also has:
+
+- `.cancel()` drops the pending call (async awaiters get `CancelledError`).
+- `.flush()` runs the pending call right now and returns its result, or `None` if nothing is pending. For `async def` functions it is a coroutine to `await`.
+
+```python
+from python_wrap import debounce
+
+saved = []
+
+@debounce(60)
+def save(text: str) -> None:
+ saved.append(text)
+
+save("draft")
+save("final")
+save.flush() # run now instead of waiting a minute
+assert saved == ["final"]
+
+save("never saved")
+save.cancel() # drop it
+assert saved == ["final"]
+```
+
+## Events
+
+`throttle` emits `throttle.skipped` (no fields) for each skipped call. `debounce` emits no events. See [Metrics](../observability/metrics.md).
+
+## Testing
+
+`disable_rate_limits()` also makes `throttle` let every call through. See [Testing](../../testing/index.md).
+
+## Gotchas
+
+!!! warning "Debounce runs on another thread"
+ Sync debounced functions run on a daemon timer thread, so the function body runs on a different thread from the caller. Async debounce waits with `asyncio.sleep`, so calls must be awaited (or wrapped in tasks) for it to take effect. Both wait in real time.
+
+!!! note "State belongs to the function"
+ Throttle and debounce state belongs to the decorated function. On a **method** it is shared by every instance. See [Limitations](../../project/limitations.md#shared-state-on-methods).
+
+## See also
+
+- [Rate limits](rate-limits.md), [Singleflight](../caching/singleflight.md)
+- [API reference](../../api/concurrency.md)
From ad16de1ae2cb88909c4015056232b12328de7c83 Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:23:50 +0300
Subject: [PATCH 05/10] docs: add resilience feature pages and configuration
---
docs/features/configuration.md | 93 +++++++
docs/features/resilience/circuit-breaker.md | 147 +++++++++++
docs/features/resilience/fallback.md | 83 ++++++
docs/features/resilience/hedge.md | 99 +++++++
docs/features/resilience/index.md | 56 ++++
docs/features/resilience/policies.md | 94 +++++++
docs/features/resilience/retry.md | 279 ++++++++++++++++++++
docs/features/resilience/timeout.md | 167 ++++++++++++
8 files changed, 1018 insertions(+)
create mode 100644 docs/features/configuration.md
create mode 100644 docs/features/resilience/circuit-breaker.md
create mode 100644 docs/features/resilience/fallback.md
create mode 100644 docs/features/resilience/hedge.md
create mode 100644 docs/features/resilience/index.md
create mode 100644 docs/features/resilience/policies.md
create mode 100644 docs/features/resilience/retry.md
create mode 100644 docs/features/resilience/timeout.md
diff --git a/docs/features/configuration.md b/docs/features/configuration.md
new file mode 100644
index 0000000..af6a56b
--- /dev/null
+++ b/docs/features/configuration.md
@@ -0,0 +1,93 @@
+# Configuration
+
+Repeating `retry(max_attempts=5, delay=0.5, ...)` or `redact=(...)` on every decorator is how defaults drift. `python_wrap.config` sets them once.
+
+```python
+import logging
+from python_wrap.config import configure, get_config, reset_config
+
+configure(
+ logger=logging.getLogger("myapp"),
+ retry={"max_attempts": 5, "delay": 0.5, "backoff": 2.0, "max_time": 30},
+ redact_extra=["ssn", "iban"],
+)
+assert get_config()["retry"]["max_attempts"] == 5
+reset_config()
+```
+
+## When to use it
+
+- Project-wide retry policy, logger, or redaction list that every decorator should share.
+- Not for one-off differences: pass the argument on that decorator, which always wins.
+
+## When values are read
+
+Call `configure` **once, at start-up, before you decorate anything**. Defaults are read when a decorator is *applied* (at import time for module-level functions), not on every call. A decorator applied earlier keeps the settings it saw. An explicit argument always beats a configured default.
+
+```python
+from python_wrap import retry
+from python_wrap.config import configure, reset_config
+
+@retry
+def early() -> None: ... # 3 attempts: applied before configure()
+
+configure(retry={"max_attempts": 7})
+
+@retry
+def late() -> None: ... # 7 attempts
+
+@retry(max_attempts=2)
+def explicit() -> None: ... # 2 attempts: explicit wins
+
+reset_config()
+```
+
+## Keys
+
+| Key | Effect |
+| --- | --- |
+| `logger` | Logger (or name) used by every decorator that logs and was not given `logger=`. `None` restores the package logger. |
+| `retry` | Dict of defaults for `retry` and `retrying`: `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time`, `log_level`. |
+| `redact` | Replaces the built-in `DEFAULT_REDACT` list for `log_args` and `audit`. `None` restores it. |
+| `redact_extra` | Names **added** to the redaction list (the built-ins stay), for `log_args` and `audit`. |
+
+Each key you pass replaces that key as a whole (`retry={...}` replaces the previous retry defaults); keys you don't mention are untouched. Unknown keys raise `TypeError` with a hint:
+
+```python
+from python_wrap.config import configure
+
+try:
+ configure(retyr={"max_attempts": 3})
+except TypeError as exc:
+ assert "Did you mean 'retry'" in str(exc)
+```
+
+Retry defaults are validated immediately (`ValueError` for `max_attempts=0`, and so on). Because `redact=` on a decorator replaces the list, prefer `redact_extra` in project settings; it is also what `redact_extra=` does on `log_args` and `audit` themselves.
+
+## Testing
+
+`configured()` applies defaults for a block and restores the previous state on exit, even after an error. Apply the decorators inside the block.
+
+```python
+from python_wrap import retry
+from python_wrap.config import configured, get_config
+
+with configured(retry={"max_attempts": 1}):
+ @retry
+ def once() -> None: ...
+
+assert get_config()["retry"] == {}
+```
+
+`reset_config()` forgets everything; `get_config()` returns a copy of what is in force.
+
+## Gotchas
+
+!!! warning "Configure before you decorate"
+ Decorators applied before `configure()` runs keep the defaults they saw. Put the call at the top of your entry point, before importing modules that define decorated functions.
+
+## See also
+
+- [Retry](resilience/retry.md), [Logging](observability/logging.md)
+- [Testing](../testing/index.md)
+- [API reference](../api/config.md)
diff --git a/docs/features/resilience/circuit-breaker.md b/docs/features/resilience/circuit-breaker.md
new file mode 100644
index 0000000..444941f
--- /dev/null
+++ b/docs/features/resilience/circuit-breaker.md
@@ -0,0 +1,147 @@
+# Circuit breaker
+
+When a dependency is down, retrying makes things worse. A circuit breaker counts consecutive failures and, past a threshold, rejects calls immediately with `CircuitOpenError` for a while.
+
+```python
+import time
+from python_wrap import CircuitOpenError, CircuitState, circuit_breaker
+
+@circuit_breaker(failure_threshold=2, recovery_timeout=0.02, on=ConnectionError)
+def call_api() -> str:
+ raise ConnectionError("down")
+
+for _ in range(2):
+ try:
+ call_api()
+ except ConnectionError:
+ pass
+
+assert call_api.breaker.state is CircuitState.OPEN
+try:
+ call_api()
+except CircuitOpenError as exc:
+ assert exc.retry_after <= 0.02
+
+time.sleep(0.03)
+assert call_api.breaker.state is CircuitState.HALF_OPEN
+call_api.breaker.reset()
+assert call_api.breaker.state is CircuitState.CLOSED
+```
+
+## When to use it
+
+- A downstream service that can go down for a while and should not be hammered, nor make every caller wait for a timeout.
+- Failing fast so a caller can use a [fallback](fallback.md).
+- Not for isolated glitches: that is [retry](retry.md). Use both: the breaker goes outside the retry and counts calls whose retries were exhausted.
+
+## States
+
+| State | Behaviour | Moves on when |
+| --- | --- | --- |
+| `closed` | Calls run normally. | `failure_threshold` consecutive failures: becomes `open`. |
+| `open` | Calls are rejected with `CircuitOpenError`. | `recovery_timeout` seconds pass: becomes `half_open`. |
+| `half_open` | A limited number of trial calls run. | A trial succeeds: `closed`. A trial fails: `open` again. |
+
+Only exceptions matching `on=` count as failures; others pass through uncounted. `half_open_max_calls` limits how many trial calls may run at once while half-open.
+
+## Sharing one breaker
+
+`@circuit_breaker(...)` gives the function its own breaker, exposed as `wrapper.breaker`. To **share one breaker across several functions** (they all hit the same service), create a `CircuitBreaker` and use the instance as the decorator:
+
+```python
+from python_wrap import CircuitBreaker, CircuitOpenError
+
+payments = CircuitBreaker(failure_threshold=1, recovery_timeout=60, name="payments")
+
+@payments
+def charge() -> None:
+ raise RuntimeError("provider down")
+
+@payments
+def refund() -> None:
+ ...
+
+try:
+ charge()
+except RuntimeError:
+ pass
+
+try:
+ refund() # never runs: the shared circuit is open
+except CircuitOpenError:
+ pass
+else:
+ raise AssertionError("expected the circuit to be open")
+
+assert payments.failure_count == 1
+```
+
+You can also call through a breaker without decorating: `breaker.call(func, *args, **kwargs)` (for an `async def` function it returns a coroutine to await).
+
+## Tuning recovery, exclusions and monitoring
+
+| Option | Default | What it does |
+| --- | --- | --- |
+| `success_threshold` | `1` | Consecutive successful trial calls needed to close a half-open circuit. Any counted failure during the trials re-opens it. |
+| `exclude` | `()` | Exception types that are never counted, even when they match `on=` (for example a "not found" error that proves the service is up). They propagate unchanged. |
+| `on_state_change` | `None` | `listener(breaker, old_state, new_state)`, called after every transition, outside the breaker's lock. An exception from the listener is logged, never raised. |
+| `name` | function name | Used in log messages, `CircuitOpenError` messages and events. |
+
+`breaker.stats` returns a frozen `CircuitStats` snapshot: `state`, total `failures`, `successes` and `rejected` calls (since creation or the last `reset()`), `consecutive_failures`, and `opened_at` (the `monotonic()` time the circuit last opened, `None` while closed).
+
+```python
+from python_wrap import CircuitBreaker, CircuitState
+
+class NotFound(Exception):
+ pass
+
+changes = []
+inventory = CircuitBreaker(
+ failure_threshold=2,
+ recovery_timeout=0, # half-open again straight away, for the example
+ success_threshold=2, # two good trial calls before trusting it again
+ exclude=NotFound, # a 404 means the service is up
+ on_state_change=lambda b, old, new: changes.append(new.value),
+ name="inventory",
+)
+
+@inventory
+def lookup(sku: str) -> int:
+ if sku == "missing":
+ raise NotFound(sku)
+ if sku == "broken":
+ raise ConnectionError("down")
+ return 3
+
+for sku in ("missing", "broken", "broken"):
+ try:
+ lookup(sku)
+ except (NotFound, ConnectionError):
+ pass
+
+assert inventory.stats.failures == 2 # NotFound was not counted
+assert lookup("ok") == 3 # first trial: still half-open
+assert inventory.state is CircuitState.HALF_OPEN
+assert lookup("ok") == 3 # second trial closes it
+assert changes == ["open", "half_open", "closed"]
+```
+
+## Events
+
+| Event | Fields |
+| --- | --- |
+| `circuit.state_changed` | `old`, `new` (`closed`, `open`, `half_open`) |
+| `circuit.rejected` | `retry_after` (seconds) |
+
+Both are reported under the breaker's name. See [Metrics](../observability/metrics.md).
+
+## Gotchas
+
+!!! note "One breaker per dependency"
+ A breaker created by `@circuit_breaker(...)` belongs to that function alone. If several functions call the same service, share one `CircuitBreaker` instance, otherwise each learns separately that the service is down. A [policy](policies.md) with a `CircuitBreaker` also shares it across everything it decorates.
+
+## See also
+
+- [Retry](retry.md), [Fallback](fallback.md), [Policies](policies.md)
+- [Errors](../../get-started/errors.md) for `CircuitOpenError`
+- [API reference](../../api/circuit.md)
diff --git a/docs/features/resilience/fallback.md b/docs/features/resilience/fallback.md
new file mode 100644
index 0000000..9456b5e
--- /dev/null
+++ b/docs/features/resilience/fallback.md
@@ -0,0 +1,83 @@
+# Fallback and suppress
+
+`fallback` returns a value, or the result of a handler, when the function raises. `suppress` swallows matching exceptions and returns `None`.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import fallback
+
+ @fallback(0, on=KeyError)
+ def stock(sku: str) -> int:
+ return {"a": 5}[sku]
+
+ assert stock("a") == 5
+ assert stock("missing") == 0
+
+ @fallback(handler=lambda exc: f"failed: {exc!r}")
+ def risky() -> str:
+ raise RuntimeError("boom")
+
+ assert risky() == "failed: RuntimeError('boom')"
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import fallback
+
+ @fallback(0, on=KeyError)
+ async def stock(sku: str) -> int:
+ return {"a": 5}[sku]
+
+ assert asyncio.run(stock("missing")) == 0
+ ```
+
+## When to use it
+
+- A missing optional value should become a default (a zero count, an empty list, a cached copy).
+- A failure should degrade the response instead of failing the request.
+- Use `suppress` for best-effort work such as cleanup or telemetry, where nobody needs the result.
+- Not to hide bugs: narrow `on=` to the errors you expect.
+
+## fallback
+
+Give exactly one of `value` or `handler=`; giving both or neither raises `ValueError`. The exception is logged at WARNING with its traceback. For async functions the handler is still a regular function.
+
+!!! tip
+ Pass a fresh object with `handler=lambda exc: []` if the default is mutable, so callers do not share one list.
+
+## suppress
+
+Suppressed errors are logged at DEBUG, and the call returns `None`.
+
+```python
+from python_wrap import suppress
+
+@suppress(KeyError)
+def first(d: dict[str, int]) -> int:
+ return d["x"]
+
+assert first({"x": 1}) == 1
+assert first({}) is None
+
+@suppress # bare form suppresses Exception
+def best_effort() -> None:
+ raise RuntimeError
+
+assert best_effort() is None
+```
+
+Use `fallback` when you need a value other than `None`.
+
+## Gotchas
+
+!!! note "Position in a stack"
+ `fallback` belongs outside [retry](retry.md) and the [circuit breaker](circuit-breaker.md), so it only answers after they have given up. A [policy](policies.md) puts it there for you.
+
+## See also
+
+- [Retry](retry.md), [Circuit breaker](circuit-breaker.md), [Policies](policies.md)
+- [Errors](../../get-started/errors.md)
+- [API reference](../../api/retry.md)
diff --git a/docs/features/resilience/hedge.md b/docs/features/resilience/hedge.md
new file mode 100644
index 0000000..99ac1ea
--- /dev/null
+++ b/docs/features/resilience/hedge.md
@@ -0,0 +1,99 @@
+# Hedge
+
+Most calls to a replicated service are fast, but a few hit a slow replica, a GC pause, or a lost packet. A **hedged request** waits a little; if the call is still running, it fires a second, identical call and takes whichever answers first.
+
+```python
+import asyncio
+from python_wrap.hedging import hedge
+
+attempts = []
+
+@hedge(0.05, max_extra=1)
+async def get_price(sku: str) -> float:
+ attempts.append(sku)
+ if len(attempts) == 1:
+ await asyncio.sleep(10) # the first replica is stuck...
+ return 9.99
+
+async def main() -> None:
+ assert await get_price("A1") == 9.99 # ...so the backup answers after ~50 ms
+ assert len(attempts) == 2
+
+asyncio.run(main())
+```
+
+## When to use it
+
+- Reads against a replicated service where a small share of calls is much slower than the rest (tail latency).
+- Idempotent lookups where an extra request costs little.
+- Not for slow calls that should simply be limited: use [timeout](timeout.md).
+- Not for failures that should be repeated after a pause: use [retry](retry.md).
+
+!!! warning "Only for idempotent operations"
+ A hedged function can run several times for one call, at the same time. Use it for reads and other operations that are safe to repeat. Never hedge payments, emails, counters, or anything else whose side effect must happen once.
+
+## How it decides
+
+1. Start the call.
+2. If no attempt has finished after `delay` seconds, start another one (up to `max_extra` extra attempts in total, each `delay` after the previous one).
+3. The first attempt to **succeed** wins; the rest are cancelled.
+4. If an attempt fails with an exception matching `on` (default: any `Exception`) while there are attempts left, the next one starts **immediately**: there is no point waiting out the delay.
+5. An exception that does not match `on` is raised at once (and the other attempts are cancelled).
+6. If every attempt fails, the last exception is raised.
+
+Pick `delay` near the latency percentile you want to cut, for example the p95 of the call. Then only about 5% of calls send an extra request.
+
+```python
+import asyncio
+from python_wrap.hedging import hedge
+
+calls = []
+
+@hedge(10, max_extra=2, on=ConnectionError)
+async def fetch() -> str:
+ calls.append(1)
+ if len(calls) < 3:
+ raise ConnectionError("replica down")
+ return "ok"
+
+async def main() -> None:
+ assert await fetch() == "ok" # failures hedge immediately: no 10 s waits
+ assert len(calls) == 3
+
+asyncio.run(main())
+```
+
+## Async and methods
+
+**Async: cancellation guarantees.** Each attempt is a task. When the wrapper returns or raises, every other attempt has been cancelled **and awaited**, so no tasks are left running. Cancelling the caller cancels all attempts. Errors raised by losing attempts while they are being cancelled are discarded. Context variables are copied into each attempt.
+
+**Sync: attempts cannot be cancelled.** For a regular function each attempt runs in its own daemon thread and the caller blocks until one succeeds.
+
+```python
+from python_wrap.hedging import hedge
+
+@hedge(0.01)
+def lookup(key: str) -> str:
+ return key.upper()
+
+assert lookup("a") == "A"
+```
+
+`hedge` keeps the decorated function's signature and type, so it stacks with other decorators and works on methods.
+
+## Testing
+
+The delay before a backup attempt is a real timer. Neither `fast_mode()` nor `fake_clock()` changes it, because making it instant would hedge every call at once. Use tiny real delays in tests. See [Testing](../../testing/index.md).
+
+## Gotchas
+
+!!! warning "Sync attempts keep running"
+ **Python threads cannot be cancelled.** Losing sync attempts keep running in the background until they finish, and their results are thrown away. Their side effects still happen, and they hold their resources (a connection, a lock) until done. Make sure the function has its own timeout, so a stuck attempt does not run forever.
+
+`hedge` emits no events.
+
+## See also
+
+- [Timeout](timeout.md), [Retry](retry.md)
+- [Singleflight](../caching/singleflight.md) to merge concurrent identical calls (the opposite problem)
+- [API reference](../../api/hedging.md)
diff --git a/docs/features/resilience/index.md b/docs/features/resilience/index.md
new file mode 100644
index 0000000..4f0fecc
--- /dev/null
+++ b/docs/features/resilience/index.md
@@ -0,0 +1,56 @@
+# Resilience
+
+Calls to networks, disks and other services fail, stall or get slow. These decorators decide what happens next: try again, stop calling a broken dependency, stop waiting, race a backup request, or return a sensible default.
+
+
+
+- :material-restart:{ .lg .middle } **[Retry](retry.md)**
+
+ ---
+
+ Call again when it raises, with backoff, jitter, `Retry-After` and a time budget.
+
+- :material-electric-switch:{ .lg .middle } **[Circuit breaker](circuit-breaker.md)**
+
+ ---
+
+ Reject calls at once while a dependency is down, then probe for recovery.
+
+- :material-timer-sand:{ .lg .middle } **[Timeout and deadline](timeout.md)**
+
+ ---
+
+ Limit one call or a block of code, or give a whole request one shared budget.
+
+- :material-lifebuoy:{ .lg .middle } **[Fallback and suppress](fallback.md)**
+
+ ---
+
+ Return a default value, or `None`, instead of raising.
+
+- :material-call-split:{ .lg .middle } **[Hedge](hedge.md)**
+
+ ---
+
+ Start a backup attempt when a call is slow and take the first success.
+
+- :material-layers-triple:{ .lg .middle } **[Policies](policies.md)**
+
+ ---
+
+ Bundle several of these into one named, reusable decorator, in the right order.
+
+
+
+## Which one?
+
+| Your problem | Use |
+| --- | --- |
+| Errors that usually go away if you try again (dropped connection, 503) | [`retry`](retry.md) |
+| A dependency that is down and keeps failing | [`circuit_breaker`](circuit-breaker.md) |
+| A call that can hang or run too long | [`timeout` / `deadline`](timeout.md) |
+| Rare slow calls (tail latency) on a replicated service | [`hedge`](hedge.md) |
+| A failure that should become a default value | [`fallback` / `suppress`](fallback.md) |
+| All of the above on one call, in the right order | [`policy`](policies.md) |
+
+These combine well, and order matters: the usual stack is fallback outside, then circuit breaker, then retry, then timeout inside. [Policies](policies.md) apply that order for you, and [Stacking](../../get-started/stacking.md) explains why.
diff --git a/docs/features/resilience/policies.md b/docs/features/resilience/policies.md
new file mode 100644
index 0000000..ab52513
--- /dev/null
+++ b/docs/features/resilience/policies.md
@@ -0,0 +1,94 @@
+# Policies
+
+A **policy** is a reusable, named bundle of decorators. You say *which* protections a call needs; the policy applies them in the [recommended order](../../get-started/stacking.md), however you list them.
+
+```python
+from python_wrap import CircuitBreaker, policy, retry, timeout
+
+payments = policy(
+ timeout=timeout(2), # listed first on purpose
+ retry=retry(max_attempts=3, delay=0.01, on=ConnectionError),
+ breaker=CircuitBreaker(failure_threshold=5, on=ConnectionError),
+ name="payments",
+)
+
+@payments
+def charge(amount: int) -> int:
+ return amount
+
+assert charge(5) == 5
+print(payments) # Policy('payments', breaker > retry > timeout)
+```
+
+## When to use it
+
+- Several calls need the same protections (every call to one service).
+- You want the stacking order guaranteed instead of hand-checked.
+- Not for a single call with one decorator: use the decorator directly.
+
+## Roles
+
+Each decorator goes into a named **role**. The role decides the position, so the code cannot be mis-ordered, and the arguments are checked by the type checker. Guessing a role from a decorator object would be fragile (factories return plain closures), so python-wrap does not guess.
+
+Outermost first:
+
+| Role | Typical decorators | Why here |
+| --- | --- | --- |
+| `observe` | `timing`, `audit`, `log_args` | See the outcome the caller gets, retries included. |
+| `validate` | `validate_args`, `enforce_types` | Bad input is rejected before anything else runs. |
+| `fallback` | `fallback`, `suppress` | Catches what the layers below could not fix. |
+| `cache` | `memoize`, `once`, `singleflight` | A hit skips everything below. |
+| `limiter` | `rate_limit`, `throttle` | One token per logical call, not per attempt. |
+| `bulkhead` | `Bulkhead`, `limit_concurrency` | Caps concurrent logical calls. |
+| `breaker` | `CircuitBreaker`, `circuit_breaker` | Counts calls whose retries were exhausted. |
+| `retry` | `retry` | Repeats everything below. |
+| `timeout` | `timeout` | A deadline per attempt. |
+| `guard` | `revert_on_failure`, `transactional` | Each attempt starts from clean state. |
+
+A role accepts one decorator or a list (outermost first). For a decorator with no role, `outer=` places it outside every role and `inner=` just above the function.
+
+## Deriving variants
+
+Policies are immutable. `with_()` returns a changed copy; `None` removes a role.
+
+```python
+from python_wrap import policy, retry, timeout
+
+base = policy(retry=retry(max_attempts=3, delay=0), timeout=timeout(2), name="io")
+patient = base.with_(timeout=timeout(30), name="io-patient")
+no_retry = base.with_(retry=None)
+
+assert base.order == ("retry", "timeout")
+assert no_retry.order == ("timeout",)
+```
+
+## Async and methods
+
+A policy is a decorator for plain, `async def`, instance, static and class methods, and keeps the function's type. Combine it with [`wrap_methods`](../devtools.md#wrap_methods) to protect a whole class.
+
+```python
+import asyncio
+from python_wrap import policy, retry
+
+p = policy(retry=retry(max_attempts=2, delay=0))
+
+@p
+async def load(x: int) -> int:
+ return x
+
+assert asyncio.run(load(1)) == 1
+```
+
+## Gotchas
+
+!!! note "Shared state"
+ A `CircuitBreaker` instance is shared by everything the policy decorates, which is usually what you want for one dependency. Other decorators create state per decorated function.
+
+!!! note "`python_wrap.policy` is the function"
+ The package attribute `python_wrap.policy` is the `policy` function, not the module.
+
+## See also
+
+- [Stacking](../../get-started/stacking.md) for the reasoning behind the order
+- [Retry](retry.md), [Circuit breaker](circuit-breaker.md), [Timeout](timeout.md), [Fallback](fallback.md)
+- [API reference](../../api/policy.md)
diff --git a/docs/features/resilience/retry.md b/docs/features/resilience/retry.md
new file mode 100644
index 0000000..fde9967
--- /dev/null
+++ b/docs/features/resilience/retry.md
@@ -0,0 +1,279 @@
+# Retry
+
+`retry` calls the function again when it raises, so transient failures (a dropped connection, a 503) do not reach your caller. The last exception is re-raised unchanged, with its original traceback, once attempts run out.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import retry
+
+ calls = []
+
+ @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError)
+ def fetch() -> str:
+ calls.append(1)
+ if len(calls) < 3:
+ raise ConnectionError("down")
+ return "payload"
+
+ assert fetch() == "payload"
+ assert len(calls) == 3
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import retry
+
+ calls = []
+
+ @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError)
+ async def fetch() -> str:
+ calls.append(1)
+ if len(calls) < 3:
+ raise ConnectionError("down")
+ return "payload"
+
+ assert asyncio.run(fetch()) == "payload"
+ assert len(calls) == 3
+ ```
+
+`retry` works bare (`@retry`), with options, and as a plain call: `retry(func, max_attempts=5)`.
+
+## When to use it
+
+- Errors that are likely to disappear on their own: dropped connections, 429 and 5xx responses, lock contention.
+- Operations that are safe to repeat.
+- Not for a dependency that stays down: add a [circuit breaker](circuit-breaker.md) so retries stop piling onto it.
+- Not for a call that is slow rather than failing: use [timeout](timeout.md), or [hedge](hedge.md) for tail latency.
+
+!!! warning "Retry only what is safe to repeat"
+ Retrying a non-idempotent call (charging a card, sending an email) can do it twice. Narrow `on=` to errors that mean "it did not happen", and use `retry_if` to inspect the exception.
+
+## Options
+
+The wait before attempt `n + 1` is `min(delay * backoff ** (n - 1), max_delay) + random.uniform(0, jitter)`.
+
+| Option | Default | Meaning |
+| --- | --- | --- |
+| `max_attempts` | `3` | Total calls allowed. `None` retries forever. |
+| `delay` | `1.0` | Base wait in seconds. |
+| `backoff` | `1.0` | Multiplier applied after each failure (use `2` for exponential). |
+| `max_delay` | `None` | Cap on the backoff wait (before jitter). |
+| `jitter` | `0.0` | Up to this many random extra seconds, so many clients do not retry in lockstep. |
+| `max_time` | `None` | Total time budget in seconds from the first attempt. |
+| `on` | `Exception` | Exception type(s) that trigger a retry. Anything else propagates immediately. |
+| `retry_if` | `None` | Extra predicate on the exception; retry only if it returns `True`. |
+| `on_retry` | `None` | Called with a `RetryState` before each wait. |
+| `delay_from` | `None` | `exc -> seconds or None`: exact wait for this attempt (e.g. `Retry-After`). |
+| `logger` | package logger | Logger or logger name for the per-retry message. |
+| `log_level` | `WARNING` | Level of the per-retry message; `None` disables it. |
+| `log_exc_info` | `False` | Attach the traceback to the per-retry log record. |
+
+## Choosing what to retry
+
+`on` picks exception types; `retry_if` looks inside the exception. `on_retry` receives a `RetryState` with `attempt`, `exception`, `next_delay` and `elapsed`, which is enough to feed a metric or a custom log line.
+
+```python
+from python_wrap import RetryState, retry
+
+class HttpError(Exception):
+ def __init__(self, status: int) -> None:
+ super().__init__(f"HTTP {status}")
+ self.status = status
+
+seen: list[RetryState] = []
+responses = iter([503, 502, 200])
+
+@retry(
+ max_attempts=5,
+ delay=0.01,
+ on=HttpError,
+ retry_if=lambda exc: exc.status >= 500,
+ on_retry=seen.append,
+)
+def get() -> int:
+ status = next(responses)
+ if status != 200:
+ raise HttpError(status)
+ return status
+
+assert get() == 200
+assert [s.attempt for s in seen] == [1, 2]
+assert isinstance(seen[0].exception, HttpError)
+```
+
+## Honouring Retry-After
+
+Servers that answer 429 or 503 often say how long to wait. Pass `delay_from` a function that maps the exception to a number of seconds, or to `None` to fall back to the normal backoff. A number is the exact wait for that attempt (no backoff, no jitter), still capped by `max_delay` and `max_time`.
+
+`retry_after_header` reads `exc.retry_after` or `exc.response.headers["Retry-After"]` (seconds or an HTTP-date) and never raises, so it works with `httpx.HTTPStatusError`, `requests.HTTPError` and similar without importing them. The example uses a stand-in exception with the same shape:
+
+```python
+from python_wrap import retry
+from python_wrap import retry_after_header
+
+class Response:
+ def __init__(self, headers: dict[str, str]) -> None:
+ self.headers = headers
+
+class HTTPStatusError(Exception): # shaped like httpx / requests errors
+ def __init__(self, headers: dict[str, str]) -> None:
+ super().__init__("429 Too Many Requests")
+ self.response = Response(headers)
+
+replies = iter([{"Retry-After": "0.01"}, {}, None])
+
+@retry(on=HTTPStatusError, delay=0.01, delay_from=retry_after_header, max_delay=30)
+def fetch() -> str:
+ headers = next(replies)
+ if headers is not None:
+ raise HTTPStatusError(headers)
+ return "payload"
+
+assert fetch() == "payload"
+```
+
+Any function works as `delay_from`, e.g. `delay_from=lambda exc: getattr(exc, "wait", None)`. If it raises, the normal backoff is used.
+
+## Time budget
+
+`max_time` is a total budget in seconds, measured from the first attempt. `retry` never sleeps past it: if the next wait would end after the deadline, the last exception is re-raised at once.
+
+```python
+from python_wrap import retry
+
+calls = []
+
+@retry(max_attempts=None, delay=0.01, backoff=2, max_time=0.05)
+def flaky() -> None:
+ calls.append(1)
+ raise ConnectionError("down")
+
+try:
+ flaky()
+except ConnectionError:
+ pass
+assert len(calls) >= 2
+```
+
+`retry` also honours an enclosing [`deadline`](timeout.md#deadlines-one-budget-for-many-steps): it never sleeps past `remaining()` (from `python_wrap.deadline`). If the next wait would outlast the deadline, or none is left, the last exception is re-raised at once (the same rule as `max_time`).
+
+```python
+from python_wrap import retry
+from python_wrap.deadline import deadline
+
+calls = []
+
+@retry(max_attempts=None, delay=0.02)
+def flaky() -> None:
+ calls.append(1)
+ raise ConnectionError("down")
+
+with deadline(0.05):
+ try:
+ flaky()
+ except ConnectionError:
+ pass
+assert 1 <= len(calls) <= 4
+```
+
+## Retrying a block of code
+
+`retrying(...)` takes the same options as `retry` and lets you retry code that is not a function of its own. Put the risky part in `with attempt:`; a retryable exception is swallowed and the loop waits and continues, success ends the loop, and the last failure propagates. `attempt.number` is the 1-based attempt number.
+
+=== "Sync"
+
+ ```python
+ from python_wrap.retry import retrying
+
+ tries = []
+
+ for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError):
+ with attempt:
+ tries.append(attempt.number)
+ if len(tries) < 3:
+ raise ConnectionError("down")
+
+ assert tries == [1, 2, 3]
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap.retry import retrying
+
+ async def main() -> list[int]:
+ tries: list[int] = []
+ async for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError):
+ with attempt:
+ tries.append(attempt.number)
+ if len(tries) < 3:
+ raise ConnectionError("down")
+ return tries
+
+ assert asyncio.run(main()) == [1, 2, 3]
+ ```
+
+Use `async for` inside coroutines: a plain `for` would block the event loop while waiting.
+
+## Statistics
+
+`retry_stats(func)` returns a `RetryStats` with thread-safe counters: `calls`, `attempts_total`, `retries`, `gave_up`, `total_sleep` and `last_exception`. Counters are per decorated function (shared by all instances for methods); `snapshot()` copies them and `reset()` clears them.
+
+```python
+from python_wrap import retry
+from python_wrap.retry import retry_stats
+
+n = []
+
+@retry(delay=0.01)
+def fetch() -> str:
+ n.append(1)
+ if len(n) < 3:
+ raise OSError("flaky")
+ return "ok"
+
+assert fetch() == "ok"
+stats = retry_stats(fetch)
+assert stats is not None
+assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == (1, 3, 2, 0)
+```
+
+## Project-wide defaults
+
+Set `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time` and `log_level` once for the whole project with `python_wrap.config.configure(retry={...})`. See [Configuration](../configuration.md).
+
+## Logging
+
+Each retry logs one message at `log_level` (default `WARNING`) on `logger`. Use `log_level=logging.INFO` to quiet it, `log_level=None` to turn it off, and `log_exc_info=True` to attach the traceback.
+
+## Events
+
+| Event | Fields |
+| --- | --- |
+| `retry.attempt_failed` | `attempt` (1-based), `exception`, `next_delay` |
+| `retry.succeeded` | `attempts` |
+| `retry.gave_up` | `attempts`, `exception` |
+
+Subscribe with `python_wrap.events`; see [Metrics](../observability/metrics.md).
+
+## Testing
+
+`fast_mode()` makes the backoff waits instant, and `fake_clock()` turns them into clock advances you can assert on. `disable_retries()` makes `retry` a single attempt, and the `max_attempts` override from `python_wrap.testing` caps the attempts of every `retry` and `retrying` at call time. See [Testing](../../testing/index.md).
+
+## Gotchas
+
+!!! warning "Cancellation and shutdown are never retried"
+ `asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are **never retried**, even with `on=BaseException` or when you list them in `on`: retrying them would make tasks and processes impossible to stop. Cancelling an async call while it waits between attempts cancels it immediately.
+
+!!! note "`python_wrap.retry` is the function"
+ The package attribute `python_wrap.retry` is the decorator, not the module. Import helpers explicitly: `from python_wrap.retry import retrying, retry_stats`.
+
+## See also
+
+- [Circuit breaker](circuit-breaker.md), [Timeout and deadline](timeout.md), [Policies](policies.md)
+- [Stacking](../../get-started/stacking.md)
+- [API reference](../../api/retry.md)
diff --git a/docs/features/resilience/timeout.md b/docs/features/resilience/timeout.md
new file mode 100644
index 0000000..9057e93
--- /dev/null
+++ b/docs/features/resilience/timeout.md
@@ -0,0 +1,167 @@
+# Timeout and deadline
+
+`timeout` raises `TimeoutExpired` (a subclass of the built-in `TimeoutError`) if a call runs too long. `deadline` gives a whole block of work one shared time budget.
+
+=== "Sync"
+
+ ```python
+ import time
+ from python_wrap import TimeoutExpired, timeout
+
+ @timeout(0.02)
+ def slow() -> None:
+ time.sleep(1)
+
+ try:
+ slow()
+ except TimeoutExpired as exc:
+ assert exc.seconds == 0.02
+ assert isinstance(exc, TimeoutError)
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import TimeoutExpired, timeout
+
+ @timeout(0.02)
+ async def slow() -> None:
+ await asyncio.sleep(1)
+
+ async def main() -> None:
+ try:
+ await slow()
+ except TimeoutExpired:
+ pass
+ else:
+ raise AssertionError("expected a timeout")
+
+ asyncio.run(main())
+ ```
+
+## When to use it
+
+- Any call to something you do not control: HTTP, a database, a subprocess.
+- Per attempt inside a [retry](retry.md), so one hung attempt cannot eat the whole budget.
+- Use a `deadline` when several calls, retries and sub-steps must finish within one total time.
+- Not to cut tail latency: that is [hedge](hedge.md).
+
+## How the limit is enforced
+
+The strategy depends on the function and the platform:
+
+| Situation | Strategy | Stops the work? |
+| --- | --- | --- |
+| `async def` | `asyncio.timeout` (3.11+) or a cancellation timer (3.10) cancels the coroutine | Yes |
+| Sync, Unix, main thread | `SIGALRM` timer interrupts the call | Yes |
+| Sync on Windows, in a non-main thread, or `use_signals=False` | Runs in a daemon worker thread; the caller stops waiting | **No** |
+
+`use_signals=True` forces signals (and raises `RuntimeError` where unavailable); `use_signals=False` forces the thread strategy. Nested timeouts work: the previous `SIGALRM` handler and any already-armed timer are restored afterwards.
+
+!!! warning "The thread fallback cannot stop the work"
+ When signals are unavailable, the function keeps running in the background after the timeout, and its result is discarded. Do not rely on it to cancel side effects. See [Limitations](../../project/limitations.md#timeout).
+
+??? info "Worker thread pool"
+ The worker threads are pooled: a finished worker waits (as an idle daemon thread) for the next call instead of a new thread being started per call. At most 8 idle workers are kept, an idle worker exits after about 5 seconds without work, and a worker still running a call that timed out is abandoned: it exits when that call returns and is never reused. The caller's `contextvars` are copied into the worker on every call.
+
+## Timing out a block
+
+The object `timeout(...)` returns is also a context manager, so you can limit a block of code without wrapping it in a function. The same object still works as a decorator.
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import TimeoutExpired, timeout
+
+ async def main() -> None:
+ try:
+ async with timeout(0.02):
+ await asyncio.sleep(1)
+ except TimeoutExpired as exc:
+ assert exc.seconds == 0.02
+ else:
+ raise AssertionError("expected a timeout")
+
+ asyncio.run(main())
+ ```
+
+=== "Sync"
+
+
+ ```python
+ import time
+ from python_wrap import TimeoutExpired, timeout
+
+ try:
+ with timeout(0.02): # Unix, main thread only
+ time.sleep(1)
+ except TimeoutExpired:
+ pass
+ ```
+
+`async with timeout(...)` uses `asyncio.timeout` on Python 3.11+ and a cancellation timer on 3.10; as with the decorator, a `TimeoutError` raised by your own code inside the block propagates unchanged.
+
+The sync `with timeout(...)` form only exists on top of `SIGALRM`: it needs Unix and the main thread. Elsewhere (Windows, worker threads, `use_signals=False`) entering it raises `RuntimeError`, because a block of code cannot be moved to a worker thread. Decorate a function instead, which falls back to the thread strategy.
+
+## Deadlines: one budget for many steps
+
+A `timeout` limits one call. A **deadline** limits everything inside a block (several calls, retries, sub-steps) to one shared budget. `deadline(seconds)` stores an absolute expiry in a context variable, so it is visible to every function called inside the block, sync or async, and to tasks started from it. Nested deadlines never extend an outer one: the earliest wins.
+
+A deadline does not interrupt anything by itself. Instead:
+
+* `timeout` (decorator or block) clamps its limit to the time the deadline has left. If the deadline has already passed, the call is not started and `TimeoutExpired` is raised at once.
+* [`retry`](retry.md#time-budget) never sleeps past it.
+* `remaining()` returns the seconds left (or `None` outside any deadline). Pass it on, for example as an HTTP client's timeout, or use it to cap your own retry loop.
+* `check()` raises `TimeoutExpired` once the deadline has passed. Call it between steps of a long job.
+
+```python
+import asyncio
+from python_wrap import TimeoutExpired, timeout
+from python_wrap.deadline import check, deadline, remaining
+
+@timeout(30) # generous per-call limit...
+async def fetch(part: int) -> int:
+ await asyncio.sleep(0.01 if part < 2 else 1)
+ return part
+
+async def handle_request() -> list[int]:
+ results = []
+ async with deadline(0.1): # ...but the whole request gets 0.1s
+ for part in range(5):
+ check() # stop early once the budget is spent
+ left = remaining()
+ assert left is not None and left <= 0.1
+ results.append(await fetch(part)) # clamped to what is left
+ return results
+
+async def main() -> None:
+ try:
+ await handle_request()
+ except TimeoutExpired as exc:
+ assert exc.seconds <= 0.1
+ else:
+ raise AssertionError("expected the deadline to run out")
+
+asyncio.run(main())
+```
+
+## Events
+
+Every expiry emits `timeout.expired` with the field `seconds` (the limit). See [Metrics](../observability/metrics.md).
+
+## Testing
+
+`fast_mode()` and `fake_clock()` do not change timeouts: the limit uses real timers, so that "wait at most 5 s" does not turn into "fail at once". Test with tiny real durations, as the examples on this page do. See [Testing](../../testing/index.md).
+
+## Gotchas
+
+!!! warning "Import the deadline helpers from the module"
+ Use `from python_wrap.deadline import check, deadline, remaining`. The package attribute `python_wrap.deadline` is the `deadline` function itself (the same is true of `retry` and `policy`), so `import python_wrap.deadline as m` does not give you the module.
+
+## See also
+
+- [Retry](retry.md), [Hedge](hedge.md), [Policies](policies.md)
+- [Errors](../../get-started/errors.md) for `TimeoutExpired`
+- [API reference](../../api/deadline.md)
From c503059fed7db38599d88c8272c980962faed2b9 Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:24:16 +0300
Subject: [PATCH 06/10] Docs: observability, correctness and devtools feature
pages
---
docs/features/correctness/index.md | 34 ++++
docs/features/correctness/state.md | 224 ++++++++++++++++++++++
docs/features/correctness/validation.md | 243 ++++++++++++++++++++++++
docs/features/devtools.md | 211 ++++++++++++++++++++
docs/features/observability/audit.md | 82 ++++++++
docs/features/observability/index.md | 47 +++++
docs/features/observability/logging.md | 227 ++++++++++++++++++++++
docs/features/observability/metrics.md | 210 ++++++++++++++++++++
docs/features/observability/timing.md | 151 +++++++++++++++
9 files changed, 1429 insertions(+)
create mode 100644 docs/features/correctness/index.md
create mode 100644 docs/features/correctness/state.md
create mode 100644 docs/features/correctness/validation.md
create mode 100644 docs/features/devtools.md
create mode 100644 docs/features/observability/audit.md
create mode 100644 docs/features/observability/index.md
create mode 100644 docs/features/observability/logging.md
create mode 100644 docs/features/observability/metrics.md
create mode 100644 docs/features/observability/timing.md
diff --git a/docs/features/correctness/index.md b/docs/features/correctness/index.md
new file mode 100644
index 0000000..9e58784
--- /dev/null
+++ b/docs/features/correctness/index.md
@@ -0,0 +1,34 @@
+# Correctness
+
+Keep bad data out and keep objects consistent when something goes wrong halfway.
+
+
+
+- :material-shield-check-outline:{ .lg .middle } **[Validation](validation.md)**
+
+ ---
+
+ `validate_args`, `check_type` and `enforce_types`: reject bad input with an error that names the parameter.
+
+- :material-undo-variant:{ .lg .middle } **[State safety](state.md)**
+
+ ---
+
+ `revert_on_failure`, `Transaction` and `transactional`: undo changes when a call fails.
+
+
+
+## Which one?
+
+| You want to | Use |
+| --- | --- |
+| Check a business rule (`amount > 0`, not empty) | [`validate_args`](validation.md#validate_args) |
+| Check that an argument is an `int` or `str` | [`check_type`](validation.md#check_type) |
+| Make your type hints true at runtime | [`enforce_types`](validation.md#enforce_types) |
+| Restore an object's attributes if a method fails | [`revert_on_failure`](state.md#revert_on_failure) |
+| Undo side effects (files, payments) when a step fails | [`transactional`](state.md#transactional) |
+
+## See also
+
+- [Stacking decorators](../../get-started/stacking.md)
+- [Errors](../../get-started/errors.md)
diff --git a/docs/features/correctness/state.md b/docs/features/correctness/state.md
new file mode 100644
index 0000000..3f74add
--- /dev/null
+++ b/docs/features/correctness/state.md
@@ -0,0 +1,224 @@
+# State safety
+
+Keep objects consistent when something goes wrong halfway.
+
+```python
+from python_wrap import revert_on_failure
+
+class Account:
+ def __init__(self) -> None:
+ self.balance = 10
+ self.history: list[str] = []
+
+ @revert_on_failure
+ def withdraw(self, amount: int) -> None:
+ self.history.append(f"withdraw {amount}")
+ self.balance -= amount
+ if self.balance < 0:
+ raise ValueError("overdrawn")
+
+account = Account()
+try:
+ account.withdraw(50)
+except ValueError:
+ pass
+
+assert account.balance == 10 # restored
+assert account.history == [] # the append was undone too
+```
+
+## When to use it
+
+- `revert_on_failure` when a method changes several attributes and must leave the object untouched if it raises.
+- `Transaction` or `transactional` when a failure must undo things outside the object: files written, records created, payments taken.
+- Neither is a database transaction. They run your callbacks; they do not make the steps atomic.
+
+## `revert_on_failure`
+
+If the function raises, restore the object's attributes to what they were before the call, then re-raise. By default it snapshots `vars(self)`: the first argument of the call. Attributes created during the call are deleted again on failure.
+
+| Option | Meaning |
+| --- | --- |
+| `target` | Index or parameter name of the argument to protect (default `0`, normally `self`). |
+| `attrs` | Snapshot only these attribute names. **Required for classes using `__slots__`.** |
+| `deep` | Use `copy.deepcopy` for the snapshot instead of `copy.copy`. |
+
+```python
+from python_wrap import revert_on_failure
+
+class Cart:
+ def __init__(self) -> None:
+ self.items: list[dict] = [{"qty": 1}]
+ self.coupon = None
+
+ @revert_on_failure(attrs=["items"], deep=True)
+ def bump(self) -> None:
+ self.items[0]["qty"] += 1 # a nested object
+ self.coupon = "TEN" # not protected: not in attrs
+ raise RuntimeError
+
+cart = Cart()
+try:
+ cart.bump()
+except RuntimeError:
+ pass
+
+assert cart.items[0]["qty"] == 1 # deep=True undoes nested mutation
+assert cart.coupon == "TEN" # outside attrs, so not restored
+```
+
+It works on `async def` methods too. It undoes attribute changes only, not side effects such as files written or requests sent; for those, use a transaction.
+
+!!! warning "Copy semantics"
+ Each value is copied (`copy.copy`, or `copy.deepcopy` with `deep=True`) and the **copy** is restored. In-place mutation of a container attribute (`self.items.append(x)`) is undone, but the attribute ends up referring to a new list, so other references to the old list still see the mutation. A shallow copy does not protect nested objects: `self.items[0].count += 1` is only undone with `deep=True`. Values that cannot be copied are restored by reference. See [Limitations](../../project/limitations.md).
+
+## Transactions
+
+A `Transaction` collects undo steps (`on_rollback`) and follow-up steps (`on_commit`) and runs the right set exactly once.
+
+- On success, `on_commit` callbacks run **in order**.
+- On exception, `on_rollback` callbacks run **in reverse registration order**; a failing rollback callback is logged and the rest still run; then the original exception propagates.
+- A transaction completes once; a second `commit()` or `rollback()` raises `RuntimeError`.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import Transaction
+
+ log: list[str] = []
+
+ try:
+ with Transaction() as tx:
+ log.append("created file")
+ tx.on_rollback(log.append, "removed file")
+ log.append("charged card")
+ tx.on_rollback(log.append, "refunded card")
+ tx.on_commit(log.append, "sent receipt")
+ raise RuntimeError("shipping failed")
+ except RuntimeError:
+ pass
+
+ assert tx.state == "rolled_back"
+ assert log == ["created file", "charged card", "refunded card", "removed file"]
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import Transaction
+
+ log: list[str] = []
+
+ async def undo() -> None:
+ log.append("undone")
+
+ async def main() -> None:
+ try:
+ async with Transaction() as tx:
+ tx.on_rollback(undo) # coroutine callbacks need `async with`
+ raise RuntimeError
+ except RuntimeError:
+ pass
+ assert log == ["undone"]
+
+ asyncio.run(main())
+ ```
+
+`tx.state` is `"pending"`, `"committed"` or `"rolled_back"`. Outside a context manager you can call `tx.commit()` / `tx.rollback()`, or `await tx.acommit()` / `await tx.arollback()` when callbacks are coroutine functions.
+
+## `transactional`
+
+Wrap a function in a fresh `Transaction` per call. Returning normally commits; raising rolls back and re-raises. Receive the transaction as a keyword parameter named `tx` declared as `tx: Transaction = INJECTED`; `INJECTED` is a typed placeholder, so type checkers let callers omit `tx`. Or fetch it anywhere in the call tree with `require_transaction()`, which raises `RuntimeError` outside a transaction, or `current_transaction()`, which returns `None` there.
+
+=== "Sync"
+
+ ```python
+ from python_wrap.state import INJECTED, Transaction, transactional
+
+ steps: list[str] = []
+
+ @transactional
+ def transfer(amount: int, *, tx: Transaction = INJECTED) -> str:
+ steps.append("debit")
+ tx.on_rollback(steps.append, "undo debit")
+ if amount > 100:
+ raise ValueError("limit")
+ tx.on_commit(steps.append, "notify")
+ return "ok"
+
+ assert transfer(50) == "ok"
+ assert steps == ["debit", "notify"]
+
+ steps.clear()
+ try:
+ transfer(500)
+ except ValueError:
+ pass
+ assert steps == ["debit", "undo debit"]
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap.state import INJECTED, Transaction, transactional
+
+ steps: list[str] = []
+
+ @transactional
+ async def transfer(amount: int, *, tx: Transaction = INJECTED) -> str:
+ steps.append("debit")
+ tx.on_rollback(steps.append, "undo debit")
+ if amount > 100:
+ raise ValueError("limit")
+ return "ok"
+
+ async def main() -> None:
+ try:
+ await transfer(500)
+ except ValueError:
+ pass
+ assert steps == ["debit", "undo debit"]
+
+ asyncio.run(main())
+ ```
+
+!!! warning "Make `tx` keyword-only"
+ When the function has a parameter named `tx`, it is passed as the keyword argument `tx=`. Callers must not pass it themselves (a `TypeError` says so). A positional-only `tx` is rejected when you decorate, because it cannot be passed by keyword. Declare it keyword-only, with the `INJECTED` default, so callers cannot collide with it.
+
+### Without a `tx` parameter
+
+`require_transaction()` returns the innermost running transaction and raises `RuntimeError` outside one; `current_transaction()` returns `None` instead. It is backed by a `ContextVar`, so it is correct across threads and asyncio tasks, and lets helper functions register undo steps without the transaction being passed through every layer.
+
+```python
+from python_wrap.state import current_transaction, require_transaction, transactional
+
+undone: list[str] = []
+
+def create_resource(name: str) -> None:
+ tx = require_transaction()
+ tx.on_rollback(undone.append, f"delete {name}")
+
+@transactional
+def provision() -> None:
+ create_resource("bucket")
+ create_resource("queue")
+ raise RuntimeError
+
+try:
+ provision()
+except RuntimeError:
+ pass
+
+assert undone == ["delete queue", "delete bucket"]
+assert current_transaction() is None
+```
+
+Nested `@transactional` calls each get their own transaction.
+
+## See also
+
+- [Validation](validation.md)
+- [Limitations](../../project/limitations.md)
+- [API reference](../../api/state.md)
diff --git a/docs/features/correctness/validation.md b/docs/features/correctness/validation.md
new file mode 100644
index 0000000..f7bc341
--- /dev/null
+++ b/docs/features/correctness/validation.md
@@ -0,0 +1,243 @@
+# Validation
+
+Reject bad input at the door, with an error that names the parameter.
+
+```python
+from python_wrap import ValidationError, validate_args
+
+@validate_args(quantity=lambda q: q > 0)
+def order(sku: str, quantity: int) -> str:
+ return f"{quantity} x {sku}"
+
+assert order("SKU-1", 2) == "2 x SKU-1"
+
+try:
+ order("SKU-1", 0)
+except ValidationError as exc:
+ assert exc.parameter == "quantity"
+```
+
+## When to use it
+
+- Business rules (`amount > 0`, "not empty", "valid email"): `validate_args`.
+- "This must be an `int` or `str`": `check_type` is the lightest.
+- "My annotations should be true at runtime": `enforce_types`.
+- Use them at boundaries (public API, request handlers). For deep or hot paths, prefer static type checking.
+
+| Decorator | Checks | Raises |
+| --- | --- | --- |
+| [`validate_args`](#validate_args) | Your predicate per argument | `ValidationError` (a `ValueError`) |
+| [`check_type`](#check_type) | `isinstance` per argument | `ArgumentTypeError` (a `TypeError`) |
+| [`enforce_types`](#enforce_types) | Arguments and return value against the function's own annotations | `ArgumentTypeError` (a `TypeError`) |
+
+All three work on plain functions, `async def` functions and methods, can be stacked, and keep the wrapped function's signature. A parameter name that does not exist raises `TypeError` **when you decorate**, so typos fail at import time rather than in production.
+
+## `validate_args`
+
+Give each parameter a predicate. A falsy result raises `ValidationError`.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import ValidationError, validate_args
+
+ @validate_args(
+ quantity=lambda q: q > 0,
+ sku=(lambda s: s.startswith("SKU-"), "sku must start with 'SKU-'"),
+ )
+ def order(sku: str, quantity: int) -> str:
+ return f"{quantity} x {sku}"
+
+ assert order("SKU-1", 2) == "2 x SKU-1"
+
+ try:
+ order("SKU-1", 0)
+ except ValidationError as exc:
+ assert exc.parameter == "quantity" and exc.value == 0
+ assert isinstance(exc, ValueError)
+
+ try:
+ order("bad", 1)
+ except ValidationError as exc:
+ assert "must start with" in str(exc)
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import ValidationError, validate_args
+
+ @validate_args(n=lambda n: n > 0)
+ async def double(n: int) -> int:
+ return n * 2
+
+ async def main() -> None:
+ assert await double(2) == 4
+ try:
+ await double(-1)
+ except ValidationError:
+ pass
+ else:
+ raise AssertionError("expected a ValidationError")
+
+ asyncio.run(main())
+ ```
+
+Details:
+
+- Use a `(predicate, "message")` tuple for a custom message.
+- Default values are applied first, so defaults are validated too.
+- A validator may raise its own exception instead of returning falsy; it propagates unchanged.
+- For `*args` / `**kwargs` parameters the validator receives the whole tuple / dict.
+
+## `check_type`
+
+`isinstance` checks, one per argument. The value can be a class or a tuple of classes.
+
+```python
+from python_wrap import ArgumentTypeError, check_type
+
+@check_type(x=int, label=(str, bytes))
+def tag(x, label):
+ return f"{label}:{x}"
+
+assert tag(1, "a") == "a:1"
+
+try:
+ tag("1", "a")
+except ArgumentTypeError as exc:
+ assert exc.parameter == "x" and exc.expected is int and exc.value == "1"
+ assert isinstance(exc, TypeError)
+```
+
+Only arguments that are actually passed are checked (defaults are not). For `*args` / `**kwargs` the type applies to every element / value.
+
+## `enforce_types`
+
+Check the arguments and the return value against the function's own annotations. No extra configuration: your type hints become runtime checks.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import ArgumentTypeError, enforce_types
+
+ @enforce_types
+ def total(items: list[int], scale: float = 1.0) -> float:
+ return sum(items) * scale
+
+ assert total([1, 2, 3]) == 6.0
+
+ try:
+ total([1, 2, "3"])
+ except ArgumentTypeError as exc:
+ assert exc.parameter == "items"
+ assert "index 2" in str(exc)
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import ArgumentTypeError, enforce_types
+
+ @enforce_types
+ async def name_length(name: str) -> int:
+ return len(name)
+
+ async def main() -> None:
+ assert await name_length("ana") == 3
+ try:
+ await name_length(42) # type: ignore[arg-type]
+ except ArgumentTypeError:
+ pass
+ else:
+ raise AssertionError("expected an ArgumentTypeError")
+
+ asyncio.run(main())
+ ```
+
+The error message says what was expected and what was found, for example `parameter 'items' expected list[int], got list containing str at index 2`. For a bad return value `error.parameter` is `"return"`; turn that off with `@enforce_types(check_return=False)`.
+
+```python
+from python_wrap import ArgumentTypeError, enforce_types
+
+@enforce_types
+def broken() -> int:
+ return "not an int" # type: ignore[return-value]
+
+try:
+ broken()
+except ArgumentTypeError as exc:
+ assert exc.parameter == "return"
+```
+
+Annotations are resolved lazily on the first call with `typing.get_type_hints`, so forward references work. For async functions the awaited result is checked.
+
+### Supported annotations
+
+| Annotation | How it is checked |
+| --- | --- |
+| Plain classes (`int`, `str`, your own) | `isinstance`. `int` also accepts `bool`; `float` accepts `int`; `complex` accepts `int` and `float` (PEP 484). |
+| `Any`, `None` | Anything / `None` only. |
+| `Optional[T]`, `Union[A, B]`, PEP 604 unions (the `X` bar `Y` syntax) | Any member matches. |
+| `Literal[...]` | The value is one of the literals. |
+| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]`, `tuple[A, B]`, `dict[K, V]` | Container type **and each element**. |
+| `Sequence[T]`, `MutableSequence[T]`, `Set[T]`, `Mapping[K, V]` | ABC and each element. |
+| `Iterable[T]`, `Iterator[T]`, `Generator` | `isinstance` only. Iterators are never consumed. |
+| `Callable[...]` | `callable()` only. |
+| `type[T]` | The value is a subclass of `T`. |
+| `TypeVar` | Its bound or constraints; unbound means anything. |
+| `Annotated[T, ...]` | Checks `T`. |
+| `NewType` | Checks the supertype. |
+| `runtime_checkable` protocols | `isinstance`. |
+| `TypedDict`, non-runtime-checkable protocols, unresolved forward references, anything else | **Accepted without checking.** |
+
+A check that cannot be performed never raises: unsupported annotations are accepted rather than rejected.
+
+```python
+from typing import Annotated, Literal, Optional
+from python_wrap import ArgumentTypeError, enforce_types
+
+@enforce_types
+def configure(
+ mode: Literal["fast", "safe"],
+ retries: Optional[int] = None,
+ tags: dict[str, list[int]] | None = None,
+ level: Annotated[int, "0 to 9"] = 0,
+) -> None: ...
+
+configure("fast", tags={"a": [1, 2]})
+
+for bad in ({"mode": "turbo"}, {"mode": "fast", "tags": {"a": ["x"]}}):
+ try:
+ configure(**bad)
+ except ArgumentTypeError:
+ pass
+ else:
+ raise AssertionError(f"{bad} should have been rejected")
+```
+
+!!! warning "A runtime cost"
+ Checking a `list[int]` inspects every element on every call. Use `enforce_types` at boundaries (public API, request handlers), not inside hot loops.
+
+## Combining them
+
+They stack: `enforce_types` for shape, `validate_args` for rules.
+
+```python
+from python_wrap import enforce_types, validate_args
+
+@enforce_types
+@validate_args(amount=(lambda a: a > 0, "amount must be positive"))
+def charge(account: str, amount: float) -> str:
+ return f"{account}: {amount}"
+
+assert charge("acme", 5) == "acme: 5"
+```
+
+## See also
+
+- [State safety](state.md)
+- [Stacking decorators](../../get-started/stacking.md)
+- [API reference](../../api/validation.md)
diff --git a/docs/features/devtools.md b/docs/features/devtools.md
new file mode 100644
index 0000000..61dae5f
--- /dev/null
+++ b/docs/features/devtools.md
@@ -0,0 +1,211 @@
+# Developer tools
+
+Tools for evolving an API, faking data, and keeping decorator stacks tidy: `deprecate`, `mock_data`, `compose`, `wrap_methods` and `no_debug`.
+
+```python
+import warnings
+from python_wrap import deprecate
+
+@deprecate(since="0.2", removed_in="1.0", alternative="new_func")
+def old_func() -> int:
+ return 1
+
+with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ assert old_func() == 1
+
+assert caught[0].category is DeprecationWarning
+text = str(caught[0].message)
+assert "old_func" in text and "0.2" in text and "1.0" in text and "new_func" in text
+```
+
+## When to use it
+
+- `deprecate` to retire a function, method or class without surprising callers.
+- `mock_data` to return fixed data in tests and local development.
+- `compose` to define a decorator stack once and reuse it.
+- `wrap_methods` to decorate many methods of a class in one line.
+
+## `deprecate`
+
+Mark a function, method or class as deprecated. Every call (or, for `async def`, every await) emits a warning that **points at the caller's line**, not at the library, so users see which of their code to change. The wrapped object also gets a `__deprecated__` attribute (PEP 702).
+
+The structured parts are combined into `" is deprecated since ... and will be removed in ...; use ... instead."`. Pass a message to replace the generated text, and `category=` to change the warning class:
+
+=== "Message"
+
+ ```python
+ import warnings
+ from python_wrap import deprecate
+
+ @deprecate("fetch() is going away; call get() instead")
+ def fetch() -> None: ...
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ fetch()
+ assert str(caught[0].message) == "fetch() is going away; call get() instead"
+ ```
+
+=== "Class"
+
+ ```python
+ import warnings
+ from python_wrap import deprecate
+
+ @deprecate("Use Widget2 instead")
+ class Widget:
+ pass
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ widget = Widget()
+
+ assert isinstance(widget, Widget) # still a class, isinstance still works
+ assert "Widget2" in str(caught[0].message)
+ ```
+
+=== "Category"
+
+ ```python
+ import warnings
+ from python_wrap import deprecate
+
+ @deprecate(since="0.2", category=FutureWarning)
+ def shown_to_end_users() -> None: ...
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ shown_to_end_users()
+ assert caught[0].category is FutureWarning
+ ```
+
+Classes are changed in place: their `__init__` warns.
+
+!!! note "Who sees the warning"
+ Python hides `DeprecationWarning` by default outside `__main__` and test runners. Use `category=FutureWarning` when end users, not developers, should see it.
+
+## `mock_data`
+
+Replace a function's result with fixed data, for tests and local development. When enabled, the real function is **not called**.
+
+```python
+import os
+from python_wrap import mock_data
+
+@mock_data({"id": 1, "name": "Ada"}, enabled=lambda: os.environ.get("USE_MOCKS") == "1")
+def fetch_user(user_id: int) -> dict:
+ raise RuntimeError("would call the real API")
+
+os.environ["USE_MOCKS"] = "1"
+assert fetch_user(99) == {"id": 1, "name": "Ada"}
+
+os.environ["USE_MOCKS"] = "0"
+try:
+ fetch_user(99)
+except RuntimeError:
+ pass # disabled: the real function ran
+else:
+ raise AssertionError("expected the real function to run")
+```
+
+- `enabled` is `True`, `False`, or a zero-argument callable evaluated **on every call**, so it can read an environment variable or a settings flag.
+- If `data` is callable it is called on each call and its result is returned. To return a callable itself, wrap it: `mock_data(lambda: my_fn)`.
+- Works for `async def` functions (the awaited result is the data).
+
+```python
+import asyncio
+from python_wrap import mock_data
+
+@mock_data(lambda: [1, 2, 3]) # a fresh list on every call
+async def numbers() -> list[int]:
+ raise RuntimeError
+
+assert asyncio.run(numbers()) == [1, 2, 3]
+```
+
+!!! danger "Never ship it enabled"
+ `mock_data` silently replaces real behaviour. Tie `enabled=` to an explicit opt-in such as an environment variable that is off by default, never a default of `True` in code that reaches production. In tests, see [Testing](../testing/index.md).
+
+## `compose`
+
+Combine decorators into one. `compose(a, b, c)(f)` is exactly `a(b(c(f)))`, so list them outermost first, as if stacked top to bottom.
+
+```python
+from python_wrap import compose, memoize, retry, validate_args
+
+resilient_lookup = compose(
+ memoize(ttl=60),
+ retry(max_attempts=3, delay=0.01),
+ validate_args(key=lambda k: k != ""),
+)
+
+@resilient_lookup
+def lookup(key: str) -> str:
+ return key.upper()
+
+@resilient_lookup
+def lookup_other(key: str) -> str:
+ return key.lower()
+
+assert lookup("a") == "A"
+assert lookup_other("A") == "a"
+```
+
+Define a stack once and reuse it across your codebase. `compose()` with no decorators returns the function unchanged, and a non-callable argument raises `TypeError`. The order rules in [Stacking decorators](../get-started/stacking.md) apply to the arguments.
+
+!!! note "Share state deliberately"
+ `compose(memoize(...))` creates its `memoize` decorator once, but `memoize` still gives each decorated function its own cache. A `CircuitBreaker` instance, on the other hand, is shared by everything it decorates. That is usually what you want for a breaker, but check it.
+
+## `wrap_methods`
+
+Apply decorators to the methods of a class in one line. Decorators are listed outermost first, like `compose`, and a [policy](resilience/policies.md) works as well.
+
+```python
+import asyncio
+from python_wrap import policy, retry, timeout, wrap_methods
+
+calls = []
+
+@wrap_methods(
+ policy(retry=retry(max_attempts=2, delay=0)),
+ exclude=["close"],
+)
+class Client:
+ def get(self, key: str) -> str:
+ calls.append(key)
+ return key
+
+ async def fetch(self, key: str) -> str:
+ return key
+
+ @staticmethod
+ def parse(raw: str) -> int:
+ return int(raw)
+
+ def close(self) -> None: ...
+
+client = Client()
+assert client.get("a") == "a"
+assert asyncio.run(client.fetch("b")) == "b"
+assert Client.parse("3") == 3
+```
+
+Plain, `async def`, `@staticmethod` and `@classmethod` methods are decorated correctly (the underlying function is wrapped and put back in its descriptor). By default these are skipped: dunder methods, names starting with `_`, properties, non-function attributes and inherited methods.
+
+| Option | Meaning |
+| --- | --- |
+| `include` | Names (list/set) or a predicate. Restricts the selection. Naming a dunder or a property here opts it in. |
+| `exclude` | Names or a predicate to leave alone. Wins over `include`. |
+| `include_private` | Also select `_single_underscore` methods. |
+
+## `no_debug`
+
+A deprecated no-op kept so v0.1 code keeps importing. It returns the function unchanged and warns when applied. It will be removed in 0.3. To hide debug output, configure [logging levels](observability/logging.md#configuring-logging) instead.
+
+## See also
+
+- [Stacking decorators](../get-started/stacking.md)
+- [Policies](resilience/policies.md)
+- [Testing](../testing/index.md)
+- [API reference](../api/devtools.md)
diff --git a/docs/features/observability/audit.md b/docs/features/observability/audit.md
new file mode 100644
index 0000000..bb42a3c
--- /dev/null
+++ b/docs/features/observability/audit.md
@@ -0,0 +1,82 @@
+# Audit
+
+Record who called a function, with what, and how it ended.
+
+```python
+from python_wrap import AuditRecord, audit
+
+records: list[AuditRecord] = []
+
+@audit(sink=records.append, include_result=True)
+def delete_user(user_id: int, token: str) -> str:
+ if user_id < 0:
+ raise ValueError("bad id")
+ return "deleted"
+
+delete_user(7, token="abc123")
+try:
+ delete_user(-1, token="abc123")
+except ValueError:
+ pass
+
+ok, failed = records
+assert ok.status == "success" and ok.result == "deleted"
+assert ok.arguments == {"user_id": 7, "token": "***"} # sensitive values are masked
+assert failed.status == "error" and "ValueError" in failed.error
+assert ok.caller.endswith(tuple("0123456789")) # "file:line" of the calling code
+```
+
+Each call produces an `AuditRecord`, delivered to your `sink`, and also attached to the log line as `LogRecord.audit`.
+
+## When to use it
+
+- You need a durable answer to "who deleted that?" for compliance or support.
+- You want structured records (not formatted strings) to store in a database or ship to a log pipeline.
+- To see arguments while debugging, [`log_args`](logging.md#log_args-and-log_return) is lighter. For latency, use [Timing](timing.md).
+
+## The record
+
+| Field | Meaning |
+| --- | --- |
+| `function` | Qualified name of the audited function. |
+| `timestamp` | UTC, timezone-aware. |
+| `caller` | `"file:line"` of the calling code. |
+| `arguments` | Arguments by parameter name, with sensitive ones masked. |
+| `status` | `"success"` or `"error"`. |
+| `error` | The `repr` of the exception, or `None`. |
+| `duration` | Seconds the call took. |
+| `result` | The return value, only with `include_result=True`. |
+| `context` | Fields bound by [`log_context`](logging.md#log_context) when the call started (an empty dict when none). |
+
+To read the record from a log handler instead of a sink, use `record.audit` on the `LogRecord`. `audit` logs at `INFO` by default and takes the same `logger=` and `level=` options as the [logging decorators](logging.md#configuring-logging).
+
+## Redaction
+
+`audit` masks parameters by name, exactly like `log_args`. Add names with `redact_extra=`; `redact=` replaces the defaults. See [Redaction](logging.md#redaction) for the default list and the warning about `redact=`.
+
+```python
+from python_wrap import audit
+
+records = []
+
+@audit(redact_extra=["ssn"], sink=records.append)
+def register(name: str, ssn: str, password: str) -> None: ...
+
+register("ana", "123-45-6789", "hunter2")
+args = records[0].arguments
+assert args["ssn"] == "***" and args["password"] == "***" and args["name"] == "ana"
+```
+
+## Gotchas
+
+!!! note "A broken sink never breaks the function"
+ An exception raised by the sink is logged and swallowed, so a broken audit store never breaks the audited function. Exceptions from the function itself are recorded and re-raised.
+
+!!! warning "Redaction is by name"
+ Values are not scanned. A secret passed in a parameter called `data` is recorded as is; add the name with `redact_extra=` or rename the parameter.
+
+## See also
+
+- [Logging](logging.md), including [`log_context`](logging.md#log_context)
+- [Project-wide configuration](../configuration.md) for setting redaction names once
+- [API reference](../../api/logs.md)
diff --git a/docs/features/observability/index.md b/docs/features/observability/index.md
new file mode 100644
index 0000000..c388d3b
--- /dev/null
+++ b/docs/features/observability/index.md
@@ -0,0 +1,47 @@
+# Observability
+
+Find out how long calls take, how much they allocate, what they were called with, who called them, and how often they fail. python-wrap has no dependencies for any of this and never configures logging for you.
+
+
+
+- :material-text-box-outline:{ .lg .middle } **[Logging](logging.md)**
+
+ ---
+
+ Configure logging, then use `log_args`, `log_return`, `trace` and `log_context` to see what was called.
+
+- :material-clipboard-text-clock-outline:{ .lg .middle } **[Audit](audit.md)**
+
+ ---
+
+ Structured records of who called what, with which arguments, and how it ended.
+
+- :material-timer-outline:{ .lg .middle } **[Timing](timing.md)**
+
+ ---
+
+ `timing`, `benchmark`, `measure_memory` and `profile`.
+
+- :material-chart-line:{ .lg .middle } **[Metrics and events](metrics.md)**
+
+ ---
+
+ `count_calls` statistics, plus the event stream you can forward to Prometheus, OpenTelemetry or statsd.
+
+
+
+## Which one?
+
+| Question | Use |
+| --- | --- |
+| What was this called with, and what did it return? | [Logging](logging.md) (`log_args`, `log_return`) |
+| Who is calling this function? | [Logging](logging.md#trace) (`trace`) |
+| Who did what, for compliance or an audit trail? | [Audit](audit.md) |
+| How slow is it? How much memory does it allocate? | [Timing](timing.md) |
+| How often is it called, what is the error rate, what is p95? | [Metrics](metrics.md) (`count_calls`) |
+| Feed retries, cache hits and circuit changes to Prometheus or OpenTelemetry | [Events](metrics.md#events) |
+
+## See also
+
+- [Project-wide configuration](../configuration.md) for setting redaction names once.
+- [API reference](../../api/logs.md)
diff --git a/docs/features/observability/logging.md b/docs/features/observability/logging.md
new file mode 100644
index 0000000..21b1982
--- /dev/null
+++ b/docs/features/observability/logging.md
@@ -0,0 +1,227 @@
+# Logging
+
+Log what a function was called with, what it returned, and who called it.
+
+```python
+import logging
+from python_wrap import log_args, log_return
+
+class ListHandler(logging.Handler):
+ def __init__(self) -> None:
+ super().__init__(logging.DEBUG)
+ self.messages: list[str] = []
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self.messages.append(record.getMessage())
+
+log = logging.getLogger("demo")
+log.setLevel(logging.DEBUG)
+handler = ListHandler()
+log.addHandler(handler)
+
+@log_args(logger=log)
+@log_return(logger=log)
+def add(a: int, b: int) -> int:
+ return a + b
+
+add(1, b=2)
+assert "Calling add(a=1, b=2)" in handler.messages
+assert "add returned 3" in handler.messages
+```
+
+The examples on these pages attach a small handler that collects log records, so they can show exactly what is logged. In your own code you configure logging once, as described below.
+
+## When to use it
+
+- You want to see arguments and results while developing or debugging.
+- You want request ids or job names on every log line (`log_context`).
+- You need to know which code path called a function (`trace`).
+- For a structured record of who did what, use [Audit](audit.md). For durations, use [Timing](timing.md).
+
+## Configuring logging
+
+python-wrap **never configures logging**. Importing it does not touch the root logger, and the `python_wrap` logger only carries a `NullHandler`. Until your application sets logging up, the decorators on these pages are silent.
+
+Every logging decorator takes two options:
+
+- `logger=` a `logging.Logger`, a logger name, or `None` for the package logger `python_wrap`.
+- `level=` the log level (`timing` and `audit` default to `INFO`; `log_args`, `log_return` and `trace` default to `DEBUG`).
+
+Turn everything on for development:
+
+
+```python
+import logging
+
+logging.basicConfig(level=logging.DEBUG)
+```
+
+Or route and filter like any other library:
+
+
+```python
+import logging
+
+# Only python-wrap's messages, to their own handler, at their own level.
+logger = logging.getLogger("python_wrap")
+handler = logging.StreamHandler()
+handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
+logger.addHandler(handler)
+logger.setLevel(logging.INFO)
+logger.propagate = False
+
+# Or silence it completely.
+logging.getLogger("python_wrap").setLevel(logging.CRITICAL + 1)
+```
+
+To send one decorator's output somewhere specific, name the logger:
+
+```python
+import logging
+from python_wrap import timing
+
+@timing(logger="myapp.perf", level=logging.DEBUG)
+def work() -> None: ...
+
+work()
+```
+
+## `log_args` and `log_return`
+
+`log_args` logs each call's arguments by parameter name, before running it. `log_return` logs the return value, or the exception type if it raises. See the example at the top of the page.
+
+Both truncate long values (`max_length`, default 200), and both default to `DEBUG` (in v0.1, `log_args` logged at `INFO`).
+
+## Redaction
+
+`log_args` (and [`audit`](audit.md)) mask parameters whose **name contains** any of the redaction fragments, case-insensitively. The masked value is shown as `'***'`.
+
+The default list is `DEFAULT_REDACT`: `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `authorization`, `auth`, `credential`, `private_key`, `cookie`, `session`.
+
+```python
+import logging
+from python_wrap import DEFAULT_REDACT, log_args
+
+class ListHandler(logging.Handler):
+ def __init__(self) -> None:
+ super().__init__(logging.DEBUG)
+ self.messages: list[str] = []
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self.messages.append(record.getMessage())
+
+log = logging.getLogger("redaction-demo")
+log.setLevel(logging.DEBUG)
+sink = ListHandler()
+log.addHandler(sink)
+
+@log_args(logger=log)
+def login(user: str, password: str) -> None: ...
+
+login("ana", "hunter2")
+assert "hunter2" not in sink.messages[0]
+assert "password='***'" in sink.messages[0]
+
+# Extend the defaults, or pass () to disable redaction.
+@log_args(logger=log, redact=(*DEFAULT_REDACT, "ssn"))
+def register(name: str, ssn: str) -> None: ...
+
+register("ana", "123-45-6789")
+assert "123-45-6789" not in sink.messages[1]
+```
+
+To **add** names without replacing the defaults, use `redact_extra=`, on `log_args` and on `audit`:
+
+```python
+from python_wrap import audit
+
+records = []
+
+@audit(redact_extra=["ssn"], sink=records.append)
+def register(name: str, ssn: str, password: str) -> None: ...
+
+register("ana", "123-45-6789", "hunter2")
+args = records[0].arguments
+assert args["ssn"] == "***" and args["password"] == "***" and args["name"] == "ana"
+```
+
+To apply the same names to every `log_args` and `audit` in the project, set them once with `configure(redact_extra=[...])` (or replace the whole list with `configure(redact=[...])`); see [Project-wide configuration](../configuration.md).
+
+!!! warning "`redact=` replaces the defaults"
+ Passing `redact=("ssn",)` masks only `ssn`; `password` is no longer masked. Prefer `redact_extra=`. Redaction works by parameter **name**; it does not scan values, and it does not touch what the function itself logs or returns.
+
+## `trace`
+
+Log entry, the call stack that led to the call, and exit. Three messages per call: `Entering f`, the stack (python-wrap's own frames left out, outermost first), and `Exiting f` (or `Exiting f (raised ValueError)`).
+
+```python
+from python_wrap import trace
+
+@trace(limit=5) # keep the 5 innermost frames
+def handler() -> int:
+ return 1
+
+assert handler() == 1
+```
+
+Use it to answer "who is calling this?" while debugging. It defaults to `DEBUG`, so it is silent unless you enable that level.
+
+## `log_context`
+
+Bind fields such as a request id once, and have every python-wrap log line made underneath carry them. It works as a context manager (`with` and `async with`) and as a decorator, and blocks nest.
+
+```python
+import logging
+from python_wrap import log_args
+from python_wrap.logs import current_log_context, log_context
+
+class ListHandler(logging.Handler):
+ def __init__(self) -> None:
+ super().__init__(logging.DEBUG)
+ self.records: list[logging.LogRecord] = []
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self.records.append(record)
+
+log = logging.getLogger("context-demo")
+log.setLevel(logging.DEBUG)
+handler = ListHandler()
+log.addHandler(handler)
+
+@log_args(logger=log)
+def charge(amount: int) -> None: ...
+
+with log_context(request_id="r-42"):
+ with log_context(user="ana"):
+ assert current_log_context() == {"request_id": "r-42", "user": "ana"}
+ charge(5)
+
+record = handler.records[0]
+assert record.getMessage() == "Calling charge(amount=5) [request_id=r-42 user=ana]"
+assert record.context == {"request_id": "r-42", "user": "ana"} # type: ignore[attr-defined]
+assert current_log_context() == {}
+```
+
+`log_args`, `log_return`, `audit`, `trace` and `timing` append ` [key=value ...]` to their message and attach the dict as `LogRecord.context`, so a JSON formatter can emit the fields as proper keys. `AuditRecord.context` holds the fields bound when the call started (an empty dict when none).
+
+As a decorator, the fields are bound for each call:
+
+```python
+import asyncio
+from python_wrap.logs import current_log_context, log_context
+
+@log_context(job="nightly")
+async def run() -> dict[str, object]:
+ return current_log_context()
+
+assert asyncio.run(run()) == {"job": "nightly"}
+```
+
+!!! note "Threads start empty"
+ The fields live in a `contextvars.ContextVar`: every asyncio task and thread sees its own. A new thread starts **empty**; to carry the fields over, start it in a copied context with `contextvars.copy_context().run(...)` (`run_in_thread` and `asyncio.to_thread` already do this).
+
+## See also
+
+- [Audit](audit.md), [Timing](timing.md), [Metrics and events](metrics.md)
+- [Project-wide configuration](../configuration.md)
+- [API reference](../../api/logs.md)
diff --git a/docs/features/observability/metrics.md b/docs/features/observability/metrics.md
new file mode 100644
index 0000000..978af66
--- /dev/null
+++ b/docs/features/observability/metrics.md
@@ -0,0 +1,210 @@
+# Metrics and events
+
+See what your code does in production, with no dependencies: in-process call statistics, and an event stream you can forward to your monitoring system.
+
+```python
+from python_wrap.metrics import count_calls
+
+@count_calls(window=500)
+def work(n: int) -> int:
+ if n < 0:
+ raise ValueError("negative")
+ return n * 2
+
+work(1)
+work(2)
+try:
+ work(-1)
+except ValueError:
+ pass
+
+stats = work.stats
+assert (stats.calls, stats.errors, stats.in_flight) == (3, 1, 0)
+assert stats.last_duration >= 0
+assert 0 <= stats.p50 <= stats.p95 <= stats.p99
+work.reset()
+assert work.stats.calls == 0
+```
+
+## When to use it
+
+- `count_calls` when you want call counts, error rate and p50/p95/p99 for one function, read from your own code or an endpoint.
+- Events when you want to know what the resilience decorators did (retries, cache hits, circuit changes, rejections) and forward it to Prometheus, OpenTelemetry, statsd or your logs.
+- For per-call durations written to the log, use [`timing`](timing.md). For arguments and results, use [Logging](logging.md).
+
+## `count_calls`
+
+`.stats` is a live view with `calls`, `errors`, `in_flight`, `last_duration`, `mean`, `p50`, `p95` and `p99` (seconds). `calls` and `errors` count everything since the last `reset()`; the latency figures cover the last `window` finished calls (default 1000), failed ones included. Percentiles use the nearest-rank method. All of it is thread-safe.
+
+`async def` functions work the same, timed across the whole awaited coroutine, and `in_flight` shows how many calls are running right now:
+
+```python
+import asyncio
+from python_wrap.metrics import count_calls
+
+@count_calls
+async def fetch(n: int) -> int:
+ await asyncio.sleep(0)
+ return n
+
+async def main() -> None:
+ await asyncio.gather(*(fetch(i) for i in range(5)))
+ assert fetch.stats.calls == 5
+
+asyncio.run(main())
+```
+
+!!! note "Methods share statistics"
+ On methods the statistics belong to the function, so they are shared by every instance. Cancellation counts as an error.
+
+## Events
+
+Decorators publish `Event(name, function, fields)` objects. Subscribe to receive them:
+
+```python
+from python_wrap.retry import retry
+from python_wrap.events import Event, listening
+
+seen: list[Event] = []
+calls = 0
+
+@retry(max_attempts=3, delay=0)
+def flaky() -> str:
+ global calls
+ calls += 1
+ if calls < 3:
+ raise ConnectionError("down")
+ return "ok"
+
+with listening(seen.append):
+ assert flaky() == "ok"
+
+names = [e.name for e in seen]
+assert names == ["retry.attempt_failed", "retry.attempt_failed", "retry.succeeded"]
+assert seen[0].fields["attempt"] == 1
+```
+
+- `subscribe(listener)` registers a listener and returns a function that unsubscribes it.
+- `listening(listener)` is the same as a `with` block.
+- `filter_events("retry.", listener)` wraps a listener so it only sees names with that prefix (or any of a tuple of prefixes).
+
+```python
+from python_wrap.events import Event, filter_events, subscribe
+
+retries: list[Event] = []
+unsubscribe = subscribe(filter_events("retry.", retries.append))
+unsubscribe()
+```
+
+!!! warning "Listeners run in the calling thread"
+ Listeners run synchronously in the thread that triggered the event, so keep them fast and never block. An exception in a listener is logged and swallowed: monitoring code can not break the decorated function. With no listeners registered, emitting is a single list check.
+
+### Event catalogue
+
+`python_wrap.events.EVENT_NAMES` maps each name to a description; this table is the same data. `Event.function` is the decorated function's qualified name (for `circuit.*` events it is the breaker's name).
+
+| Event | Fields |
+| --- | --- |
+| `retry.attempt_failed` | `attempt` (int, 1-based), `exception`, `next_delay` (seconds) |
+| `retry.succeeded` | `attempts` (total made) |
+| `retry.gave_up` | `attempts`, `exception` |
+| `timeout.expired` | `seconds` (the limit) |
+| `circuit.state_changed` | `old`, `new` (`closed`, `open`, `half_open`) |
+| `circuit.rejected` | `retry_after` (seconds) |
+| `rate_limit.rejected` | `retry_after` (seconds) |
+| `rate_limit.waited` | `seconds` |
+| `throttle.skipped` | none |
+| `cache.hit` | none |
+| `cache.miss` | none |
+| `cache.evicted` | `reason` (`lru` or `ttl`) |
+| `bulkhead.rejected` | `active`, `waiting` |
+
+```python
+from python_wrap.events import EVENT_NAMES
+
+assert "cache.hit" in EVENT_NAMES
+for name, description in EVENT_NAMES.items():
+ assert description
+```
+
+## Recipes
+
+None of these libraries are dependencies of python-wrap, so the blocks below are not run by the docs checker. Each is a few lines: subscribe once at start-up.
+
+### Prometheus
+
+
+```python
+from prometheus_client import Counter
+from python_wrap.events import Event, subscribe
+
+_counters: dict[str, Counter] = {}
+
+def to_prometheus(event: Event) -> None:
+ name = "python_wrap_" + event.name.replace(".", "_") + "_total"
+ counter = _counters.get(name)
+ if counter is None:
+ counter = _counters[name] = Counter(name, event.name, ["function"])
+ counter.labels(function=event.function).inc()
+
+subscribe(to_prometheus)
+```
+
+Use a fixed set of names (for instance built from `EVENT_NAMES`) to avoid the lookup race on first use, and keep `function` as the only label: never label with exception messages.
+
+### OpenTelemetry
+
+
+```python
+from opentelemetry import metrics, trace
+from python_wrap.events import Event, subscribe
+
+counter = metrics.get_meter("python_wrap").create_counter("python_wrap.events")
+
+def to_otel(event: Event) -> None:
+ counter.add(1, {"event": event.name, "function": event.function})
+ span = trace.get_current_span() # annotate the active span, if any
+ if span.is_recording():
+ span.add_event(event.name, {"function": event.function})
+
+subscribe(to_otel)
+```
+
+### statsd
+
+
+```python
+from statsd import StatsClient
+from python_wrap.events import Event, subscribe
+
+statsd = StatsClient()
+
+def to_statsd(event: Event) -> None:
+ statsd.incr(f"python_wrap.{event.name}")
+
+subscribe(to_statsd)
+```
+
+### structlog or logging
+
+
+```python
+import structlog
+from python_wrap.events import Event, subscribe
+
+log = structlog.get_logger("python_wrap.events")
+
+def to_structlog(event: Event) -> None:
+ fields = {k: v for k, v in event.fields.items() if k != "exception"}
+ log.info(event.name, function=event.function, **fields)
+
+subscribe(to_structlog)
+```
+
+With the standard library instead, call `logging.getLogger("myapp.events").info("%s %s", event.name, event.function, extra={"fields": event.fields})`.
+
+## See also
+
+- [Timing](timing.md), [Logging](logging.md)
+- [Retry](../resilience/retry.md), [Circuit breaker](../resilience/circuit-breaker.md) and the other pages that list the events they emit
+- [API reference](../../api/metrics.md), [events API](../../api/events.md)
diff --git a/docs/features/observability/timing.md b/docs/features/observability/timing.md
new file mode 100644
index 0000000..8f5b719
--- /dev/null
+++ b/docs/features/observability/timing.md
@@ -0,0 +1,151 @@
+# Timing
+
+Measure how long calls take, how much they allocate, and where the time goes.
+
+```python
+from python_wrap import TimingRecord, timing
+
+records: list[TimingRecord] = []
+
+@timing(logger="demo", threshold=0.5, callback=records.append)
+def fast() -> int:
+ return 1
+
+fast()
+assert records[0].success is True
+assert records[0].name.endswith("fast")
+assert records[0].elapsed < 0.5
+```
+
+## When to use it
+
+- `timing` to log or collect the duration of every call in production.
+- `benchmark` to compare implementations in a script or test.
+- `measure_memory` and `profile` to investigate one slow or hungry function.
+- For counts, error rates and percentiles over time, use [`count_calls`](metrics.md#count_calls).
+
+## `timing`
+
+Measure and log how long each call takes. Async functions are timed across the whole awaited coroutine. A call that raises is logged as ` raised ValueError after 12.34 ms` and the exception propagates.
+
+=== "Sync"
+
+ ```python
+ from python_wrap import TimingRecord, timing
+
+ records: list[TimingRecord] = []
+
+ @timing(callback=records.append)
+ def work() -> int:
+ return 1
+
+ work()
+ assert records[0].success is True
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import TimingRecord, timing
+
+ records: list[TimingRecord] = []
+
+ @timing(callback=records.append)
+ async def pause() -> None:
+ await asyncio.sleep(0.02)
+
+ asyncio.run(pause())
+ assert records[0].elapsed >= 0.015 # the awaited time is measured
+ ```
+
+| Option | Meaning |
+| --- | --- |
+| `threshold` | Only log successful calls slower than this many seconds. Failed calls are always logged. |
+| `callback` | Called with a `TimingRecord(name, elapsed, success)` for **every** call, regardless of `threshold`. Use it to feed a metrics system. Errors in the callback are logged and swallowed. |
+| `logger`, `level` | Where it logs; `INFO` by default. See [Configuring logging](logging.md#configuring-logging). |
+
+`timing` uses `time.perf_counter`.
+
+## `benchmark`
+
+Run a function repeatedly and get statistics. It is a **plain function, not a decorator** (see [Migration](../../project/migration.md)).
+
+=== "Sync"
+
+ ```python
+ from python_wrap import benchmark
+
+ result = benchmark(sorted, args=([3, 1, 2],), runs=10, warmup=2)
+ assert result.runs == 10
+ assert result.min <= result.median <= result.max
+ print(result) # sorted: 10 runs, mean 1.2 us, median 1.1 us, ...
+ ```
+
+=== "Async"
+
+ ```python
+ import asyncio
+ from python_wrap import benchmark_async
+
+ async def work(n: int) -> int:
+ await asyncio.sleep(0)
+ return n
+
+ async def main() -> None:
+ result = await benchmark_async(work, args=(1,), runs=3)
+ assert result.runs == 3
+
+ asyncio.run(main())
+ ```
+
+`BenchmarkResult` has `name`, `runs`, `timings`, `mean`, `median`, `min`, `max` and `stdev`, all in seconds. `benchmark` raises `TypeError` for an `async def` function (use `benchmark_async`).
+
+## `measure_memory`
+
+Measure the Python memory a call allocates, using `tracemalloc`. Works on every OS and logs ` peak memory 1.00 MiB (net +12.00 KiB)`.
+
+```python
+from python_wrap import MemoryRecord, measure_memory
+
+records: list[MemoryRecord] = []
+
+@measure_memory(callback=records.append)
+def build() -> list[int]:
+ return [0] * 100_000
+
+data = build()
+assert records[0].peak_bytes >= 100_000 * 8 * 0.5 # at least roughly the list's size
+assert records[0].net_bytes > 0 # the list is still alive
+```
+
+`peak_bytes` is the highest traced allocation during the call, relative to its start; `net_bytes` is the memory still held at the end minus at the start (it can be negative).
+
+!!! note
+ `tracemalloc` is process-global. Allocations by other threads or tasks while the call runs are counted too, and tracing slows the program. See [Limitations](../../project/limitations.md).
+
+## `profile`
+
+Profile each call with `cProfile` and log the top rows.
+
+```python
+import pstats
+from python_wrap import profile
+
+captured: list[pstats.Stats] = []
+
+@profile(sort="tottime", limit=5, callback=captured.append)
+def work() -> int:
+ return sum(i * i for i in range(1000))
+
+work()
+assert isinstance(captured[0], pstats.Stats)
+```
+
+`sort` is any `pstats.SortKey` value (`"cumulative"` by default, or `"tottime"`, `"calls"`, ...); `limit` is the number of rows logged (`None` for all). If another profiler is already active, a warning is logged once and the function runs unprofiled.
+
+## See also
+
+- [Metrics and events](metrics.md) for in-process counts and percentiles
+- [Logging](logging.md)
+- [API reference](../../api/timing.md)
From bc64ec07e5df82d2379b84abc5b06cc9985d5d6c Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:25:15 +0300
Subject: [PATCH 07/10] Docs: add get-started section and testing page
---
docs/get-started/async-and-methods.md | 93 ++++++
docs/get-started/choosing.md | 155 ++++++++++
docs/get-started/errors.md | 123 ++++++++
docs/get-started/index.md | 49 ++++
docs/get-started/installation.md | 81 +++++
docs/get-started/quickstart.md | 311 ++++++++++++++++++++
docs/get-started/stacking.md | 171 +++++++++++
docs/testing/index.md | 408 ++++++++++++++++++++++++++
8 files changed, 1391 insertions(+)
create mode 100644 docs/get-started/async-and-methods.md
create mode 100644 docs/get-started/choosing.md
create mode 100644 docs/get-started/errors.md
create mode 100644 docs/get-started/index.md
create mode 100644 docs/get-started/installation.md
create mode 100644 docs/get-started/quickstart.md
create mode 100644 docs/get-started/stacking.md
create mode 100644 docs/testing/index.md
diff --git a/docs/get-started/async-and-methods.md b/docs/get-started/async-and-methods.md
new file mode 100644
index 0000000..7d7679e
--- /dev/null
+++ b/docs/get-started/async-and-methods.md
@@ -0,0 +1,93 @@
+# Async and methods
+
+Every decorator in python-wrap works on `async def` functions. You use it the same way; the wrapper behaves correctly for coroutines.
+
+## What you get
+
+When you decorate an `async def` function, python-wrap returns an `async def` wrapper. That means:
+
+- `inspect.iscoroutinefunction(wrapped)` stays `True`, so frameworks that check it (FastAPI, Starlette, pytest-asyncio, `asyncio.to_thread` guards) treat it correctly.
+- The decorator awaits your function. It does not return a coroutine to a sync wrapper that hides it.
+- Waiting uses `asyncio.sleep`, so the event loop is never blocked.
+
+```python
+import asyncio
+import inspect
+from python_wrap import (
+ circuit_breaker, fallback, memoize, once, rate_limit, retry, singleflight,
+ throttle, timeout, timing,
+)
+
+@retry(max_attempts=2, delay=0.01)
+@timeout(1)
+@timing
+async def fetch() -> str:
+ return "ok"
+
+assert inspect.iscoroutinefunction(fetch)
+assert asyncio.run(fetch()) == "ok"
+
+for decorator in (
+ retry, fallback(0), memoize, once, singleflight, timeout(1), throttle(1),
+ rate_limit(1, 1), circuit_breaker, timing,
+):
+ async def probe() -> None: ...
+ assert inspect.iscoroutinefunction(decorator(probe)), decorator
+```
+
+Methods work too, including `async def` methods; `self` is just the first argument. Read [Methods](#methods) below before decorating methods with limiters or caches.
+
+## Per-decorator behaviour
+
+| Decorator | What is different for `async def` |
+| --- | --- |
+| `retry` | Awaits the function and sleeps with `asyncio.sleep` between attempts. |
+| `fallback`, `suppress` | Catch exceptions from the awaited call. The `fallback` handler is still a plain (sync) function. |
+| `circuit_breaker`, `CircuitBreaker` | Counts failures from the awaited call. `breaker.call(async_func, ...)` returns a coroutine to await. State changes are lock-protected but never hold the lock across an `await`. |
+| `timeout` | Cancels the coroutine with `asyncio.wait_for`, which actually stops the work. |
+| `memoize`, `cache` | Caches the **awaited** result. Concurrent awaits of the same missing key share one in-flight task, so the function runs once. Cancelling one caller does not cancel the shared computation. |
+| `once` | Concurrent first awaits share one task. A first call that raises can be retried. |
+| `singleflight` | De-duplicates across tasks on the same event loop, using a shared task. |
+| `rate_limit` | With `block=True` waits with `await`; otherwise raises `RateLimitExceeded`. |
+| `throttle` | Same semantics with an `async` wrapper. |
+| `debounce` | Schedules a task on the running loop. Every caller in a burst awaits the same outcome (the last call's result). Tracked per event loop. |
+| `run_in_thread` | The coroutine runs with `asyncio.run` in a new thread (its own loop). The wrapper is a plain function returning a `Future`. |
+| `timing`, `measure_memory`, `profile` | Cover the whole awaited coroutine. (`profile` includes event-loop work for other tasks; see [Limitations](../project/limitations.md#measure_memory-and-profile).) |
+| `benchmark` / `benchmark_async` | Use `benchmark_async` for coroutine functions; `benchmark` raises `TypeError`. |
+| `log_args`, `log_return`, `audit`, `trace` | Log around the awaited call. |
+| `validate_args`, `check_type`, `enforce_types` | Validate at call time; `enforce_types` checks the awaited result against the return annotation. |
+| `revert_on_failure`, `transactional` | Work on `async def`. `Transaction` supports `async with`, and async callbacks are awaited. |
+| `deprecate` | Warns on every await. |
+| `mock_data` | Works on `async def`; the awaited result is the data. |
+
+## Calling async code from sync code
+
+Use `asyncio.run` from a plain script or thread, as the examples in these docs do:
+
+```python
+import asyncio
+from python_wrap import retry
+
+@retry(max_attempts=2, delay=0.01)
+async def job() -> int:
+ return 1
+
+assert asyncio.run(job()) == 1
+```
+
+To run a coroutine function in the background from sync code, use [`run_in_thread`](../features/concurrency/run-in-thread.md). It gives the coroutine its own event loop in a new thread and hands you a `Future`.
+
+## Methods
+
+Decorators work on instance methods; `self` is an ordinary first argument. Two things differ from plain functions:
+
+- **`rate_limit`, `throttle` and `debounce` keep their state on the decorated function**, so every instance shares one limiter, one throttle window and one pending call. Split the state with `key=` (for `rate_limit`) or build the decorated callable per instance in `__init__`.
+- **`memoize` includes `self` in the cache key**, so results are per instance, but the cache holds a strong reference to every instance it has seen. Set `maxsize` or `ttl` to let them go.
+
+Both are explained, with workarounds, in [Limitations](../project/limitations.md#shared-state-on-methods). To apply one decorator to every method of a class, see [`wrap_methods`](../features/devtools.md).
+
+## Gotchas
+
+- **One event loop per `debounce` and `singleflight` burst.** Their state is tracked per loop; do not share a debounced coroutine across loops and expect them to coordinate.
+- **`rate_limit(block=False)` raises inside the coroutine**, at the `await`, not when you call the function.
+- **Sync decorators on an `async def` function you wrote yourself** (a `functools.wraps` wrapper that is not itself `async def`) hide the coroutine from python-wrap's detection. Stack python-wrap decorators directly on the `async def`, or make your wrapper `async def`.
diff --git a/docs/get-started/choosing.md b/docs/get-started/choosing.md
new file mode 100644
index 0000000..5d13b66
--- /dev/null
+++ b/docs/get-started/choosing.md
@@ -0,0 +1,155 @@
+# Choosing a decorator
+
+Find your problem, use the decorator in the second column.
+
+## Things fail
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| A network call fails now and then | `@retry(on=ConnectionError, delay=..., backoff=2)` | [Resilience](../features/resilience/retry.md) |
+| I want a default value instead of an exception | `@fallback(value)` or `@fallback(handler=fn)` | [Resilience](../features/resilience/fallback.md) |
+| I just want an error to go away (returns `None`) | `@suppress(KeyError)` | [Resilience](../features/resilience/fallback.md) |
+| A dependency is down and I keep hammering it | `@circuit_breaker(...)` or a shared `CircuitBreaker` | [Resilience](../features/resilience/circuit-breaker.md) |
+| A call can hang forever | `@timeout(seconds)` or `with timeout(seconds):` | [Resilience](../features/resilience/timeout.md) |
+| The server says `Retry-After` | `@retry(delay_from=retry_after_header)` | [Resilience](../features/resilience/retry.md) |
+| Retry only part of a function | `for attempt in retrying(...)` | [Resilience](../features/resilience/retry.md) |
+| A whole request needs one time budget | `with deadline(seconds):` | [Resilience](../features/resilience/timeout.md) |
+| A read is occasionally very slow | `@hedge(delay)` (idempotent calls only) | [Batching and hedging](../features/resilience/hedge.md) |
+| Apply retry, breaker and timeout in the right order | `policy(retry=..., breaker=..., timeout=...)` | [Policies](../features/resilience/policies.md) |
+
+## Things are slow or repeated
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| Same arguments, same answer, compute it once | `@memoize` | [Caching](../features/caching/memoize.md) |
+| Cached values should go stale after N seconds | `@memoize(ttl=...)` | [Caching](../features/caching/memoize.md) |
+| Expensive one-time setup | `@once` | [Caching](../features/caching/once.md) |
+| 100 concurrent requests for the same thing | `@singleflight` | [Concurrency](../features/caching/singleflight.md) |
+| An expensive attribute on an object | `@cached_property(ttl=...)` | [Caching](../features/caching/cached-property.md) |
+| Run a blocking function in the background | `@run_in_thread` | [Concurrency](../features/concurrency/run-in-thread.md) |
+
+## Too many things at once
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| `gather` thousands of coroutines without flooding a server | `await gather_limit(*coros, limit=20)` | [Async toolkit](../features/concurrency/async-helpers.md) |
+| Apply an async function to a list, a few at a time | `await map_async(fn, items, limit=20)` | [Async toolkit](../features/concurrency/async-helpers.md) |
+| Take whichever of several calls answers first | `await race(a(), b())` | [Async toolkit](../features/concurrency/async-helpers.md) |
+| Call async code from a script | `run_sync(coro)` | [Async toolkit](../features/concurrency/async-helpers.md) |
+| Protect a dependency from overload; fail fast when full | `Bulkhead(n, max_waiting=...)` or `@limit_concurrency(n)` | [Async toolkit](../features/concurrency/bulkheads.md) |
+| Many single lookups should become one bulk query | `@batch_calls(max_size=..., max_wait=...)` | [Batching and hedging](../features/caching/batching.md) |
+
+## Things happen too often
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| Reject calls over N per second | `@rate_limit(n, period)` | [Concurrency](../features/concurrency/rate-limits.md) |
+| Slow callers down instead of rejecting them | `@rate_limit(n, period, block=True)` | [Concurrency](../features/concurrency/rate-limits.md) |
+| One limit per user or API key | `@rate_limit(..., key=...)` | [Concurrency](../features/concurrency/rate-limits.md) |
+| Several functions share one API quota | `RateLimiter(n, period)` | [Concurrency](../features/concurrency/rate-limits.md) |
+| Run at most once per interval, skip the rest | `@throttle(interval)` | [Concurrency](../features/concurrency/throttle-debounce.md) |
+| Wait for a burst of calls to settle, then run once | `@debounce(wait)` | [Concurrency](../features/concurrency/throttle-debounce.md) |
+
+## I need to see what is happening
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| How long does this take? | `@timing` | [Observability](../features/observability/timing.md) |
+| Send durations to my metrics system | `@timing(callback=...)` | [Observability](../features/observability/timing.md) |
+| Compare how fast a function is over many runs | `benchmark(func, ...)` | [Observability](../features/observability/timing.md) |
+| How much memory does it allocate? | `@measure_memory` | [Observability](../features/observability/timing.md) |
+| Where does the time go? | `@profile` | [Observability](../features/observability/timing.md) |
+| Log the arguments (without leaking passwords) | `@log_args` | [Observability](../features/observability/logging.md) |
+| Log what a function returns | `@log_return` | [Observability](../features/observability/logging.md) |
+| Keep an audit trail of who called what | `@audit` | [Observability](../features/observability/audit.md) |
+| Log the call stack that led here | `@trace` | [Observability](../features/observability/logging.md) |
+| Count calls, errors and p95 latency per function | `@count_calls` then `func.stats` | [Metrics and events](../features/observability/metrics.md) |
+| Feed retries, cache hits and breaker trips to Prometheus or OpenTelemetry | `subscribe(listener)` | [Metrics and events](../features/observability/metrics.md) |
+| Put a request id on every log line | `with log_context(request_id=...):` | [Observability](../features/observability/logging.md) |
+
+## Bad input
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| Check arguments with my own rules | `@validate_args(x=lambda x: x > 0)` | [Validation](../features/correctness/validation.md) |
+| `isinstance` checks on a few arguments | `@check_type(x=int)` | [Validation](../features/correctness/validation.md) |
+| Enforce my type annotations at runtime | `@enforce_types` | [Validation](../features/correctness/validation.md) |
+
+## Half-finished work
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| Undo changes to `self` if a method raises | `@revert_on_failure` | [State safety](../features/correctness/state.md) |
+| Register undo steps and run them if anything fails | `@transactional` / `Transaction` | [State safety](../features/correctness/state.md) |
+
+## Maintaining and testing code
+
+| Problem | Use | Guide |
+| --- | --- | --- |
+| Tests that wait on retries and backoff | `with fast_mode():` from `python_wrap.testing` | [Testing](../testing/index.md) |
+| Switch retries, rate limits or caches off in a test | `disable_retries()`, `disable_rate_limits()`, `disable_caching()` | [Testing](../testing/index.md) |
+| Control time in a test | `FakeClock` / `fake_clock()` | [Testing](../testing/index.md) |
+| Set the same logger, retry defaults or redaction everywhere | `configure(...)` | [Configuration](../features/configuration.md) |
+| Protect every method of a class | `@wrap_methods(...)` | [Developer tools](../features/devtools.md) |
+| Warn callers that an API is going away | `@deprecate(since=..., alternative=...)` | [Developer tools](../features/devtools.md) |
+| Return fixed data in tests or local runs | `@mock_data(...)` | [Developer tools](../features/devtools.md) |
+| Reuse a stack of decorators | `compose(a, b, c)` | [Developer tools](../features/devtools.md) |
+
+## Common recipes
+
+??? example "Robust HTTP call"
+
+ ```python
+ from python_wrap import circuit_breaker, retry, timeout
+
+ @circuit_breaker(failure_threshold=5, recovery_timeout=30)
+ @retry(max_attempts=3, delay=0.2, backoff=2, jitter=0.1, on=(ConnectionError, TimeoutError))
+ @timeout(5)
+ def call_api() -> dict:
+ ...
+ ```
+
+ Each attempt gets its own 5 second deadline, and the breaker counts a call as failed only once its retries are used up. See [Stacking decorators](stacking.md).
+
+??? example "Cached, de-duplicated lookup"
+
+ ```python
+ from python_wrap import memoize, singleflight
+
+ @memoize(ttl=30)
+ @singleflight
+ def load_price(sku: str) -> float:
+ ...
+ ```
+
+??? example "Public API with input checks"
+
+ ```python
+ from python_wrap import enforce_types, log_args, validate_args
+
+ @log_args
+ @enforce_types
+ @validate_args(amount=(lambda a: a > 0, "amount must be positive"))
+ def charge(account: str, amount: float) -> None:
+ ...
+ ```
+
+??? example "The same thing as one named policy"
+
+ ```python
+ from python_wrap import CircuitBreaker, policy, retry, retry_after_header, timeout
+
+ api = policy(
+ breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30),
+ retry=retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError,
+ delay_from=retry_after_header),
+ timeout=timeout(5),
+ name="api",
+ )
+
+ @api
+ def call_api() -> dict:
+ ...
+ ```
+
+ The roles decide the order, so listing them differently changes nothing. See [Policies](../features/resilience/policies.md).
diff --git a/docs/get-started/errors.md b/docs/get-started/errors.md
new file mode 100644
index 0000000..949e685
--- /dev/null
+++ b/docs/get-started/errors.md
@@ -0,0 +1,123 @@
+# Errors
+
+Every exception python-wrap raises on its own behalf derives from one base class, `PythonWrapError`. Where a built-in exception fits, the class also subclasses it, so code written with `except TimeoutError` or `except ValueError` keeps working.
+
+All of them are importable from `python_wrap`.
+
+## The hierarchy
+
+```text
+Exception
+└── PythonWrapError
+ ├── TimeoutExpired (also TimeoutError)
+ ├── RateLimitExceeded
+ ├── CircuitOpenError
+ ├── BulkheadFull
+ ├── ValidationError (also ValueError)
+ ├── ArgumentTypeError (also TypeError)
+ └── BatchError
+```
+
+| Exception | Also a | Attributes | Raised when |
+| --- | --- | --- | --- |
+| `TimeoutExpired` | `TimeoutError` | `seconds`: the limit that was exceeded | A call or block ran longer than its limit |
+| `RateLimitExceeded` | none | `retry_after`: seconds until a call would be allowed | A rate limit is used up and the call is not allowed to wait |
+| `CircuitOpenError` | none | `retry_after`: seconds until the breaker tries again | The breaker is open, so the call was not attempted |
+| `BulkheadFull` | none | `name`: the bulkhead's name or `None`; `max_concurrent`: its capacity | The wait queue is full, or waiting for a slot timed out |
+| `ValidationError` | `ValueError` | `parameter`: the argument name; `value`: the rejected value | An argument failed a validator |
+| `ArgumentTypeError` | `TypeError` | `parameter`, `expected`, `value` | An argument does not match its expected type |
+| `BatchError` | none | none | A batch function broke its output contract for one caller's item |
+
+## Which decorator raises which
+
+| Exception | Raised by |
+| --- | --- |
+| `TimeoutExpired` | [`timeout`](../features/resilience/timeout.md) (decorator and context manager), [`deadline`](../features/resilience/timeout.md) when `check()` finds the budget spent or a `timeout` starts with none left, and [`race`](../features/concurrency/async-helpers.md) with `timeout=` when nothing wins in time |
+| `RateLimitExceeded` | [`rate_limit`](../features/concurrency/rate-limits.md) and `RateLimiter` when `block=False` (the default) |
+| `CircuitOpenError` | [`circuit_breaker`](../features/resilience/circuit-breaker.md) and `CircuitBreaker` while open (and while half-open with no probe slot left) |
+| `BulkheadFull` | [`Bulkhead`](../features/concurrency/bulkheads.md) and `limit_concurrency` |
+| `ValidationError` | [`validate_args`](../features/correctness/validation.md) |
+| `ArgumentTypeError` | [`check_type`](../features/correctness/validation.md) and [`enforce_types`](../features/correctness/validation.md) |
+| `BatchError` | [`batch_calls`](../features/caching/batching.md), when the batch function returns the wrong number of results, a mapping without this item's key, or something that is neither a sequence nor a mapping |
+
+`retry` has no exception of its own. When attempts run out it re-raises the last error from your function, so callers handle the same exception types with or without retries.
+
+## Catching them
+
+Catch the specific class when you can act on it, and `PythonWrapError` to handle "python-wrap refused or cut off this call" in one place.
+
+```python
+from python_wrap import (
+ CircuitBreaker, CircuitOpenError, PythonWrapError, RateLimitExceeded,
+ rate_limit,
+)
+
+breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=30)
+
+@breaker
+def call_api() -> str:
+ raise ConnectionError("down")
+
+@rate_limit(1, 60)
+def search(q: str) -> str:
+ return q
+
+try:
+ call_api()
+except ConnectionError:
+ pass # the first failure opens the circuit
+
+try:
+ call_api()
+except CircuitOpenError as exc:
+ assert 0 < exc.retry_after <= 30 # tell the caller when to come back
+
+search("a")
+try:
+ search("b")
+except RateLimitExceeded as exc:
+ assert exc.retry_after > 0 # for a Retry-After header, for example
+
+try:
+ call_api()
+except PythonWrapError as exc: # one handler for every kind of refusal
+ print(type(exc).__name__)
+```
+
+Because `TimeoutExpired` is a `TimeoutError`, and `ValidationError` a `ValueError`, built-in handlers still match:
+
+```python
+import time
+from python_wrap import PythonWrapError, TimeoutExpired, timeout, validate_args
+
+@timeout(0.01)
+def slow() -> None:
+ time.sleep(0.5)
+
+try:
+ slow()
+except TimeoutError as exc: # also matches TimeoutExpired
+ assert isinstance(exc, TimeoutExpired) and exc.seconds == 0.01
+
+@validate_args(n=lambda n: n > 0)
+def sqrt_of(n: int) -> float:
+ return n ** 0.5
+
+try:
+ sqrt_of(-1)
+except ValueError as exc: # also matches ValidationError
+ assert isinstance(exc, PythonWrapError)
+ assert exc.parameter == "n" and exc.value == -1
+```
+
+!!! warning "Do not let `retry` swallow these"
+ All of these are ordinary exceptions. A `retry` left at its default `on=Exception` retries `CircuitOpenError`, `TimeoutExpired` and `ValidationError` too, which rarely helps. Name the transient errors you want retried (`on=ConnectionError`). See [Stacking](stacking.md#rules-of-thumb).
+
+!!! note "Catching `TimeoutError` also catches other timeouts"
+ Built-in and library timeouts (`socket.timeout`, `asyncio.TimeoutError` on Python 3.11+) are `TimeoutError` as well. Catch `TimeoutExpired` when you mean only python-wrap's.
+
+## See also
+
+- [Choosing a decorator](choosing.md)
+- [Stacking decorators](stacking.md)
+- [API reference](../api/errors.md)
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
new file mode 100644
index 0000000..1045546
--- /dev/null
+++ b/docs/get-started/index.md
@@ -0,0 +1,49 @@
+# Get started
+
+python-wrap is a set of decorators for the problems every project meets: calls that fail, work that repeats, load that needs limiting, and code you cannot see into. This section takes you from install to a working, tested resilient function. Read the pages in this order.
+
+
+
+- :material-download-outline:{ .lg .middle } __[Installation](installation.md)__
+
+ ---
+
+ pip, uv or poetry. Python 3.10+, no dependencies, typed.
+
+- :material-rocket-launch-outline:{ .lg .middle } __[Quickstart](quickstart.md)__
+
+ ---
+
+ A ten-minute tutorial: make a flaky client resilient, cached, observable and fast to test.
+
+- :material-compass-outline:{ .lg .middle } __[Choosing a decorator](choosing.md)__
+
+ ---
+
+ A table by problem: what to reach for, and what to combine it with.
+
+- :material-layers-triple-outline:{ .lg .middle } __[Stacking decorators](stacking.md)__
+
+ ---
+
+ Order changes behaviour. The rules, and a recommended order.
+
+- :material-lightning-bolt:{ .lg .middle } __[Async and methods](async-and-methods.md)__
+
+ ---
+
+ How every decorator behaves on `async def` functions and on methods.
+
+- :material-alert-octagon-outline:{ .lg .middle } __[Errors](errors.md)__
+
+ ---
+
+ The exceptions python-wrap raises, what each subclasses, and which decorator raises which.
+
+
+
+## After this
+
+- Browse the [features](../features/index.md) one decorator per page.
+- Make your tests instant with the [testing helpers](../testing/index.md).
+- Copy a complete setup from the [cookbook](../cookbook/index.md).
diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md
new file mode 100644
index 0000000..f71ba3f
--- /dev/null
+++ b/docs/get-started/installation.md
@@ -0,0 +1,81 @@
+# Installation
+
+python-wrap needs Python 3.10 or newer and has no runtime dependencies. It runs on Linux, macOS and Windows.
+
+## Install
+
+=== "pip"
+
+ ```bash
+ pip install python-wrap
+ ```
+
+=== "uv"
+
+ ```bash
+ uv add python-wrap
+ ```
+
+=== "poetry"
+
+ ```bash
+ poetry add python-wrap
+ ```
+
+The package name on PyPI is `python-wrap`; the import name uses an underscore, `python_wrap`.
+
+!!! note "Zero dependencies"
+ `dependencies = []` is deliberate. Adding python-wrap to an application, or to a library you publish, pulls in nothing else. The testing helpers in `python_wrap.testing` use only the standard library too; pytest is optional.
+
+## Verify the install
+
+Run this in the Python you installed into. It uses a decorator, so it checks the import and that the package works:
+
+```python
+import python_wrap
+from python_wrap import retry
+
+attempts = []
+
+@retry(max_attempts=3, delay=0)
+def flaky() -> str:
+ attempts.append(1)
+ if len(attempts) < 3:
+ raise ConnectionError("try again")
+ return "ok"
+
+assert flaky() == "ok"
+print("python-wrap", python_wrap.__version__, "works")
+```
+
+From a shell, `pip show python-wrap` (or `uv pip show python-wrap`) prints the installed version.
+
+## Type checkers
+
+python-wrap ships a `py.typed` marker and is checked with `mypy --strict`. Decorators preserve the wrapped function's signature with `ParamSpec`, so mypy, pyright and your editor still know what a decorated function takes and returns. There is nothing to configure.
+
+```python
+from python_wrap import memoize
+
+@memoize(ttl=60)
+def square(n: int) -> int:
+ return n * n
+
+result = square(4) # a type checker infers int
+assert result == 16
+assert square.cache_info().hits == 0 # and knows about cache_info()
+```
+
+Decorators that add attributes to the function (for example `cache_info()`, `breaker`, `stats`) return a typed wrapper that declares them, so `square.cache_info()` type-checks.
+
+## Logging
+
+Importing python-wrap never configures logging. Decorators log to the `python_wrap` logger, which has a `NullHandler`, so you see nothing until you opt in. See [Logging](../features/observability/logging.md).
+
+## Upgrading from 0.1
+
+Version 0.2 renames and restructures some APIs. The [migration guide](../project/migration.md) lists every change with before and after code.
+
+## Next
+
+Build something with it in the [Quickstart](quickstart.md), or find the decorator for your problem in [Choosing a decorator](choosing.md).
diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md
new file mode 100644
index 0000000..707d268
--- /dev/null
+++ b/docs/get-started/quickstart.md
@@ -0,0 +1,311 @@
+# Quickstart
+
+In about ten minutes you will take one fragile function and make it resilient, cached, observable and fast to test. Every block on this page runs as written, so you can paste it into a file and try it.
+
+The function is a small weather client. To keep the examples self-contained there is no real network: a fake transport fails in the ways a real one does (connection errors, hangs, outages), and each step only changes the decorators on top.
+
+## 1. Start with a plain, flaky function
+
+Here is the starting point. The fake transport fails twice with `ConnectionError` and then answers:
+
+```python
+calls = {"n": 0}
+
+def transport(city: str) -> dict:
+ """Stands in for an HTTP call: fails twice, then works."""
+ calls["n"] += 1
+ if calls["n"] <= 2:
+ raise ConnectionError("connection reset")
+ return {"city": city, "temp_c": 21}
+
+def get_weather(city: str) -> dict:
+ return transport(city)
+
+try:
+ get_weather("Cairo")
+except ConnectionError as exc:
+ print("failed:", exc)
+```
+
+One bad packet and the caller gets an exception. Most of those failures are transient, so the right response is to try again.
+
+## 2. Add `retry`
+
+[`retry`](../features/resilience/retry.md) calls the function again when it raises. Name the errors that are worth retrying with `on=`; anything else still fails at once.
+
+```python
+from python_wrap import retry
+
+calls = {"n": 0}
+
+def transport(city: str) -> dict:
+ calls["n"] += 1
+ if calls["n"] <= 2:
+ raise ConnectionError("connection reset")
+ return {"city": city, "temp_c": 21}
+
+@retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError)
+def get_weather(city: str) -> dict:
+ return transport(city)
+
+assert get_weather("Cairo") == {"city": "Cairo", "temp_c": 21}
+assert calls["n"] == 3 # two failures, then success
+```
+
+What changed: one line. `delay=0.01, backoff=2` waits 10 ms, then 20 ms, and so on between attempts (use seconds that suit your service in real code). The caller no longer sees the two transient failures.
+
+## 3. Add `timeout`
+
+A retry cannot help with a call that never returns. [`timeout`](../features/resilience/timeout.md) puts a limit on each attempt and raises `TimeoutExpired`, which is also a built-in `TimeoutError`.
+
+```python
+import time
+from python_wrap import TimeoutExpired, retry, timeout
+
+calls = {"n": 0}
+
+def transport(city: str) -> dict:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ time.sleep(0.5) # the first call hangs
+ return {"city": city, "temp_c": 21}
+
+@retry(max_attempts=3, delay=0.01, on=(ConnectionError, TimeoutExpired))
+@timeout(0.05)
+def get_weather(city: str) -> dict:
+ return transport(city)
+
+assert get_weather("Cairo")["temp_c"] == 21
+assert calls["n"] == 2 # the hung attempt was cut off and retried
+```
+
+What changed: `@timeout` sits below `@retry`, so each attempt gets its own deadline. Put it above `retry` and one deadline would cover all attempts instead. Order matters; [Stacking](stacking.md) explains the rules, and step 6 hands the ordering to `policy(...)`.
+
+!!! note "Timeouts and threads"
+ On Unix in the main thread, `timeout` uses a signal timer and interrupts the call. Elsewhere it runs the call in a worker thread and stops waiting for it, but cannot stop the work. See [Timeout](../features/resilience/timeout.md).
+
+## 4. Add `memoize(ttl=...)`
+
+Weather does not change every second, so asking twice for the same city is wasteful. [`memoize`](../features/caching/memoize.md) remembers results by arguments, and `ttl=` makes them expire.
+
+```python
+from python_wrap import memoize
+
+calls = {"n": 0}
+
+@memoize(ttl=60)
+def get_weather(city: str) -> dict:
+ calls["n"] += 1
+ return {"city": city, "temp_c": 21}
+
+get_weather("Cairo")
+get_weather("Cairo")
+get_weather("Oslo")
+
+assert calls["n"] == 2 # Cairo was computed once
+info = get_weather.cache_info()
+assert (info.hits, info.misses) == (1, 2)
+```
+
+What changed: repeated calls inside 60 seconds skip the network entirely. Exceptions are never cached, so a failed lookup is tried again next time.
+
+## 5. Protect the service with a `CircuitBreaker`
+
+If the weather service is down, retrying every request makes things worse for everyone. A [`CircuitBreaker`](../features/resilience/circuit-breaker.md) counts consecutive failures and, after a threshold, rejects calls immediately with `CircuitOpenError` until a recovery period has passed.
+
+```python
+from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState
+
+calls = {"n": 0}
+breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30, on=ConnectionError)
+
+@breaker
+def get_weather(city: str) -> dict:
+ calls["n"] += 1
+ raise ConnectionError("service down")
+
+for _ in range(3):
+ try:
+ get_weather("Cairo")
+ except ConnectionError:
+ pass
+
+assert breaker.state is CircuitState.OPEN
+
+try:
+ get_weather("Cairo")
+except CircuitOpenError as exc:
+ print(f"rejected, try again in {exc.retry_after:.0f}s")
+
+assert calls["n"] == 3 # the fourth call never reached the transport
+```
+
+What changed: after three failures the service is left alone for 30 seconds, and callers get an instant, explicit error that carries `retry_after`. Because the breaker is an object, you can share one between several functions that call the same service.
+
+## 6. Combine everything with `policy(...)`
+
+You now have four protections and a question: in what order? [`policy`](../features/resilience/policies.md) takes each one by role and applies them in the recommended order, whatever order you list them in.
+
+```python
+import time
+from python_wrap import (
+ CircuitBreaker, TimeoutExpired, memoize, policy, retry, timeout,
+)
+
+calls = {"n": 0}
+
+def transport(city: str) -> dict:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise ConnectionError("connection reset")
+ if calls["n"] == 2:
+ time.sleep(0.5)
+ return {"city": city, "temp_c": 21}
+
+weather_api = policy(
+ cache=memoize(ttl=60),
+ breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30,
+ on=(ConnectionError, TimeoutExpired)),
+ retry=retry(max_attempts=4, delay=0.01, backoff=2,
+ on=(ConnectionError, TimeoutExpired)),
+ timeout=timeout(0.05),
+ name="weather",
+)
+
+@weather_api
+def get_weather(city: str) -> dict:
+ return transport(city)
+
+assert get_weather("Cairo")["temp_c"] == 21 # survived a reset and a hang
+assert calls["n"] == 3
+assert get_weather("Cairo")["temp_c"] == 21 # served from the cache
+assert calls["n"] == 3
+```
+
+What changed: the decorator stack became one named, reusable object. Apply `weather_api` to every function that talks to this service and they share one configuration. The cache is outermost, then the breaker, then retry, then a deadline per attempt, which is the order from [Stacking](stacking.md#a-recommended-order).
+
+## 7. Observe it
+
+Resilience you cannot see is hard to trust. [`count_calls`](../features/observability/metrics.md) keeps call, error and latency statistics on the function, and [`subscribe`](../features/observability/metrics.md) delivers an event for every retry, cache hit and breaker change so you can forward them to your metrics system.
+
+```python
+from python_wrap import count_calls, memoize, retry, subscribe
+
+seen = []
+unsubscribe = subscribe(lambda event: seen.append(event.name))
+
+calls = {"n": 0}
+
+@count_calls
+@memoize(ttl=60)
+@retry(max_attempts=3, delay=0.01, on=ConnectionError)
+def get_weather(city: str) -> dict:
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise ConnectionError("connection reset")
+ return {"city": city, "temp_c": 21}
+
+get_weather("Cairo")
+get_weather("Cairo")
+unsubscribe()
+
+stats = get_weather.stats
+assert stats.calls == 2 and stats.errors == 0
+assert "retry.attempt_failed" in seen
+assert "cache.hit" in seen
+print(seen)
+```
+
+What changed: `@count_calls` outermost counts what the caller experienced (two calls, no errors, even though one attempt failed underneath). The event list shows what happened inside: a failed attempt, a retry that succeeded, a cache miss and then a cache hit. Every event name is listed in [Metrics and events](../features/observability/metrics.md).
+
+## 8. Test it instantly
+
+Retries with real delays make tests slow. [`fast_mode`](../testing/index.md) makes every wait inside python-wrap instant while retries still run, so a test that would take minutes finishes immediately.
+
+```python
+import time
+from python_wrap import retry
+from python_wrap.testing import fast_mode
+
+calls = {"n": 0}
+
+@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError)
+def get_weather(city: str) -> dict:
+ calls["n"] += 1
+ if calls["n"] < 4:
+ raise ConnectionError("connection reset")
+ return {"city": city, "temp_c": 21}
+
+def test_recovers_after_three_failures() -> None:
+ with fast_mode():
+ assert get_weather("Cairo")["temp_c"] == 21
+ assert calls["n"] == 4
+
+start = time.monotonic()
+test_recovers_after_three_failures() # would sleep 30 + 60 + 120 seconds
+assert time.monotonic() - start < 1
+```
+
+What changed: nothing in the production code. The delays are real in production and instant in the test. With pytest you can enable the `pw_fast_mode` and `pw_fake_clock` fixtures instead; see [Testing](../testing/index.md).
+
+## What you built
+
+In production code the whole client is one decorator on one function:
+
+```python
+from python_wrap import CircuitBreaker, memoize, policy, retry, timeout
+
+weather_api = policy(
+ cache=memoize(ttl=60),
+ breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30),
+ retry=retry(max_attempts=3, delay=0.5, backoff=2, on=(ConnectionError, TimeoutError)),
+ timeout=timeout(5),
+ name="weather",
+)
+
+@weather_api
+def get_weather(city: str) -> dict:
+ ...
+```
+
+## Next steps
+
+
+
+- :material-shield-refresh-outline:{ .lg .middle } __[Resilience](../features/resilience/index.md)__
+
+ ---
+
+ Retry with `Retry-After`, circuit breakers, timeouts, fallbacks and hedged requests in depth.
+
+- :material-database-clock-outline:{ .lg .middle } __[Caching](../features/caching/index.md)__
+
+ ---
+
+ `memoize`, `once`, `cached_property`, batching and `singleflight`.
+
+- :material-speedometer:{ .lg .middle } __[Concurrency](../features/concurrency/index.md)__
+
+ ---
+
+ Rate limits, bulkheads, throttle and debounce, async helpers.
+
+- :material-chart-line:{ .lg .middle } __[Observability](../features/observability/index.md)__
+
+ ---
+
+ Logging, audit trails, timing and metrics for Prometheus and OpenTelemetry.
+
+- :material-chef-hat:{ .lg .middle } __[Cookbook](../cookbook/index.md)__
+
+ ---
+
+ A complete [resilient HTTP client](../cookbook/resilient-http-client.md), FastAPI, Django and more.
+
+- :material-alert-octagon-outline:{ .lg .middle } __[Errors](errors.md)__
+
+ ---
+
+ Every exception python-wrap raises, and what to catch.
+
+
diff --git a/docs/get-started/stacking.md b/docs/get-started/stacking.md
new file mode 100644
index 0000000..77c6625
--- /dev/null
+++ b/docs/get-started/stacking.md
@@ -0,0 +1,171 @@
+# Stacking decorators
+
+Decorators apply bottom to top: the one nearest the `def` wraps the function first, and the one at the top is the **outermost** layer, the first to see a call and the last to see the result.
+
+```python
+from python_wrap import retry, timeout
+
+@retry(max_attempts=3, delay=0.01) # outer: sees the final result of all attempts
+@timeout(5) # inner: bounds each single attempt
+def fetch() -> str:
+ return "ok"
+```
+
+Order changes behaviour, not just style. A good rule: **put a decorator outside whatever it should observe or protect as a whole, and inside whatever should happen once per attempt.**
+
+## Order matters: four examples
+
+### `timing` above or below `retry`
+
+`timing` above `retry` times the whole call, including every attempt and every wait. Below it, `timing` measures each attempt separately.
+
+```python
+from python_wrap import TimingRecord, retry, timing
+
+outer: list[TimingRecord] = []
+inner: list[TimingRecord] = []
+
+@timing(callback=outer.append) # one record for the whole call
+@retry(max_attempts=3, delay=0.01, on=ValueError)
+@timing(callback=inner.append) # one record per attempt
+def flaky() -> None:
+ raise ValueError
+
+try:
+ flaky()
+except ValueError:
+ pass
+
+assert len(outer) == 1 and outer[0].success is False
+assert len(inner) == 3
+assert outer[0].elapsed >= 0.02 # includes the two 10 ms waits
+```
+
+### `timeout` above or below `retry`
+
+`retry` above `timeout`: each attempt gets its own deadline. `timeout` above `retry`: one deadline covers all attempts and the waits between them.
+
+=== "Per attempt (retry above)"
+
+ ```python
+ import time
+ from python_wrap import TimeoutExpired, retry, timeout
+
+ attempts = []
+
+ @retry(max_attempts=3, delay=0.001, on=TimeoutExpired)
+ @timeout(0.01)
+ def slow() -> None:
+ attempts.append(1)
+ time.sleep(1)
+
+ try:
+ slow()
+ except TimeoutExpired:
+ pass
+
+ assert len(attempts) == 3 # three separate 10 ms deadlines
+ ```
+
+=== "Overall (timeout above)"
+
+ ```python
+ import time
+ from python_wrap import TimeoutExpired, retry, timeout
+
+ attempts = []
+
+ @timeout(0.03)
+ @retry(max_attempts=100, delay=0.01, on=ConnectionError)
+ def always_fails() -> None:
+ attempts.append(1)
+ raise ConnectionError
+
+ try:
+ always_fails()
+ except TimeoutExpired:
+ pass
+
+ assert 1 <= len(attempts) < 100 # cut off by the overall deadline
+ ```
+
+!!! warning "Narrow `on=` when an outer `timeout` wraps a `retry`"
+ `TimeoutExpired` is an ordinary exception. A `retry` left at its default `on=Exception` can catch the timeout from an outer `timeout` and keep going, so the overall deadline is swallowed. Name the transient errors you want retried (`on=ConnectionError`) and the timeout passes through.
+
+### `circuit_breaker` above or below `retry`
+
+Above `retry`, the breaker counts one failure per call whose retries were all used up. Below it, every single attempt counts, so one unlucky call can trip the breaker, and `retry` then sees `CircuitOpenError`.
+
+```python
+from python_wrap import CircuitOpenError, circuit_breaker, retry
+
+@circuit_breaker(failure_threshold=2, recovery_timeout=60, on=ConnectionError)
+@retry(max_attempts=3, delay=0, on=ConnectionError)
+def call() -> None:
+ raise ConnectionError
+
+for _ in range(2):
+ try:
+ call()
+ except ConnectionError:
+ pass
+
+assert call.breaker.failure_count == 2 # two calls, six attempts
+
+try:
+ call()
+except CircuitOpenError:
+ pass
+```
+
+Usually you want the breaker **above** `retry`: "this dependency has failed N calls in a row, after retrying".
+
+### `rate_limit` above or below `retry`
+
+Above `retry`: one count per logical call. Below it: every attempt counts against the limit, which is right when the limit is the remote service's quota and wrong when it protects your own callers.
+
+## A recommended order
+
+Outermost first. Skip the layers you do not need.
+
+| # | Decorator | Why here |
+| --- | --- | --- |
+| 1 | `deprecate`, `mock_data` | Warn or short-circuit before anything else runs. |
+| 2 | `audit`, `log_args`, `log_return`, `timing` | Observe the whole call, including retries and fallbacks. |
+| 3 | `validate_args`, `check_type`, `enforce_types` | Reject bad input immediately; do not retry or cache it. |
+| 4 | `fallback`, `suppress` | Catch whatever the resilience layers below could not fix. |
+| 5 | `memoize`, `once` | A cache hit skips everything below it. Exceptions are not cached. |
+| 6 | `singleflight` | Concurrent cache misses share one execution. |
+| 7 | `rate_limit`, `throttle`, `debounce` | Cap calls per logical request. |
+| 8 | `circuit_breaker` | Counts calls whose retries were exhausted. |
+| 9 | `retry` | Repeats everything below it. |
+| 10 | `timeout` | A deadline per attempt. |
+| 11 | `revert_on_failure`, `transactional` | Each attempt starts from clean state and undoes itself. |
+| 12 | `timing` (optional second one) | Per-attempt durations. |
+
+```python
+from python_wrap import (
+ circuit_breaker, enforce_types, fallback, memoize, retry, timeout, timing,
+)
+
+@timing(threshold=1.0)
+@enforce_types
+@fallback({}, on=ConnectionError)
+@memoize(ttl=30)
+@circuit_breaker(failure_threshold=5, recovery_timeout=30, on=ConnectionError)
+@retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError)
+@timeout(5)
+def load_profile(user_id: int) -> dict:
+ ...
+```
+
+Put the same stack on many functions with [`compose`](../features/devtools.md).
+
+## Rules of thumb
+
+- **`retry(on=...)` should name transient errors.** With the default `on=Exception` it also retries `ValidationError`, `CircuitOpenError` and `TimeoutExpired`, which rarely helps.
+- **Observers go outside, guards go inside.** An `audit` or `timing` outside the retry loop records the outcome the caller experienced.
+- **Caches go outside the things that are expensive to repeat.** `memoize` above `retry` caches the final success and skips the retry loop on a hit.
+- **`singleflight` goes below `memoize`.** Cache first; only a miss needs de-duplicating.
+- **Decorators that hold state belong to the function.** Stacking never merges state, but see [shared state on methods](../project/limitations.md#shared-state-on-methods).
+- **Async and sync stack the same way.** Every decorator preserves whether the function is `async def`, so the order rules are identical. See [Async and methods](async-and-methods.md).
diff --git a/docs/testing/index.md b/docs/testing/index.md
new file mode 100644
index 0000000..c18a7b9
--- /dev/null
+++ b/docs/testing/index.md
@@ -0,0 +1,408 @@
+# Testing code that uses these decorators
+
+Decorators that wait or remember make tests slow or order-dependent. python-wrap is built so you can fix both without touching production code.
+
+python-wrap ships its own testing helpers in `python_wrap.testing` (standard library only; pytest is optional). They cover the common jobs: skip the waiting, cap retries, switch off rate limits and caches, control time, observe calls and wait for background work.
+
+For a worked example of testing a whole service built on these decorators, see the cookbook recipe [Testing your code](../cookbook/testing-your-code.md).
+
+## Fast mode: instant waits, retries still run
+
+`fast_mode()` makes every wait inside python-wrap instant (`no_sleep`). Retries still run the configured number of times and limiters still count; nothing actually sleeps. Use it as a context manager or a decorator on sync and async tests:
+
+```python
+import time
+from python_wrap import retry
+from python_wrap.testing import fast_mode
+
+calls = []
+
+@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError)
+def flaky() -> str:
+ calls.append(1)
+ if len(calls) < 4:
+ raise ConnectionError
+ return "ok"
+
+start = time.monotonic()
+with fast_mode():
+ assert flaky() == "ok" # would take 30 + 60 + 120 seconds
+assert time.monotonic() - start < 5
+```
+
+The other switches work the same way:
+
+| Helper | Effect |
+| --- | --- |
+| `fast_mode()` | `_core.sleep` / `_core.async_sleep` return immediately |
+| `disable_retries()` | `retry` makes a single attempt |
+| `disable_rate_limits()` | `rate_limit` / `throttle` let every call through |
+| `disable_caching()` | `memoize` / `cache` always call the wrapped function |
+| `overrides(no_sleep=..., max_attempts=..., ...)` | Any combination, in one scope |
+
+```python
+import asyncio
+from python_wrap import _core
+from python_wrap.testing import disable_retries, fast_mode, overrides
+
+with overrides(no_sleep=True, max_attempts=3):
+ with disable_retries():
+ assert _core.overrides.max_attempts == 1
+ assert _core.overrides.max_attempts == 3 # restored on exit
+assert _core.overrides.max_attempts is None
+
+@fast_mode()
+async def test_something() -> None:
+ assert _core.overrides.no_sleep
+
+asyncio.run(test_something())
+```
+
+!!! note "Nesting and threads"
+ The overrides are one process-wide state, not context variables, so worker threads started by your test see them. Each scope restores exactly the fields it changed, to the values they had on entry, so scopes nest. They must exit in reverse order of entry (`with` blocks and decorators always do). Overlapping scopes from different threads that touch the same field are not isolated from each other; enter them from the test, not from concurrent workers.
+
+## Fake clock
+
+`fake_clock()` replaces `_core.monotonic`, `_core.perf_counter`, `_core.sleep` and `_core.async_sleep`. Sleeping advances the clock and records the duration, so you can assert the exact backoff schedule. The async sleep yields to the event loop, so other tasks still run.
+
+```python
+from python_wrap import retry
+from python_wrap.testing import fake_clock
+
+calls = []
+
+@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError)
+def flaky() -> str:
+ calls.append(1)
+ if len(calls) < 4:
+ raise ConnectionError
+ return "ok"
+
+with fake_clock() as clock:
+ start = clock.now
+ assert flaky() == "ok" # returns at once; no real waiting
+
+assert clock.sleeps == [1.0, 2.0, 4.0] # the backoff schedule you configured
+assert clock.now == start + 7.0
+```
+
+`FakeClock()` starts at the real `time.monotonic()` of the moment it is created, rounded up to a whole second (so differences stay exact), so entries stamped before it was installed are in its past and still expire; pass `start=` for a fixed reading. It has `.now`, `.advance(seconds)`, `.sleeps` and `.total_slept`; pass your own instance with `fake_clock(clock)`.
+
+## What the overrides affect
+
+python-wrap separates two kinds of waiting:
+
+- **Backoff waits** go through `_core.sleep` / `_core.async_sleep`: `retry` delays and a blocking rate limiter (`block=True`) waiting for a slot. `fast_mode()` makes them instant; `fake_clock()` turns them into clock advances you can assert on.
+- **Limits on how long to wait** use real timers: the deadline of `timeout` (signal timer, worker thread, `asyncio.timeout`), `Bulkhead(timeout=...)` / `limit_concurrency` waiting for a slot, `race(timeout=...)`, the `hedge` delay before a backup attempt, the `batch_calls` window and the `debounce` quiet period. Neither `fast_mode()` nor `fake_clock()` changes them, and sync and async code behave the same. Making them instant would turn "wait at most 5 s" into "fail at once" (or "hedge at once"). Test them with tiny real durations, or call `.flush()` / `.aflush()` on `debounce` and `batch_calls`.
+
+| Behaviour | `fast_mode()` | `fake_clock()` |
+| --- | --- | --- |
+| `retry` backoff | instant | advances the clock |
+| `rate_limit(block=True)` / `RateLimiter(block=True)` waiting | the call goes through at once and is counted | advances the clock, then goes through |
+| `rate_limit` / `throttle` non-blocking rejection | still rejects | windows follow the fake clock |
+| TTLs, circuit breaker recovery, `deadline` remaining time | real time | follow the fake clock |
+| `timeout`, `Bulkhead` / `race` timeouts, `hedge` delay, `batch_calls` / `debounce` windows | real time | real time |
+
+Bulkhead and other concurrency limits are not rate limits: `disable_rate_limits()` leaves them on.
+
+## pytest fixtures
+
+Enable the plugin in your top-level `conftest.py`:
+
+
+```python
+# conftest.py
+pytest_plugins = ["python_wrap.testing"]
+```
+
+You get two fixtures:
+
+- `pw_fake_clock`: installs a `FakeClock` for the test and yields it.
+- `pw_fast_mode`: runs the test under `fast_mode()`.
+
+
+```python
+def test_backoff(pw_fake_clock):
+ assert flaky() == "ok"
+ assert pw_fake_clock.sleeps == [1.0, 2.0, 4.0]
+
+def test_quick(pw_fast_mode):
+ ...
+```
+
+`python_wrap.testing` imports without pytest installed; the fixtures only exist when it is.
+
+## Spies
+
+`spy(func)` records every call without changing behaviour, for sync and async functions. `spy_on(obj, name)` patches a module, class or instance attribute for a block and restores it.
+
+```python
+import math
+from python_wrap.testing import spy, spy_on
+
+@spy
+def add(a: int, b: int) -> int:
+ return a + b
+
+add(1, b=2)
+assert add.call_count == 1 and add.called
+add.assert_called_with(1, b=2)
+call = add.calls[0]
+assert (call.args, call.kwargs, call.result, call.exception) == ((1,), {"b": 2}, 3, None)
+add.reset()
+assert add.call_count == 0
+
+with spy_on(math, "floor") as floor:
+ math.floor(2.5)
+assert floor.call_count == 1 # and math.floor is the original again
+```
+
+Each `Call` holds `args`, `kwargs`, `result`, `exception` and `duration`. On a class, a spy records `self` as the first argument.
+
+## Waiting for background work
+
+`eventually` (async) and `eventually_sync` retry an assertion until it passes or `timeout` runs out, then raise the last `AssertionError` annotated with how long it waited. They use real time unless a fake clock is installed.
+
+```python
+import asyncio
+import threading
+from python_wrap.testing import eventually, eventually_sync
+
+done = threading.Event()
+threading.Timer(0.02, done.set).start()
+
+def finished() -> None:
+ assert done.is_set()
+
+eventually_sync(finished, timeout=2.0, interval=0.005)
+
+async def main() -> None:
+ state = {"ready": False}
+ asyncio.get_running_loop().call_later(0.02, state.update, {"ready": True})
+
+ async def check() -> None: # sync or async assertions both work
+ assert state["ready"]
+
+ await eventually(check, timeout=2.0, interval=0.005)
+
+asyncio.run(main())
+```
+
+!!! tip "Under `fast_mode()`"
+ `eventually` waits through `_core`, so inside `fast_mode()` its polling interval is zero and it spins until `timeout`. Leave it outside fast mode when you wait on real threads.
+
+## Patching the clock by hand
+
+Everything above is built on four module attributes of `python_wrap._core`, always read as attributes so they can be patched:
+
+| Attribute | Used for |
+| --- | --- |
+| `_core.monotonic()` | Rate-limit windows, throttle, cache TTL, circuit recovery, retry `elapsed` |
+| `_core.perf_counter()` | `timing`, `benchmark`, `audit` durations |
+| `_core.sleep(seconds)` | Waiting in sync `retry` and `rate_limit(block=True)` |
+| `_core.async_sleep(seconds)` | The same, for `async def` functions |
+
+If you prefer `unittest.mock`, patch them directly:
+
+```python
+from unittest import mock
+from python_wrap import _core, retry
+
+sleeps: list[float] = []
+calls = []
+
+@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError)
+def flaky() -> str:
+ calls.append(1)
+ if len(calls) < 4:
+ raise ConnectionError
+ return "ok"
+
+with mock.patch.object(_core, "sleep", sleeps.append):
+ assert flaky() == "ok"
+
+assert sleeps == [1.0, 2.0, 4.0]
+```
+
+With it, you can test expiry and recovery without waiting:
+
+```python
+from unittest import mock
+from python_wrap import _core, memoize
+
+now = [1000.0]
+calls = []
+
+@memoize(ttl=60)
+def load() -> int:
+ calls.append(1)
+ return len(calls)
+
+with mock.patch.object(_core, "monotonic", lambda: now[0]):
+ assert load() == 1
+ now[0] += 59
+ assert load() == 1 # still fresh
+ now[0] += 2
+ assert load() == 2 # expired: recomputed
+```
+
+The same trick moves a circuit breaker from open to half-open:
+
+```python
+from unittest import mock
+from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState, _core
+
+now = [0.0]
+breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=30)
+
+@breaker
+def call() -> None:
+ raise ConnectionError
+
+with mock.patch.object(_core, "monotonic", lambda: now[0]):
+ try:
+ call()
+ except ConnectionError:
+ pass
+ assert breaker.state is CircuitState.OPEN
+ try:
+ call()
+ except CircuitOpenError as exc:
+ assert exc.retry_after == 30
+
+ now[0] += 31
+ assert breaker.state is CircuitState.HALF_OPEN
+```
+
+!!! tip "Async"
+ With `mock`, patch `_core.async_sleep` with an `async def` that records and returns (as `FakeClock.async_sleep` does) so `async def` retries do not wait either.
+
+## Reset state between tests
+
+State lives on the decorated function, so it outlives a single test. Reset it in a fixture.
+
+| State | Reset with |
+| --- | --- |
+| `memoize` / `cache` entries and statistics | `func.cache_clear()` (or `func.cache_delete(*args)` for one entry) |
+| `once` result | `func.reset()` |
+| Circuit breaker | `breaker.reset()` (or `func.breaker.reset()` for `@circuit_breaker`) |
+| `debounce` pending call | `func.cancel()` |
+
+```python
+from python_wrap import CircuitState, circuit_breaker, memoize, once
+
+@memoize
+def lookup(n: int) -> int:
+ return n
+
+@once
+def setup() -> object:
+ return object()
+
+@circuit_breaker(failure_threshold=1)
+def call() -> None:
+ raise RuntimeError
+
+lookup(1); setup()
+try:
+ call()
+except RuntimeError:
+ pass
+assert call.breaker.state is CircuitState.OPEN
+
+# What an autouse fixture would do after every test:
+lookup.cache_clear()
+setup.reset()
+call.breaker.reset()
+
+assert lookup.cache_info().currsize == 0
+assert setup.called is False
+assert call.breaker.state is CircuitState.CLOSED
+```
+
+
+```python
+# conftest.py
+import pytest
+
+from myapp.api import call_api, lookup, setup_once
+
+
+@pytest.fixture(autouse=True)
+def reset_python_wrap_state():
+ yield
+ lookup.cache_clear()
+ setup_once.reset()
+ call_api.breaker.reset()
+```
+
+`rate_limit`, `throttle` and `singleflight` have no reset method. Use the fake clock to move past the window, or build a fresh decorated function inside the test.
+
+## Disabling `mock_data`
+
+`mock_data(enabled=...)` is evaluated on **every call**, so a test only has to flip whatever it reads:
+
+```python
+import os
+from python_wrap import mock_data
+
+@mock_data({"id": 1}, enabled=lambda: os.environ.get("USE_MOCKS") == "1")
+def fetch() -> dict:
+ return {"id": 2}
+
+os.environ["USE_MOCKS"] = "1"
+assert fetch() == {"id": 1}
+
+os.environ["USE_MOCKS"] = "0" # tests of the real behaviour
+assert fetch() == {"id": 2}
+```
+
+In pytest, use `monkeypatch.setenv("USE_MOCKS", "0")` so the change is undone afterwards.
+
+## Bypass a decorator
+
+Wrapped functions keep `__wrapped__` (they use `functools.wraps`), so you can call the undecorated function in a unit test:
+
+```python
+from python_wrap import retry
+
+calls = []
+
+@retry(max_attempts=3, delay=0)
+def flaky() -> None:
+ calls.append(1)
+ raise ValueError
+
+try:
+ flaky.__wrapped__() # skips the retry loop
+except ValueError:
+ pass
+assert len(calls) == 1
+```
+
+## Capturing logs
+
+python-wrap logs to the `python_wrap` logger (or your `logger=`), so pytest's `caplog` works as usual:
+
+
+```python
+import logging
+
+def test_retry_logs_a_warning(caplog):
+ caplog.set_level(logging.WARNING, logger="python_wrap")
+ ...
+ assert "retrying in" in caplog.text
+```
+
+## Silence deprecation warnings you expect
+
+
+```python
+import pytest
+
+def test_old_api_warns():
+ with pytest.warns(DeprecationWarning, match="old_func"):
+ old_func()
+```
+
+`deprecate` points the warning at the caller's line, so `-W error::DeprecationWarning` shows which test line used the old API.
From 0d7bf53c9fc219e8ed4720462c88a594661ccb8d Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 15:25:59 +0300
Subject: [PATCH 08/10] Restructure the docs: get started, one page per
feature, testing, project
---
docs/async.md | 84 ----
docs/choosing.md | 155 -------
docs/cookbook/dataloader-batching.md | 2 +-
docs/cookbook/index.md | 4 +-
docs/cookbook/resilient-http-client.md | 2 +-
docs/cookbook/testing-your-code.md | 2 +-
docs/guides/async-toolkit.md | 319 --------------
docs/guides/batching.md | 263 -----------
docs/guides/caching.md | 327 --------------
docs/guides/concurrency.md | 401 -----------------
docs/guides/configuration.md | 77 ----
docs/guides/devtools.md | 194 ---------
docs/guides/metrics.md | 199 ---------
docs/guides/observability.md | 369 ----------------
docs/guides/policies.md | 77 ----
docs/guides/resilience.md | 579 -------------------------
docs/guides/state.md | 214 ---------
docs/guides/validation.md | 219 ----------
docs/index.md | 24 +-
docs/project/limitations.md | 4 +-
docs/project/migration.md | 2 +-
docs/stacking.md | 171 --------
docs/testing.md | 406 -----------------
src/python_wrap/validation.py | 2 +-
24 files changed, 21 insertions(+), 4075 deletions(-)
delete mode 100644 docs/async.md
delete mode 100644 docs/choosing.md
delete mode 100644 docs/guides/async-toolkit.md
delete mode 100644 docs/guides/batching.md
delete mode 100644 docs/guides/caching.md
delete mode 100644 docs/guides/concurrency.md
delete mode 100644 docs/guides/configuration.md
delete mode 100644 docs/guides/devtools.md
delete mode 100644 docs/guides/metrics.md
delete mode 100644 docs/guides/observability.md
delete mode 100644 docs/guides/policies.md
delete mode 100644 docs/guides/resilience.md
delete mode 100644 docs/guides/state.md
delete mode 100644 docs/guides/validation.md
delete mode 100644 docs/stacking.md
delete mode 100644 docs/testing.md
diff --git a/docs/async.md b/docs/async.md
deleted file mode 100644
index d301456..0000000
--- a/docs/async.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# Async support
-
-Every decorator in python-wrap works on `async def` functions. You use it the same way; the wrapper behaves correctly for coroutines.
-
-## What you get
-
-When you decorate an `async def` function, python-wrap returns an `async def` wrapper. That means:
-
-- `inspect.iscoroutinefunction(wrapped)` stays `True`, so frameworks that check it (FastAPI, Starlette, pytest-asyncio, `asyncio.to_thread` guards) treat it correctly.
-- The decorator awaits your function. It does not return a coroutine to a sync wrapper that hides it.
-- Waiting uses `asyncio.sleep`, so the event loop is never blocked.
-
-```python
-import asyncio
-import inspect
-from python_wrap import (
- circuit_breaker, fallback, memoize, once, rate_limit, retry, singleflight,
- throttle, timeout, timing,
-)
-
-@retry(max_attempts=2, delay=0.01)
-@timeout(1)
-@timing
-async def fetch() -> str:
- return "ok"
-
-assert inspect.iscoroutinefunction(fetch)
-assert asyncio.run(fetch()) == "ok"
-
-for decorator in (
- retry, fallback(0), memoize, once, singleflight, timeout(1), throttle(1),
- rate_limit(1, 1), circuit_breaker, timing,
-):
- async def probe() -> None: ...
- assert inspect.iscoroutinefunction(decorator(probe)), decorator
-```
-
-Methods work too, including `async def` methods; `self` is just the first argument.
-
-## Per-decorator behaviour
-
-| Decorator | What is different for `async def` |
-| --- | --- |
-| `retry` | Awaits the function and sleeps with `asyncio.sleep` between attempts. |
-| `fallback`, `suppress` | Catch exceptions from the awaited call. The `fallback` handler is still a plain (sync) function. |
-| `circuit_breaker`, `CircuitBreaker` | Counts failures from the awaited call. `breaker.call(async_func, ...)` returns a coroutine to await. State changes are lock-protected but never hold the lock across an `await`. |
-| `timeout` | Cancels the coroutine with `asyncio.wait_for`, which actually stops the work. |
-| `memoize`, `cache` | Caches the **awaited** result. Concurrent awaits of the same missing key share one in-flight task, so the function runs once. Cancelling one caller does not cancel the shared computation. |
-| `once` | Concurrent first awaits share one task. A first call that raises can be retried. |
-| `singleflight` | De-duplicates across tasks on the same event loop, using a shared task. |
-| `rate_limit` | With `block=True` waits with `await`; otherwise raises `RateLimitExceeded`. |
-| `throttle` | Same semantics with an `async` wrapper. |
-| `debounce` | Schedules a task on the running loop. Every caller in a burst awaits the same outcome (the last call's result). Tracked per event loop. |
-| `run_in_thread` | The coroutine runs with `asyncio.run` in a new thread (its own loop). The wrapper is a plain function returning a `Future`. |
-| `timing`, `measure_memory`, `profile` | Cover the whole awaited coroutine. (`profile` includes event-loop work for other tasks; see [Limitations](limitations.md#measure_memory-and-profile).) |
-| `benchmark` / `benchmark_async` | Use `benchmark_async` for coroutine functions; `benchmark` raises `TypeError`. |
-| `log_args`, `log_return`, `audit`, `trace` | Log around the awaited call. |
-| `validate_args`, `check_type`, `enforce_types` | Validate at call time; `enforce_types` checks the awaited result against the return annotation. |
-| `revert_on_failure`, `transactional` | Work on `async def`. `Transaction` supports `async with`, and async callbacks are awaited. |
-| `deprecate` | Warns on every await. |
-| `mock_data` | Works on `async def`; the awaited result is the data. |
-
-## Calling async code from sync code
-
-Use `asyncio.run` from a plain script or thread, as the examples in these docs do:
-
-```python
-import asyncio
-from python_wrap import retry
-
-@retry(max_attempts=2, delay=0.01)
-async def job() -> int:
- return 1
-
-assert asyncio.run(job()) == 1
-```
-
-To run a coroutine function in the background from sync code, use [`run_in_thread`](guides/concurrency.md#run_in_thread). It gives the coroutine its own event loop in a new thread and hands you a `Future`.
-
-## Gotchas
-
-- **One event loop per `debounce` and `singleflight` burst.** Their state is tracked per loop; do not share a debounced coroutine across loops and expect them to coordinate.
-- **`rate_limit(block=False)` raises inside the coroutine**, at the `await`, not when you call the function.
-- **Sync decorators on an `async def` function you wrote yourself** (a `functools.wraps` wrapper that is not itself `async def`) hide the coroutine from python-wrap's detection. Stack python-wrap decorators directly on the `async def`, or make your wrapper `async def`.
diff --git a/docs/choosing.md b/docs/choosing.md
deleted file mode 100644
index 90a12bc..0000000
--- a/docs/choosing.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# Which decorator do I need?
-
-Find your problem, use the decorator in the second column.
-
-## Things fail
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| A network call fails now and then | `@retry(on=ConnectionError, delay=..., backoff=2)` | [Resilience](guides/resilience.md#retry) |
-| I want a default value instead of an exception | `@fallback(value)` or `@fallback(handler=fn)` | [Resilience](guides/resilience.md#fallback) |
-| I just want an error to go away (returns `None`) | `@suppress(KeyError)` | [Resilience](guides/resilience.md#suppress) |
-| A dependency is down and I keep hammering it | `@circuit_breaker(...)` or a shared `CircuitBreaker` | [Resilience](guides/resilience.md#circuit-breaker) |
-| A call can hang forever | `@timeout(seconds)` or `with timeout(seconds):` | [Resilience](guides/resilience.md#timeout) |
-| The server says `Retry-After` | `@retry(delay_from=retry_after_header)` | [Resilience](guides/resilience.md#honour-retry-after) |
-| Retry only part of a function | `for attempt in retrying(...)` | [Resilience](guides/resilience.md#retrying-a-block-of-code) |
-| A whole request needs one time budget | `with deadline(seconds):` | [Resilience](guides/resilience.md#deadlines-one-budget-for-many-steps) |
-| A read is occasionally very slow | `@hedge(delay)` (idempotent calls only) | [Batching and hedging](guides/batching.md#hedge) |
-| Apply retry, breaker and timeout in the right order | `policy(retry=..., breaker=..., timeout=...)` | [Policies](guides/policies.md) |
-
-## Things are slow or repeated
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| Same arguments, same answer, compute it once | `@memoize` | [Caching](guides/caching.md#memoize) |
-| Cached values should go stale after N seconds | `@memoize(ttl=...)` | [Caching](guides/caching.md#expiry-and-size) |
-| Expensive one-time setup | `@once` | [Caching](guides/caching.md#once) |
-| 100 concurrent requests for the same thing | `@singleflight` | [Concurrency](guides/concurrency.md#singleflight) |
-| An expensive attribute on an object | `@cached_property(ttl=...)` | [Caching](guides/caching.md#cached_property) |
-| Run a blocking function in the background | `@run_in_thread` | [Concurrency](guides/concurrency.md#run_in_thread) |
-
-## Too many things at once
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| `gather` thousands of coroutines without flooding a server | `await gather_limit(*coros, limit=20)` | [Async toolkit](guides/async-toolkit.md#gather_limit) |
-| Apply an async function to a list, a few at a time | `await map_async(fn, items, limit=20)` | [Async toolkit](guides/async-toolkit.md#map_async-and-amap_iter) |
-| Take whichever of several calls answers first | `await race(a(), b())` | [Async toolkit](guides/async-toolkit.md#race) |
-| Call async code from a script | `run_sync(coro)` | [Async toolkit](guides/async-toolkit.md#run_sync) |
-| Protect a dependency from overload; fail fast when full | `Bulkhead(n, max_waiting=...)` or `@limit_concurrency(n)` | [Async toolkit](guides/async-toolkit.md#bulkhead) |
-| Many single lookups should become one bulk query | `@batch_calls(max_size=..., max_wait=...)` | [Batching and hedging](guides/batching.md#batch_calls) |
-
-## Things happen too often
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| Reject calls over N per second | `@rate_limit(n, period)` | [Concurrency](guides/concurrency.md#rate_limit) |
-| Slow callers down instead of rejecting them | `@rate_limit(n, period, block=True)` | [Concurrency](guides/concurrency.md#rate_limit) |
-| One limit per user or API key | `@rate_limit(..., key=...)` | [Concurrency](guides/concurrency.md#one-limit-per-user) |
-| Several functions share one API quota | `RateLimiter(n, period)` | [Concurrency](guides/concurrency.md#one-budget-for-several-functions-ratelimiter) |
-| Run at most once per interval, skip the rest | `@throttle(interval)` | [Concurrency](guides/concurrency.md#throttle) |
-| Wait for a burst of calls to settle, then run once | `@debounce(wait)` | [Concurrency](guides/concurrency.md#debounce) |
-
-## I need to see what is happening
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| How long does this take? | `@timing` | [Observability](guides/observability.md#timing) |
-| Send durations to my metrics system | `@timing(callback=...)` | [Observability](guides/observability.md#timing) |
-| Compare how fast a function is over many runs | `benchmark(func, ...)` | [Observability](guides/observability.md#benchmark) |
-| How much memory does it allocate? | `@measure_memory` | [Observability](guides/observability.md#measure_memory) |
-| Where does the time go? | `@profile` | [Observability](guides/observability.md#profile) |
-| Log the arguments (without leaking passwords) | `@log_args` | [Observability](guides/observability.md#log_args-and-log_return) |
-| Log what a function returns | `@log_return` | [Observability](guides/observability.md#log_args-and-log_return) |
-| Keep an audit trail of who called what | `@audit` | [Observability](guides/observability.md#audit) |
-| Log the call stack that led here | `@trace` | [Observability](guides/observability.md#trace) |
-| Count calls, errors and p95 latency per function | `@count_calls` then `func.stats` | [Metrics and events](guides/metrics.md#count_calls) |
-| Feed retries, cache hits and breaker trips to Prometheus or OpenTelemetry | `subscribe(listener)` | [Metrics and events](guides/metrics.md#events) |
-| Put a request id on every log line | `with log_context(request_id=...):` | [Observability](guides/observability.md#log_context) |
-
-## Bad input
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| Check arguments with my own rules | `@validate_args(x=lambda x: x > 0)` | [Validation](guides/validation.md#validate_args) |
-| `isinstance` checks on a few arguments | `@check_type(x=int)` | [Validation](guides/validation.md#check_type) |
-| Enforce my type annotations at runtime | `@enforce_types` | [Validation](guides/validation.md#enforce_types) |
-
-## Half-finished work
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| Undo changes to `self` if a method raises | `@revert_on_failure` | [State safety](guides/state.md#revert_on_failure) |
-| Register undo steps and run them if anything fails | `@transactional` / `Transaction` | [State safety](guides/state.md#transactions) |
-
-## Maintaining and testing code
-
-| Problem | Use | Guide |
-| --- | --- | --- |
-| Tests that wait on retries and backoff | `with fast_mode():` from `python_wrap.testing` | [Testing](testing.md) |
-| Switch retries, rate limits or caches off in a test | `disable_retries()`, `disable_rate_limits()`, `disable_caching()` | [Testing](testing.md) |
-| Control time in a test | `FakeClock` / `fake_clock()` | [Testing](testing.md) |
-| Set the same logger, retry defaults or redaction everywhere | `configure(...)` | [Configuration](guides/configuration.md) |
-| Protect every method of a class | `@wrap_methods(...)` | [Developer tools](guides/devtools.md#wrap_methods) |
-| Warn callers that an API is going away | `@deprecate(since=..., alternative=...)` | [Developer tools](guides/devtools.md#deprecate) |
-| Return fixed data in tests or local runs | `@mock_data(...)` | [Developer tools](guides/devtools.md#mock_data) |
-| Reuse a stack of decorators | `compose(a, b, c)` | [Developer tools](guides/devtools.md#compose) |
-
-## Common recipes
-
-??? example "Robust HTTP call"
-
- ```python
- from python_wrap import circuit_breaker, retry, timeout
-
- @circuit_breaker(failure_threshold=5, recovery_timeout=30)
- @retry(max_attempts=3, delay=0.2, backoff=2, jitter=0.1, on=(ConnectionError, TimeoutError))
- @timeout(5)
- def call_api() -> dict:
- ...
- ```
-
- Each attempt gets its own 5 second deadline, and the breaker counts a call as failed only once its retries are used up. See [Stacking decorators](stacking.md).
-
-??? example "Cached, de-duplicated lookup"
-
- ```python
- from python_wrap import memoize, singleflight
-
- @memoize(ttl=30)
- @singleflight
- def load_price(sku: str) -> float:
- ...
- ```
-
-??? example "Public API with input checks"
-
- ```python
- from python_wrap import enforce_types, log_args, validate_args
-
- @log_args
- @enforce_types
- @validate_args(amount=(lambda a: a > 0, "amount must be positive"))
- def charge(account: str, amount: float) -> None:
- ...
- ```
-
-??? example "The same thing as one named policy"
-
- ```python
- from python_wrap import CircuitBreaker, policy, retry, retry_after_header, timeout
-
- api = policy(
- breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30),
- retry=retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError,
- delay_from=retry_after_header),
- timeout=timeout(5),
- name="api",
- )
-
- @api
- def call_api() -> dict:
- ...
- ```
-
- The roles decide the order, so listing them differently changes nothing. See [Policies](guides/policies.md).
diff --git a/docs/cookbook/dataloader-batching.md b/docs/cookbook/dataloader-batching.md
index 09a9625..58a7af7 100644
--- a/docs/cookbook/dataloader-batching.md
+++ b/docs/cookbook/dataloader-batching.md
@@ -11,7 +11,7 @@ for post in posts:
This is the "N+1" query problem, and it appears everywhere: ORMs, GraphQL resolvers, templates, serializers. Fixing it by hand means restructuring the code so someone collects all ids first, loads them in one query and passes the results back down, and that couples every layer to the loading strategy.
-[`batch_calls`](../guides/batching.md) is the DataLoader pattern: write **one bulk function** that takes a list of ids, and call it **one item at a time** from anywhere. Calls made close together are collected into a single bulk call, and each caller gets its own result.
+[`batch_calls`](../features/caching/batching.md) is the DataLoader pattern: write **one bulk function** that takes a list of ids, and call it **one item at a time** from anywhere. Calls made close together are collected into a single bulk call, and each caller gets its own result.
## The code
diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md
index 44b32bb..22d72a1 100644
--- a/docs/cookbook/index.md
+++ b/docs/cookbook/index.md
@@ -6,7 +6,7 @@ The guides explain one decorator at a time. The cookbook shows how they combine
2. **The code**, complete and runnable. Every code block on these pages is executed by the docs test suite, so what you read works.
3. **How it works**, one decorator at a time.
4. **Pitfalls**, the mistakes that cost a day.
-5. **Test it**, a runnable test file that uses [`python_wrap.testing`](../testing.md), so the decorators never make your tests slow or flaky.
+5. **Test it**, a runnable test file that uses [`python_wrap.testing`](../testing/index.md), so the decorators never make your tests slow or flaky.
Every recipe that matters has a standalone script in the [`examples/`](https://github.com/LitePy/PythonWrap/tree/main/examples) directory that you can run with `python examples/.py`. No third-party packages are needed; framework code (FastAPI, Django, httpx) is shown but marked as such.
@@ -29,7 +29,7 @@ New to python-wrap? Start with the [resilient HTTP client](resilient-http-client
## Ground rules the recipes follow
-* **Stack deliberately.** The decorator closest to the function runs first. `@retry` above `@rate_limit` means every retry counts against the limit; the other way round, the whole retry loop counts once. See [Stacking decorators](../stacking.md).
+* **Stack deliberately.** The decorator closest to the function runs first. `@retry` above `@rate_limit` means every retry counts against the limit; the other way round, the whole retry loop counts once. See [Stacking decorators](../get-started/stacking.md).
* **State belongs to the function.** Caches, breakers and rate-limit windows are created once per decorated function and shared by every caller in the process. That is the point, and also the first thing to reset in tests.
* **Libraries do not configure logging.** Recipes that print or collect logs set up their own handlers, as your application should.
* **Be explicit about what is safe to repeat.** `retry`, `hedge` and `race` may run an operation more than once. Use them for reads and idempotent writes.
diff --git a/docs/cookbook/resilient-http-client.md b/docs/cookbook/resilient-http-client.md
index c81afd4..2b8fb37 100644
--- a/docs/cookbook/resilient-http-client.md
+++ b/docs/cookbook/resilient-http-client.md
@@ -219,7 +219,7 @@ For `httpx.AsyncClient` make `get` and `_send` `async def` and use `await breake
## Test it
-The decorators wait and remember, so tests need the helpers from [`python_wrap.testing`](../testing.md): `fake_clock` makes sleeps instant and records them, `spy_on` counts transport calls and `disable_rate_limits` lets calls through limiters. (`fast_mode` skips waits without recording them; see [Testing code that uses python-wrap](testing-your-code.md).)
+The decorators wait and remember, so tests need the helpers from [`python_wrap.testing`](../testing/index.md): `fake_clock` makes sleeps instant and records them, `spy_on` counts transport calls and `disable_rate_limits` lets calls through limiters. (`fast_mode` skips waits without recording them; see [Testing code that uses python-wrap](testing-your-code.md).)
```python
import threading
diff --git a/docs/cookbook/testing-your-code.md b/docs/cookbook/testing-your-code.md
index d066127..bae98eb 100644
--- a/docs/cookbook/testing-your-code.md
+++ b/docs/cookbook/testing-your-code.md
@@ -9,7 +9,7 @@ Decorators that wait and remember make tests slow and order-dependent:
- A `rate_limit` or circuit breaker keeps counting across tests, so a test passes alone and fails in the suite.
- A TTL cache or throttle window needs real time to pass.
-[`python_wrap.testing`](../testing.md) is built for this. It is standard library only (pytest is optional), and the helpers change python-wrap's behaviour from the outside, so production code stays unchanged.
+[`python_wrap.testing`](../testing/index.md) is built for this. It is standard library only (pytest is optional), and the helpers change python-wrap's behaviour from the outside, so production code stays unchanged.
| Helper | Use it to |
| --- | --- |
diff --git a/docs/guides/async-toolkit.md b/docs/guides/async-toolkit.md
deleted file mode 100644
index f2f1851..0000000
--- a/docs/guides/async-toolkit.md
+++ /dev/null
@@ -1,319 +0,0 @@
-# Async toolkit and bulkheads
-
-Small helpers for the things every asyncio project ends up hand-writing: bounded
-`gather`, bounded `map`, racing, calling async code from sync code, and capping how many
-calls hit a fragile dependency at once.
-
-| Helper | One-line behaviour |
-| --- | --- |
-| [`gather_limit`](#gather_limit) | `asyncio.gather`, but at most `limit` run at once. |
-| [`map_async`](#map_async-and-amap_iter) | Apply an async function to every item, `limit` at a time; get a list. |
-| [`amap_iter`](#map_async-and-amap_iter) | Same, as an async generator: results stream out. |
-| [`race`](#race) | First success wins; the others are cancelled. |
-| [`run_sync`](#run_sync) | Run a coroutine from sync code (never inside a running loop). |
-| [`Bulkhead`](#bulkhead) | Cap concurrent calls, bound the queue, fail fast with `BulkheadFull`. |
-| [`limit_concurrency`](#limit_concurrency) | Decorator: a private `Bulkhead(n)` per function. |
-
-The async helpers live in `python_wrap.aio`, the bulkhead in `python_wrap.bulkhead`.
-
-!!! note "No task is left behind"
- Every helper owns the tasks it starts. When it returns, raises, is cancelled or (for
- `amap_iter`) is closed early, all of them have been cancelled *and awaited*. Nothing
- keeps running in the background, and you never see "Task exception was never
- retrieved" or "coroutine was never awaited" warnings from them.
-
-## `gather_limit`
-
-Like `asyncio.gather`, but coroutines are started only when one of the `limit` slots is
-free. Results come back in input order.
-
-```python
-import asyncio
-from python_wrap.aio import gather_limit
-
-running = 0
-peak = 0
-
-async def download(n: int) -> str:
- global running, peak
- running += 1
- peak = max(peak, running)
- await asyncio.sleep(0.001)
- running -= 1
- return f"file-{n}"
-
-async def main() -> None:
- files = await gather_limit(*(download(n) for n in range(20)), limit=5)
- assert files[:2] == ["file-0", "file-1"]
- assert peak == 5
-
-asyncio.run(main())
-```
-
-On the first exception the other awaitables are cancelled, coroutines that never started
-are closed, and the exception is raised. Pass `return_exceptions=True` to collect errors
-in the list instead:
-
-```python
-import asyncio
-from python_wrap.aio import gather_limit
-
-async def parse(text: str) -> int:
- return int(text)
-
-async def main() -> None:
- results = await gather_limit(parse("1"), parse("x"), limit=2, return_exceptions=True)
- assert results[0] == 1
- assert isinstance(results[1], ValueError)
-
-asyncio.run(main())
-```
-
-Tasks and futures are accepted too, but they are already running when you pass them, so
-`limit` cannot delay them; pass coroutines to get the cap.
-
-## `map_async` and `amap_iter`
-
-`map_async(fn, items, limit=...)` calls `fn` on every item with at most `limit` calls in
-flight, and returns the list of results. `items` may be a normal or an async iterable,
-and it is read **lazily**: an item is pulled only when a slot is free, so a generator of
-a million rows never sits in memory as a million tasks.
-
-```python
-import asyncio
-from python_wrap.aio import map_async
-
-async def rows():
- for i in range(100):
- yield {"id": i}
-
-async def save(row: dict[str, int]) -> int:
- await asyncio.sleep(0)
- return row["id"]
-
-async def main() -> None:
- ids = await map_async(save, rows(), limit=8)
- assert ids == list(range(100))
-
-asyncio.run(main())
-```
-
-`ordered=False` returns results in completion order instead. If a call fails, the others
-are cancelled and the error is raised (unless `return_exceptions=True`).
-
-`amap_iter` is the streaming version: it yields results as they are ready, which also
-works with infinite inputs.
-
-```python
-import asyncio
-from contextlib import aclosing
-from itertools import count
-from python_wrap.aio import amap_iter
-
-async def square(n: int) -> int:
- await asyncio.sleep(0)
- return n * n
-
-async def main() -> None:
- found = []
- async with aclosing(amap_iter(square, count(), limit=4)) as squares:
- async for value in squares:
- if value > 50:
- break
- found.append(value)
- assert found == [0, 1, 4, 9, 16, 25, 36, 49]
-
-asyncio.run(main())
-```
-
-* `ordered=True` (default) yields in input order. Results that finish early wait in a
- buffer, and buffered results count towards `limit`, so memory stays bounded; a slow
- item holds back the ones after it. An error is raised when its turn comes.
-* `ordered=False` yields each result as soon as it finishes.
-
-Breaking out of the loop, calling `aclose()` or cancelling the consumer cancels and
-awaits every call still in flight. Wrap the generator in `contextlib.aclosing` (as above)
-so this happens immediately instead of whenever the generator is garbage-collected. The
-input iterable is yours: `amap_iter` does not close it.
-
-## `race`
-
-Start every awaitable and return the first **successful** result. The losers are
-cancelled and awaited before `race` returns. A contestant that raises does not end the
-race; the others keep going.
-
-```python
-import asyncio
-from python_wrap.aio import race
-
-async def from_cache() -> str:
- raise LookupError("cache miss")
-
-async def from_replica() -> str:
- await asyncio.sleep(0.01)
- return "replica"
-
-async def from_primary() -> str:
- await asyncio.sleep(1)
- return "primary"
-
-async def main() -> None:
- assert await race(from_cache(), from_replica(), from_primary(), timeout=5) == "replica"
-
-asyncio.run(main())
-```
-
-* With `timeout=`, `TimeoutExpired` (a `TimeoutError`) is raised if nobody succeeds in
- time.
-* If every contestant fails, `RaceFailed` is raised with all the errors, in input order,
- in `.exceptions`. On Python 3.11+ `RaceFailed` is also an `ExceptionGroup`, so
- `except* LookupError:` works; on 3.10 it is a plain `PythonWrapError`. Catching
- `RaceFailed` works on every version.
-
-```python
-import asyncio
-from python_wrap.aio import RaceFailed, race
-
-async def broken(name: str) -> str:
- raise ConnectionError(name)
-
-async def main() -> None:
- try:
- await race(broken("a"), broken("b"))
- except RaceFailed as exc:
- assert [str(e) for e in exc.exceptions] == ["a", "b"]
-
-asyncio.run(main())
-```
-
-`race` is for idempotent work: the losers may already have done part of their job when
-they are cancelled.
-
-## `run_sync`
-
-Run async code from a sync entry point (a CLI command, a script, a test helper). It is
-`asyncio.run` with a friendlier call form:
-
-```python
-import asyncio
-from python_wrap.aio import run_sync
-
-async def fetch_total(a: int, b: int) -> int:
- await asyncio.sleep(0)
- return a + b
-
-assert run_sync(fetch_total, 2, 3) == 5
-assert run_sync(fetch_total(4, 5)) == 9
-```
-
-There is deliberately **no nested-event-loop magic**. Called while an event loop is
-running in the current thread, `run_sync` raises `RuntimeError` (and closes the coroutine
-so you get no "never awaited" warning). Inside async code, `await` instead; from sync
-code that runs inside a loop, move the sync code to a thread with `asyncio.to_thread`.
-
-## `Bulkhead`
-
-A bulkhead protects something with limited capacity (a connection pool, a rate-limited
-API, a CPU-heavy section) by capping how many calls use it at once, and by refusing to
-build an unbounded queue in front of it.
-
-```python
-import asyncio
-from python_wrap.bulkhead import Bulkhead
-from python_wrap.errors import BulkheadFull
-
-payments = Bulkhead(2, max_waiting=1, name="payments")
-
-@payments
-async def charge(amount: int) -> int:
- await asyncio.sleep(0.01)
- return amount
-
-async def main() -> None:
- results = await asyncio.gather(*(charge(n) for n in range(4)), return_exceptions=True)
- assert results[:3] == [0, 1, 2] # two ran at once, one waited
- assert isinstance(results[3], BulkheadFull) # the queue was full
-
-asyncio.run(main())
-```
-
-| Argument | Meaning |
-| --- | --- |
-| `max_concurrent` | Calls allowed to run at once. |
-| `max_waiting` | Callers allowed to queue for a slot. `None` = unlimited, `0` = never wait. |
-| `timeout` | Seconds a queued caller waits before giving up with `BulkheadFull`. |
-| `name` | Shown in error messages and `repr`. |
-
-One bulkhead can guard several functions and blocks at once. Use it as a decorator (sync
-or `async def`), as `with bulkhead:` in threaded code, or as `async with bulkhead:` in
-async code. `bulkhead.active` and `bulkhead.waiting` report the current load.
-
-```python
-from concurrent.futures import ThreadPoolExecutor
-from python_wrap.bulkhead import Bulkhead
-
-db = Bulkhead(3, timeout=5, name="db")
-
-def query(n: int) -> int:
- with db:
- return n * 2
-
-with ThreadPoolExecutor(10) as pool:
- assert list(pool.map(query, range(10))) == [n * 2 for n in range(10)]
-assert db.active == 0
-```
-
-!!! warning "Threads and event loops have separate limits"
- Sync callers share a `threading.BoundedSemaphore`; async callers use one
- `asyncio.Semaphore` per event loop (asyncio primitives cannot be shared across loops).
- So the cap applies separately to threads and to each loop. If one program uses both
- sides, give each side its own bulkhead. Never use `with bulkhead:` inside a coroutine:
- waiting for a slot there would block the event loop.
-
-!!! warning "A bulkhead inside a global limit can starve"
- Suppose `map_async(fetch, urls, limit=10)` calls a function that waits for a per-host
- `Bulkhead(2)`. A task holds one of the 10 global slots *while it queues for the
- bulkhead*. If most URLs hit the same host, ten tasks sit on global slots, two work and
- eight wait, so requests to every other host are blocked behind them (and with a
- `timeout` or `max_waiting` the waiters may be rejected instead). Two ways out:
-
- * Put the bulkhead outermost per host: acquire it *before* starting the work that
- takes a global slot, or group the items by host and run one `map_async` per host
- (each with `limit` equal to that host's bulkhead size).
- * Size the limits so each per-host limit is at most the global one and the global
- limit is at least the sum of all the per-host limits, so a global slot is never
- held for a bulkhead slot that cannot be free.
-
-`BulkheadFull` is a `PythonWrapError`, so it composes with the other decorators, for
-example `fallback` to serve a degraded answer when the bulkhead is full, or `retry` to
-try again a little later. Every rejection also emits the `bulkhead.rejected` event, with
-the `active` and `waiting` counts at that moment (see [Observability](observability.md)).
-
-## `limit_concurrency`
-
-The decorator form for the common case "at most N of these at once": each decorated
-function gets its own `Bulkhead(n)` with no queue limit and no timeout, available as
-`.bulkhead`.
-
-```python
-import asyncio
-from python_wrap.bulkhead import limit_concurrency
-
-@limit_concurrency(3)
-async def crawl(url: str) -> str:
- await asyncio.sleep(0)
- return url.upper()
-
-async def main() -> None:
- pages = await asyncio.gather(*(crawl(f"site{i}") for i in range(10)))
- assert pages[0] == "SITE0"
- assert crawl.bulkhead.max_concurrent == 3
-
-asyncio.run(main())
-```
-
-It works on methods too, including under a `@classmethod` stacked above it, and type
-checkers (mypy and pyright) keep the signature and the `.bulkhead` attribute there.
-On a method, the limit is shared by every instance (it belongs to the function). For a
-limit shared by several functions, or for `max_waiting` / `timeout`, create a `Bulkhead`
-yourself.
diff --git a/docs/guides/batching.md b/docs/guides/batching.md
deleted file mode 100644
index dda8dc0..0000000
--- a/docs/guides/batching.md
+++ /dev/null
@@ -1,263 +0,0 @@
-# Batching and hedging
-
-Two decorators for calls that go over the network:
-
-| Decorator | One-line behaviour |
-| --- | --- |
-| [`batch_calls`](#batch_calls) | Many single-item calls made close together become one batched call. |
-| [`hedge`](#hedge) | If a call is slow, start a backup attempt; the first success wins. |
-
-## `batch_calls`
-
-You often have code that loads one thing at a time — `await get_user(id)` in many places, many tasks — while the backend is much faster when asked for many things at once (`SELECT ... WHERE id IN (...)`, a bulk API endpoint). `batch_calls` is the DataLoader pattern: write the bulk function once, and call it with one item from everywhere.
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap.batching import batch_calls
-
- queries = []
-
- @batch_calls(max_size=100, max_wait=0.005)
- async def get_user(ids: list[int]) -> dict[int, str]:
- queries.append(ids) # one round trip for the whole batch
- return {i: f"user{i}" for i in ids}
-
- async def main() -> None:
- users = await asyncio.gather(get_user(1), get_user(2), get_user(1))
- assert users == ["user1", "user2", "user1"]
- assert queries == [[1, 2]] # one call; the duplicate 1 was sent once
-
- asyncio.run(main())
- ```
-
-=== "Sync (threads)"
-
- ```python
- from concurrent.futures import ThreadPoolExecutor
- from python_wrap.batching import batch_calls
-
- queries = []
-
- @batch_calls(max_size=4, max_wait=0.05)
- def get_price(skus: list[str]) -> list[float]:
- queries.append(sorted(skus))
- return [float(len(s)) for s in skus] # same order as the input
-
- with ThreadPoolExecutor(4) as pool:
- prices = list(pool.map(get_price, ["a", "bb", "ccc", "dddd"]))
-
- assert prices == [1.0, 2.0, 3.0, 4.0]
- assert queries == [["a", "bb", "ccc", "dddd"]] # the 4th caller filled the batch
- ```
-
-### When a batch is sent
-
-A **window** opens with the first call. The batch is sent when either happens first:
-
-* `max_wait` seconds have passed since the window opened (default 10 ms), or
-* the batch holds `max_size` distinct items (default 100).
-
-The next call after that opens a new window. Duplicate items in one window — same key — are sent once and their callers share the result.
-
-### The output contract
-
-The batch function receives a `list` of the **distinct** items, in the order they first arrived. It must return one of:
-
-| Return value | How results are matched |
-| --- | --- |
-| A sequence (`list`, `tuple`, ...) | Position: result `i` belongs to item `i`. The length must equal the number of items. |
-| A mapping (`dict`, ...) | By key: `key(item)` if you passed `key=`, otherwise the item itself. Extra entries are ignored. |
-
-Anything else is a contract violation. What callers see:
-
-| What the batch function did | Who gets what |
-| --- | --- |
-| Returned a valid result | Each caller gets its own item's result. |
-| Raised an exception | **Every** caller in the batch gets that exception. |
-| Returned a sequence of the wrong length | **Every** caller gets `BatchError`. |
-| Returned a mapping missing some keys | Only callers of the missing items get `BatchError`; the rest get results. |
-| Returned something else (`None`, a `str`, a generator, ...) | **Every** caller gets `BatchError`. |
-
-`str` and `bytes` are not accepted as sequences, and iterators are not consumed: return a `list`. `BatchError` is a `PythonWrapError`.
-
-```python
-import asyncio
-from python_wrap.batching import batch_calls
-from python_wrap.errors import BatchError
-
-@batch_calls()
-async def lookup(ids: list[int]) -> dict[int, str]:
- return {i: "found" for i in ids if i != 404}
-
-async def main() -> None:
- found, missing = await asyncio.gather(lookup(1), lookup(404), return_exceptions=True)
- assert found == "found"
- assert isinstance(missing, BatchError) # only this caller fails
-
-asyncio.run(main())
-```
-
-### Keys: deduplication and unhashable items
-
-Items must be hashable, or you pass `key=` to say what identifies one. The key is used to deduplicate within a window and to look results up in a returned mapping.
-
-```python
-import asyncio
-from python_wrap.batching import batch_calls
-
-@batch_calls(key=lambda req: req["id"])
-async def resolve(requests: list[dict]) -> dict[int, str]:
- return {r["id"]: r["name"].title() for r in requests}
-
-async def main() -> None:
- assert await resolve({"id": 7, "name": "ada"}) == "Ada"
-
-asyncio.run(main())
-```
-
-### Async details
-
-* Callers on the **same event loop** are batched together; batches never span loops.
-* The batch runs in its own task. Cancelling one caller does not cancel the batch for the others.
-* A caller cancelled **before** its batch is sent removes its item from the batch, unless another caller is waiting for the same key. If every caller goes away, the batch is never sent.
-* No tasks are left behind once the batch has been delivered.
-* When a call fills the batch to `max_size` and its caller is the batch's only waiter (always the case with `max_size=1`), the batch runs right in the calling task: no extra task, no event-loop round trip.
-
-### Sync details
-
-* Callers are threads; each blocks until its result is ready.
-* There is no timer thread. The **first caller of a window** keeps time: it waits up to `max_wait` and then runs the batch in its own thread. A caller that fills the batch to `max_size` runs it immediately in its thread. So the batch function always runs in one of the calling threads (or the thread calling `.flush()`).
-* A single thread calling the loader in a loop waits up to `max_wait` per call — batching needs concurrency. Do not call a sync loader from `async` code; it blocks the event loop. Decorate an `async def` instead.
-
-!!! warning "A sync loader is not awaitable"
- Decorating a plain `def` gives a plain function: `loader(1)` blocks and returns the value itself. So `await loader(1)` and `asyncio.create_task(loader(1))` fail (with `TypeError`, after the loop was already blocked for the batch window). The first time a sync loader is called from a running event loop it emits a `RuntimeWarning` saying so. In async code, decorate an `async def` batch function.
-
-### Flushing and inspecting
-
-* `.pending` — how many distinct items are queued and not yet sent.
-* `.flush()` (sync) — send every queued batch now, in the calling thread.
-* `await .aflush()` (async) — send the batches queued on the running loop now, and wait until every batch on that loop has finished. Useful at the end of a request or before shutdown.
-
-```python
-import asyncio
-from python_wrap.batching import batch_calls
-
-@batch_calls(max_wait=60) # a long window...
-async def load(ids: list[int]) -> list[int]:
- return [i * 10 for i in ids]
-
-async def main() -> None:
- tasks = [asyncio.create_task(load(i)) for i in range(3)]
- await asyncio.sleep(0) # let the tasks queue their items
- assert load.pending == 3
- await load.aflush() # ...cut short
- assert [t.result() for t in tasks] == [0, 10, 20]
-
-asyncio.run(main())
-```
-
-### Methods
-
-On a method the batch function is called as `func(self, items)`, and each instance gets its own batches (for a class method, each class). Calls on two instances are never mixed in one batch.
-
-```python
-import asyncio
-from python_wrap.batching import batch_calls
-
-class UserRepo:
- def __init__(self, table: str) -> None:
- self.table = table
-
- @batch_calls()
- async def get(self, ids: list[int]) -> list[str]:
- return [f"{self.table}:{i}" for i in ids]
-
-async def main() -> None:
- a, b = UserRepo("a"), UserRepo("b")
- assert await asyncio.gather(a.get(1), b.get(2)) == ["a:1", "b:2"]
-
-asyncio.run(main())
-```
-
-!!! note "Static typing"
- The wrapper is typed as `Batched[[Item], Result]` (or `AsyncBatched`), with `.pending`, `.flush()` / `.aflush()`. Pyright types instance, class and static methods exactly. mypy types instance and class methods correctly but binds the first parameter too eagerly for a `@staticmethod` stacked on top, and for unbound access such as `Repo.get(repo, 1)`; prefer a module-level loader there.
-
-## `hedge`
-
-Most calls to a replicated service are fast, but a few hit a slow replica, a GC pause, or a lost packet. A **hedged request** waits a little; if the call is still running, it fires a second, identical call and takes whichever answers first.
-
-!!! warning "Only for idempotent operations"
- A hedged function can run several times for one call, at the same time. Use it for reads and other operations that are safe to repeat. Never hedge payments, emails, counters, or anything else whose side effect must happen once.
-
-```python
-import asyncio
-from python_wrap.hedging import hedge
-
-attempts = []
-
-@hedge(0.05, max_extra=1)
-async def get_price(sku: str) -> float:
- attempts.append(sku)
- if len(attempts) == 1:
- await asyncio.sleep(10) # the first replica is stuck...
- return 9.99
-
-async def main() -> None:
- assert await get_price("A1") == 9.99 # ...so the backup answers after ~50 ms
- assert len(attempts) == 2
-
-asyncio.run(main())
-```
-
-### How it decides
-
-1. Start the call.
-2. If no attempt has finished after `delay` seconds, start another one (up to `max_extra` extra attempts in total, each `delay` after the previous one).
-3. The first attempt to **succeed** wins; the rest are cancelled.
-4. If an attempt fails with an exception matching `on` (default: any `Exception`) while there are attempts left, the next one starts **immediately** — there is no point waiting out the delay.
-5. An exception that does not match `on` is raised at once (and the other attempts are cancelled).
-6. If every attempt fails, the last exception is raised.
-
-Pick `delay` near the latency percentile you want to cut — for example the p95 of the call. Then only about 5% of calls send an extra request.
-
-```python
-import asyncio
-from python_wrap.hedging import hedge
-
-calls = []
-
-@hedge(10, max_extra=2, on=ConnectionError)
-async def fetch() -> str:
- calls.append(1)
- if len(calls) < 3:
- raise ConnectionError("replica down")
- return "ok"
-
-async def main() -> None:
- assert await fetch() == "ok" # failures hedge immediately: no 10 s waits
- assert len(calls) == 3
-
-asyncio.run(main())
-```
-
-### Async: cancellation guarantees
-
-Each attempt is a task. When the wrapper returns or raises, every other attempt has been cancelled **and awaited**, so no tasks are left running. Cancelling the caller cancels all attempts. Errors raised by losing attempts while they are being cancelled are discarded. Context variables are copied into each attempt.
-
-### Sync: attempts cannot be cancelled
-
-For a regular function each attempt runs in its own daemon thread and the caller blocks until one succeeds. **Python threads cannot be cancelled**: losing attempts keep running in the background until they finish, and their results are thrown away. Their side effects still happen, and they hold their resources (a connection, a lock) until done. Make sure the function has its own timeout, so a stuck attempt does not run forever.
-
-```python
-from python_wrap.hedging import hedge
-
-@hedge(0.01)
-def lookup(key: str) -> str:
- return key.upper()
-
-assert lookup("a") == "A"
-```
-
-`hedge` keeps the decorated function's signature and type, so it stacks with other decorators and works on methods.
diff --git a/docs/guides/caching.md b/docs/guides/caching.md
deleted file mode 100644
index 1feb19c..0000000
--- a/docs/guides/caching.md
+++ /dev/null
@@ -1,327 +0,0 @@
-# Caching
-
-Four tools, four different questions: "have I computed this before?" (`memoize`), "for how long is the answer good?" (`memoize(ttl=...)`), "have I done this setup yet?" (`once`), and "what is this object's expensive attribute?" (`cached_property`).
-
-## `memoize`
-
-Cache a function's result per argument values.
-
-=== "Sync"
-
- ```python
- from python_wrap import memoize
-
- calls = []
-
- @memoize
- def square(n: int) -> int:
- calls.append(n)
- return n * n
-
- assert square(4) == 16
- assert square(n=4) == 16 # arguments are bound to the signature: same entry
- assert calls == [4]
- assert square.cache_info().hits == 1
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import memoize
-
- calls = []
-
- @memoize
- async def square(n: int) -> int:
- calls.append(n)
- await asyncio.sleep(0.01)
- return n * n
-
- async def main() -> None:
- # Concurrent awaits of a missing key share one in-flight task.
- results = await asyncio.gather(square(4), square(4), square(n=4))
- assert results == [16, 16, 16]
- assert calls == [4]
-
- asyncio.run(main())
- ```
-
-How keys work:
-
-- Arguments are bound to the function signature and defaults are applied, so `f(1)`, `f(x=1)` and `f(1, y=)` share one entry.
-- Lists, dicts and sets (also nested) are converted to hashable forms automatically, and `[1]` and `(1,)` do not collide.
-- Any other unhashable argument raises `TypeError` at call time. Pass `key=` to build the key yourself.
-- Exceptions are not cached by default; see [Caching exceptions](#caching-exceptions).
-
-```python
-from python_wrap import memoize
-
-@memoize
-def total(items: list[int]) -> int:
- return sum(items)
-
-assert total([1, 2, 3]) == 6
-assert total([1, 2, 3]) == 6
-assert total.cache_info().hits == 1
-```
-
-### Expiry and size
-
-```python
-import time
-from python_wrap import memoize
-
-calls = []
-
-@memoize(maxsize=2, ttl=0.02)
-def load(key: str) -> str:
- calls.append(key)
- return key.upper()
-
-load("a"); load("b"); load("c") # "a" is evicted (least recently used)
-assert load.cache_info().currsize == 2
-assert load.cache_info().maxsize == 2
-
-time.sleep(0.03) # entries expire after ttl
-assert load.cache_info().currsize == 0
-load("c")
-assert calls == ["a", "b", "c", "c"]
-```
-
-| Option | Meaning |
-| --- | --- |
-| `maxsize` | Maximum entries; least recently used are evicted. `None` is unbounded. |
-| `ttl` | Seconds (or a `datetime.timedelta`) an entry stays valid. `None` never expires. |
-| `typed` | If `True`, `f(1)` and `f(1.0)` are cached separately. |
-| `key` | Custom `key(*args, **kwargs)` returning a hashable key. |
-| `cache_exceptions` | Exception class (or tuple) to cache like a result. |
-| `exception_ttl` | Seconds a cached exception stays valid (defaults to `ttl`). |
-
-### Per-item TTL
-
-`ttl` may also be a function of the result, evaluated when the result is stored. Return `None` for "never expires"; a zero or negative value means "do not store".
-
-```python
-from python_wrap import memoize
-
-@memoize(ttl=lambda token: token["expires_in"])
-def fetch_token(scope: str) -> dict:
- return {"scope": scope, "expires_in": 3600}
-
-fetch_token("read")
-assert fetch_token.cache_contains("read")
-```
-
-### Caching exceptions
-
-List the exceptions that are a stable answer (a missing record, a rejected input) in `cache_exceptions`. They are stored for `exception_ttl` seconds (default: `ttl`) and re-raised on every hit as the **same exception instance**, so its traceback grows with each raise. `asyncio.CancelledError` is never cached. When `ttl` is a function you must pass `exception_ttl` too.
-
-```python
-from python_wrap import memoize
-
-calls = []
-
-@memoize(cache_exceptions=KeyError, exception_ttl=30)
-def lookup(key: str) -> str:
- calls.append(key)
- raise KeyError(key)
-
-for _ in range(3):
- try:
- lookup("x")
- except KeyError:
- pass
-assert calls == ["x"]
-```
-
-### Controlling the cache
-
-Every memoized function has:
-
-```python
-from python_wrap import memoize
-
-@memoize
-def double(n: int) -> int:
- return n * 2
-
-double(1); double(2)
-assert double.cache_info().currsize == 2 # CacheInfo(hits, misses, maxsize, currsize)
-assert double.cache_delete(1) is True # drop one entry
-assert double.cache_delete(1) is False # it was not there
-double.cache_clear() # drop everything and reset the statistics
-assert double.cache_info() == (0, 0, None, 0)
-```
-
-Inspect without side effects (nothing is computed, counted or moved in the LRU order):
-
-```python
-from python_wrap import memoize
-
-@memoize(maxsize=8)
-def triple(n: int) -> int:
- return n * 3
-
-assert not triple.cache_contains(2)
-triple(2)
-assert triple.cache_contains(2)
-assert triple.cache_get(2) == 6 # raises KeyError when absent
-assert triple.cache_parameters()["maxsize"] == 8
-```
-
-On a method, `cache_delete`, `cache_contains` and `cache_get` take the instance as the first argument, because `obj.method` is an ordinary bound method and its attributes are not bound: `repo.load.cache_get(repo, 42)`.
-
-`cache_get` is typed `Any` because for `async def` functions it returns the awaited result, not a coroutine. Pass `python_wrap.testing.disable_caching()` to bypass every cache in tests.
-
-### Events
-
-`memoize` (and `cache`) emit `cache.hit`, `cache.miss` and `cache.evicted` (field `reason`: `"lru"` or `"ttl"`) to `python_wrap.events` listeners, with the function's qualified name. With no listener registered this costs one list check.
-
-### A custom key
-
-Ignore arguments that do not change the answer, or accept arguments that are not hashable:
-
-```python
-from python_wrap import memoize
-
-class Client: ...
-
-@memoize(key=lambda client, user_id: user_id) # ignore the client object
-def fetch_user(client: Client, user_id: int) -> dict:
- return {"id": user_id}
-
-fetch_user(Client(), 1)
-fetch_user(Client(), 1)
-assert fetch_user.cache_info().hits == 1
-```
-
-### Caching in the face of threads
-
-The cache is guarded by a lock, but the function is never called while holding it. For sync functions, two threads that miss on the same key at the same moment may both compute the value, and the last result stored wins. If that is not acceptable, stack [`singleflight`](concurrency.md#singleflight) under `memoize`.
-
-### Methods
-
-`self` is part of the key, so results are per instance. The cache holds a strong reference to every instance it has seen, which keeps them alive. See [Limitations](../limitations.md#memoize-on-methods).
-
-## `cache`
-
-`cache(duration, maxsize=None)` is v0.1's name for `memoize(ttl=duration, maxsize=maxsize)`, kept for compatibility. Prefer `memoize(ttl=...)` in new code (and note that `cache` also clashes with `functools.cache`).
-
-```python
-import datetime
-from python_wrap import cache
-
-@cache(datetime.timedelta(minutes=5))
-def load() -> int:
- return 42
-
-assert load() == 42
-```
-
-Unlike v0.1, every decorated function gets its own store, even when one `cache(...)` result is applied to several functions.
-
-## `once`
-
-Run a function a single time; later calls return the first result and ignore their arguments.
-
-=== "Sync"
-
- ```python
- from python_wrap import once
-
- @once
- def setup() -> object:
- return object()
-
- assert setup() is setup()
- assert setup.called is True
-
- setup.reset() # forget the result; the next call runs again
- assert setup.called is False
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import once
-
- runs = []
-
- @once
- async def connect() -> str:
- runs.append(1)
- await asyncio.sleep(0.01)
- return "connection"
-
- async def main() -> None:
- a, b = await asyncio.gather(connect(), connect()) # share one task
- assert a == b == "connection"
- assert runs == [1]
-
- asyncio.run(main())
- ```
-
-`once` is thread-safe. If the first call raises, the function is not marked done and the next call tries again.
-
-## `cached_property`
-
-Like `functools.cached_property`, plus `ttl`, per-instance locking, `async def` getters and explicit invalidation.
-
-=== "Sync"
-
- ```python
- from python_wrap import cached_property, invalidate
-
- class Report:
- loads = 0
-
- @cached_property(ttl=60)
- def totals(self) -> int:
- Report.loads += 1
- return 42
-
- r = Report()
- assert r.totals == 42 and r.totals == 42
- assert Report.loads == 1
-
- del r.totals # invalidate; the next access recomputes
- invalidate(r, "totals") # same, by name (no names: every property)
- assert r.totals == 42 and Report.loads == 2
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import cached_property
-
- class Client:
- calls = 0
-
- @cached_property
- async def profile(self) -> dict:
- Client.calls += 1
- await asyncio.sleep(0.01)
- return {"name": "ada"}
-
- async def main() -> None:
- c = Client()
- a, b = await asyncio.gather(c.profile, c.profile) # one shared task
- assert a == b == {"name": "ada"} and Client.calls == 1
-
- asyncio.run(main())
- ```
-
-Notes: the value is stored in the instance `__dict__`, so a `__slots__` class must list `"__dict__"` (otherwise access raises `TypeError`). The attribute is read-only (assigning raises `AttributeError`); use `del` or `invalidate`. Copies and pickles start with an empty cache. Type checkers infer the getter's return type, and `Awaitable[T]` for `async def` getters.
-
-## Which one?
-
-| You want | Use |
-| --- | --- |
-| Same arguments give the same answer forever | `@memoize` (add `maxsize` so it cannot grow without bound) |
-| The answer goes stale | `@memoize(ttl=...)` |
-| One result regardless of arguments (setup, config load) | `@once` |
-| An expensive attribute of one object | `@cached_property` |
-| No cache, but do not run the same in-flight call twice | [`@singleflight`](concurrency.md#singleflight) |
diff --git a/docs/guides/concurrency.md b/docs/guides/concurrency.md
deleted file mode 100644
index db53fdc..0000000
--- a/docs/guides/concurrency.md
+++ /dev/null
@@ -1,401 +0,0 @@
-# Concurrency and rate limits
-
-Control how often a function runs and how many identical calls run at once.
-
-| Decorator | One-line behaviour |
-| --- | --- |
-| [`rate_limit`](#rate_limit) | At most N calls per period; reject or wait. |
-| [`RateLimiter`](#one-budget-for-several-functions-ratelimiter) | One rate limit shared by several functions and blocks. |
-| [`throttle`](#throttle) | At most one run per interval; skipped calls return the last result. |
-| [`debounce`](#debounce) | Run once, after calls have stopped for a while. |
-| [`singleflight`](#singleflight) | Identical concurrent calls share one execution. |
-| [`run_in_thread`](#run_in_thread) | Start the call in a new thread and get a `Future`. |
-
-For `timeout`, see [Resilience](resilience.md#timeout).
-
-!!! note "State belongs to the function"
- The state kept by `rate_limit`, `throttle` and `debounce` belongs to the decorated function. On a **method** it is shared by every instance. `rate_limit` (and `singleflight`) take `key=` to split it. See [Limitations](../limitations.md#shared-state-on-methods).
-
-## `rate_limit`
-
-Allow at most `calls` calls per `period` seconds, using a sliding window. Unlike a fixed window, it never lets a burst of `2 * calls` through at a window boundary.
-
-=== "Sync"
-
- ```python
- from python_wrap import RateLimitExceeded, rate_limit
-
- @rate_limit(2, 60)
- def send(text: str) -> str:
- return text
-
- send("one")
- send("two")
- try:
- send("three")
- except RateLimitExceeded as exc:
- assert 0 < exc.retry_after <= 60 # seconds until a slot frees
- else:
- raise AssertionError("expected the limit to apply")
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import RateLimitExceeded, rate_limit
-
- @rate_limit(1, 60)
- async def send(text: str) -> str:
- return text
-
- async def main() -> None:
- await send("one")
- try:
- await send("two")
- except RateLimitExceeded:
- pass
- else:
- raise AssertionError("expected the limit to apply")
-
- asyncio.run(main())
- ```
-
-By default an over-limit call raises `RateLimitExceeded`; its `retry_after` attribute says how many seconds until a slot frees. Rejected calls are not counted.
-
-### Waiting instead of rejecting
-
-With `block=True` the call waits until it is allowed. Async functions wait with `await`, so the event loop is never blocked.
-
-```python
-from python_wrap import rate_limit
-
-@rate_limit(1, 0.02, block=True)
-def tick() -> int:
- return 1
-
-assert tick() + tick() == 2 # the second call waits about 20 ms
-```
-
-### One limit per user
-
-`key=` receives the call's arguments (including `self` for methods) and returns a hashable bucket key. Each key gets its own limit.
-
-```python
-from python_wrap import RateLimitExceeded, rate_limit
-
-@rate_limit(1, 60, key=lambda user, *_: user)
-def send(user: str, text: str) -> None: ...
-
-send("ana", "hi")
-send("ben", "hi") # a different bucket
-try:
- send("ana", "again")
-except RateLimitExceeded:
- pass
-else:
- raise AssertionError("ana is over her limit")
-```
-
-!!! tip "Per-instance limits on methods"
- Use `key=lambda self, *a, **kw: id(self)` (or an identifying attribute) to give each instance its own limit.
-
-### Several limits at once
-
-APIs often have more than one quota, such as "5 per second and 600 per 15 minutes". Pass them all with `limits=`; a call goes through only when **every** limit allows it, and a rejected call uses up none of them. `retry_after` (and the wait with `block=True`) is the time until all of them allow a call.
-
-```python
-from python_wrap import RateLimitExceeded, rate_limit
-
-@rate_limit(limits=[(5, 1.0), (600, 900.0)])
-def strava(activity: int) -> int:
- return activity
-
-for n in range(5):
- strava(n)
-try:
- strava(5) # the per-second limit is used up
-except RateLimitExceeded as exc:
- assert 0 < exc.retry_after <= 1
-else:
- raise AssertionError("expected the limit to apply")
-```
-
-`limits=` can also be combined with the positional pair: `rate_limit(5, 1.0, limits=[(600, 900.0)])`.
-
-### One budget for several functions: `RateLimiter`
-
-`rate_limit` gives each decorated function its own budget. When several functions call the same service, create one `RateLimiter` and use it everywhere: as a decorator, as `with limiter:` / `async with limiter:` around a block, or through its methods.
-
-```python
-from python_wrap import RateLimitExceeded
-from python_wrap.concurrency import RateLimiter
-
-github = RateLimiter(2, 60, name="github")
-
-@github
-def get_repo(name: str) -> str:
- return name
-
-@github
-def get_user(name: str) -> str:
- return name
-
-get_repo("python-wrap")
-get_user("ana") # same budget: 2 of 2 used
-try:
- with github: # a plain block counts against it too
- pass
-except RateLimitExceeded as exc:
- assert "github rate limit" in str(exc)
-else:
- raise AssertionError("expected the shared limit to apply")
-```
-
-`RateLimiter(calls, period, *, limits=None, block=False, name=None)` takes the same limits as `rate_limit`. `block` decides what the decorator and `with` do when over the limit (raise, or wait). For explicit control:
-
-| Method | Behaviour |
-| --- | --- |
-| `try_acquire()` | Take a slot if one is free now; returns `True` or `False`. |
-| `acquire(block=True, timeout=None)` | Wait in this thread for a slot. With `timeout`, returns `False` as soon as the next slot is further away than that. |
-| `await acquire_async(block=True, timeout=None)` | The same for async code; never blocks the event loop. |
-
-```python
-import asyncio
-from python_wrap.concurrency import RateLimiter
-
-limiter = RateLimiter(1, 0.02, block=True)
-
-async def main() -> None:
- async with limiter:
- pass
- async with limiter: # waits about 20 ms, without blocking the loop
- pass
- assert not limiter.try_acquire()
- assert await limiter.acquire_async(timeout=1)
-
-asyncio.run(main())
-```
-
-### Rate limits in tests and metrics
-
-`python_wrap.testing.disable_rate_limits()` makes `rate_limit`, `RateLimiter` and `throttle` let every call through. The switch is read at call time, so it also works on functions decorated at import. Under `fast_mode()` limiters keep counting: a non-blocking call over the limit is still rejected, and a `block=True` call that would wait goes through at once (and is recorded). See [Testing](../testing.md).
-
-To keep limits on in a test but start each test with a fresh budget, call `RateLimiter.reset()`: it forgets every recorded call, for every key. Use a shared `RateLimiter` you can reach from the test, or get the limiter of a `rate_limit`-decorated function with `limiter_of(func)` (it returns `None` for a function without one; `throttle` has no reset):
-
-```python
-from python_wrap.concurrency import limiter_of, rate_limit
-
-@rate_limit(1, 60)
-def send() -> str:
- return "sent"
-
-assert send() == "sent"
-limiter = limiter_of(send)
-assert limiter is not None
-limiter.reset() # e.g. in a fixture, between tests
-assert send() == "sent"
-```
-
-Rate limits emit `rate_limit.rejected` (`retry_after`) when a call is refused and `rate_limit.waited` (`seconds`) after a call had to wait; `throttle` emits `throttle.skipped` for each skipped call. See [Observability](observability.md).
-
-## `throttle`
-
-Run at most once per `interval` seconds. The first call runs and opens a window; calls inside the window do not run and return **the result of the last call that did run** (`None` if none has finished yet, or it raised).
-
-=== "Sync"
-
- ```python
- from python_wrap import throttle
-
- runs = []
-
- @throttle(60)
- def refresh() -> int:
- runs.append(1)
- return len(runs)
-
- assert refresh() == 1
- assert refresh() == 1 # skipped: returns the previous result
- assert len(runs) == 1
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import throttle
-
- runs = []
-
- @throttle(60)
- async def refresh() -> int:
- runs.append(1)
- return len(runs)
-
- async def main() -> None:
- assert await refresh() == 1
- assert await refresh() == 1
- assert len(runs) == 1
-
- asyncio.run(main())
- ```
-
-A call that raises still uses up its window. The wrapped function returns `R | None` for the type checker.
-
-Use `throttle` for "update the progress bar", "sync at most once a minute" and similar. Use `rate_limit` when excess calls must be rejected or delayed rather than silently skipped.
-
-## `debounce`
-
-Postpone running until `wait` seconds have passed since the **last** call. Each call restarts the countdown and replaces the pending arguments, so only the final call of a burst runs. This is the right tool for "save after the user stops typing".
-
-=== "Sync"
-
- ```python
- import time
- from python_wrap import debounce
-
- saved = []
-
- @debounce(0.02)
- def save(text: str) -> None:
- saved.append(text)
-
- save("a")
- save("ab")
- save("abc") # each call returns None immediately
- time.sleep(0.04) # the timer thread runs the last call
- assert saved == ["abc"]
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import debounce
-
- saved = []
-
- @debounce(0.02)
- async def save(text: str) -> str:
- saved.append(text)
- return text
-
- async def main() -> None:
- # Every caller in a burst awaits the same outcome: the final call's result.
- results = await asyncio.gather(save("a"), save("ab"), save("abc"))
- assert results == ["abc", "abc", "abc"]
- assert saved == ["abc"]
-
- asyncio.run(main())
- ```
-
-The wrapper also has:
-
-- `.cancel()` drops the pending call (async awaiters get `CancelledError`).
-- `.flush()` runs the pending call right now and returns its result, or `None` if nothing is pending. For `async def` functions it is a coroutine to `await`.
-
-```python
-from python_wrap import debounce
-
-saved = []
-
-@debounce(60)
-def save(text: str) -> None:
- saved.append(text)
-
-save("draft")
-save("final")
-save.flush() # run now instead of waiting a minute
-assert saved == ["final"]
-
-save("never saved")
-save.cancel() # drop it
-assert saved == ["final"]
-```
-
-Sync debounced functions run on a daemon timer thread, so the function body runs on a different thread from the caller. Calls must be awaited (or wrapped in tasks) for async debounce to take effect.
-
-## `singleflight`
-
-While a call for a given key is running, further calls with the same key wait and receive **the same result or exception**. Once it finishes, the next call runs fresh. This de-duplicates work in flight; it is not a cache. Use it to stop a thundering herd of identical requests (a cache miss that every request tries to fill).
-
-=== "Sync"
-
- ```python
- import time
- from concurrent.futures import ThreadPoolExecutor
- from python_wrap import singleflight
-
- calls = []
-
- @singleflight
- def load(path: str) -> str:
- calls.append(path)
- time.sleep(0.03)
- return f"contents of {path}"
-
- with ThreadPoolExecutor(3) as pool:
- results = list(pool.map(load, ["a.txt"] * 3))
-
- assert results == ["contents of a.txt"] * 3
- assert len(calls) == 1
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import singleflight
-
- calls = []
-
- @singleflight
- async def load(path: str) -> str:
- calls.append(path)
- await asyncio.sleep(0.01)
- return f"contents of {path}"
-
- async def main() -> None:
- results = await asyncio.gather(*(load("a.txt") for _ in range(3)))
- assert results == ["contents of a.txt"] * 3
- assert calls == ["a.txt"]
-
- asyncio.run(main())
- ```
-
-Sync functions de-duplicate across threads. Async functions de-duplicate across tasks on the same event loop; the work runs in a shared task, so cancelling one waiting caller does not cancel it for the others.
-
-The default key is the positional arguments plus sorted keyword arguments (including `self` for methods), which must be hashable. Pass `key=` to choose what counts as "the same call", for example `key=lambda path, **_: path`.
-
-## `run_in_thread`
-
-Start each call in a new thread and get a `concurrent.futures.Future` back.
-
-```python
-from python_wrap import run_in_thread
-
-@run_in_thread
-def add(a: int, b: int) -> int:
- return a + b
-
-future = add(1, 2)
-assert future.result() == 3 # waits; re-raises the function's exception, if any
-```
-
-- `future.result()` is the equivalent of v0.1's `thread.join()`, plus the return value.
-- `async def` functions run with `asyncio.run` in the new thread (their own event loop), and the wrapper itself is a plain function returning a `Future`.
-- The caller's `contextvars` are copied into the thread.
-- Options: `daemon=True` (do not keep the interpreter alive) and `name=` (thread name).
-
-```python
-import asyncio
-from python_wrap import run_in_thread
-
-@run_in_thread(daemon=True, name="worker")
-async def fetch() -> str:
- await asyncio.sleep(0.01)
- return "done"
-
-assert fetch().result() == "done"
-```
diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md
deleted file mode 100644
index 2eb27d4..0000000
--- a/docs/guides/configuration.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Project-wide configuration
-
-Repeating `retry(max_attempts=5, delay=0.5, ...)` or `redact=(...)` on every decorator is how defaults drift. `python_wrap.config` sets them once.
-
-```python
-import logging
-from python_wrap.config import configure, get_config, reset_config
-
-configure(
- logger=logging.getLogger("myapp"),
- retry={"max_attempts": 5, "delay": 0.5, "backoff": 2.0, "max_time": 30},
- redact_extra=["ssn", "iban"],
-)
-assert get_config()["retry"]["max_attempts"] == 5
-reset_config()
-```
-
-## When values are read
-
-Call `configure` **once, at start-up, before you decorate anything**. Defaults are read when a decorator is *applied* (at import time for module-level functions), not on every call. A decorator applied earlier keeps the settings it saw. An explicit argument always beats a configured default.
-
-```python
-from python_wrap import retry
-from python_wrap.config import configure, reset_config
-
-@retry
-def early() -> None: ... # 3 attempts: applied before configure()
-
-configure(retry={"max_attempts": 7})
-
-@retry
-def late() -> None: ... # 7 attempts
-
-@retry(max_attempts=2)
-def explicit() -> None: ... # 2 attempts: explicit wins
-
-reset_config()
-```
-
-## Keys
-
-| Key | Effect |
-| --- | --- |
-| `logger` | Logger (or name) used by every decorator that logs and was not given `logger=`. `None` restores the package logger. |
-| `retry` | Dict of defaults for `retry` and `retrying`: `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time`, `log_level`. |
-| `redact` | Replaces the built-in `DEFAULT_REDACT` list for `log_args` and `audit`. `None` restores it. |
-| `redact_extra` | Names **added** to the redaction list (the built-ins stay), for `log_args` and `audit`. |
-
-Each key you pass replaces that key as a whole (`retry={...}` replaces the previous retry defaults); keys you don't mention are untouched. Unknown keys raise `TypeError` with a hint:
-
-```python
-from python_wrap.config import configure
-
-try:
- configure(retyr={"max_attempts": 3})
-except TypeError as exc:
- assert "Did you mean 'retry'" in str(exc)
-```
-
-Retry defaults are validated immediately (`ValueError` for `max_attempts=0`, and so on). Because `redact=` on a decorator replaces the list, prefer `redact_extra` in project settings; it is also what `redact_extra=` does on `log_args` and `audit` themselves.
-
-## In tests
-
-`configured()` applies defaults for a block and restores the previous state on exit, even after an error. Apply the decorators inside the block.
-
-```python
-from python_wrap import retry
-from python_wrap.config import configured, get_config
-
-with configured(retry={"max_attempts": 1}):
- @retry
- def once() -> None: ...
-
-assert get_config()["retry"] == {}
-```
-
-`reset_config()` forgets everything; `get_config()` returns a copy of what is in force.
diff --git a/docs/guides/devtools.md b/docs/guides/devtools.md
deleted file mode 100644
index f705010..0000000
--- a/docs/guides/devtools.md
+++ /dev/null
@@ -1,194 +0,0 @@
-# Developer tools
-
-Tools for evolving an API, faking data, and keeping decorator stacks tidy.
-
-## `deprecate`
-
-Mark a function, method or class as deprecated. Every call (or, for `async def`, every await) emits a warning that **points at the caller's line**, not at the library, so users see which of their code to change. The wrapped object also gets a `__deprecated__` attribute (PEP 702).
-
-```python
-import warnings
-from python_wrap import deprecate
-
-@deprecate(since="0.2", removed_in="1.0", alternative="new_func")
-def old_func() -> int:
- return 1
-
-with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- assert old_func() == 1
-
-assert caught[0].category is DeprecationWarning
-text = str(caught[0].message)
-assert "old_func" in text and "0.2" in text and "1.0" in text and "new_func" in text
-```
-
-The structured parts are combined into `" is deprecated since ... and will be removed in ...; use ... instead."`. Pass a message to replace the generated text, and `category=` to change the warning class:
-
-=== "Message"
-
- ```python
- import warnings
- from python_wrap import deprecate
-
- @deprecate("fetch() is going away; call get() instead")
- def fetch() -> None: ...
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- fetch()
- assert str(caught[0].message) == "fetch() is going away; call get() instead"
- ```
-
-=== "Class"
-
- ```python
- import warnings
- from python_wrap import deprecate
-
- @deprecate("Use Widget2 instead")
- class Widget:
- pass
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- widget = Widget()
-
- assert isinstance(widget, Widget) # still a class, isinstance still works
- assert "Widget2" in str(caught[0].message)
- ```
-
-=== "Category"
-
- ```python
- import warnings
- from python_wrap import deprecate
-
- @deprecate(since="0.2", category=FutureWarning)
- def shown_to_end_users() -> None: ...
-
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- shown_to_end_users()
- assert caught[0].category is FutureWarning
- ```
-
-Classes are changed in place: their `__init__` warns. Remember that Python hides `DeprecationWarning` by default outside `__main__` and test runners; use `category=FutureWarning` when end users, not developers, should see it.
-
-## `mock_data`
-
-Replace a function's result with fixed data, for tests and local development. When enabled, the real function is **not called**.
-
-```python
-import os
-from python_wrap import mock_data
-
-@mock_data({"id": 1, "name": "Ada"}, enabled=lambda: os.environ.get("USE_MOCKS") == "1")
-def fetch_user(user_id: int) -> dict:
- raise RuntimeError("would call the real API")
-
-os.environ["USE_MOCKS"] = "1"
-assert fetch_user(99) == {"id": 1, "name": "Ada"}
-
-os.environ["USE_MOCKS"] = "0"
-try:
- fetch_user(99)
-except RuntimeError:
- pass # disabled: the real function ran
-else:
- raise AssertionError("expected the real function to run")
-```
-
-- `enabled` is `True`, `False`, or a zero-argument callable evaluated **on every call**, so it can read an environment variable or a settings flag.
-- If `data` is callable it is called on each call and its result is returned. To return a callable itself, wrap it: `mock_data(lambda: my_fn)`.
-- Works for `async def` functions (the awaited result is the data).
-
-```python
-import asyncio
-from python_wrap import mock_data
-
-@mock_data(lambda: [1, 2, 3]) # a fresh list on every call
-async def numbers() -> list[int]:
- raise RuntimeError
-
-assert asyncio.run(numbers()) == [1, 2, 3]
-```
-
-!!! danger "Never ship it enabled"
- `mock_data` silently replaces real behaviour. Tie `enabled=` to an explicit opt-in such as an environment variable that is off by default, never a default of `True` in code that reaches production. In tests, see [Testing](../testing.md#disabling-mock_data).
-
-## `compose`
-
-Combine decorators into one. `compose(a, b, c)(f)` is exactly `a(b(c(f)))`, so list them outermost first, as if stacked top to bottom.
-
-```python
-from python_wrap import compose, memoize, retry, validate_args
-
-resilient_lookup = compose(
- memoize(ttl=60),
- retry(max_attempts=3, delay=0.01),
- validate_args(key=lambda k: k != ""),
-)
-
-@resilient_lookup
-def lookup(key: str) -> str:
- return key.upper()
-
-@resilient_lookup
-def lookup_other(key: str) -> str:
- return key.lower()
-
-assert lookup("a") == "A"
-assert lookup_other("A") == "a"
-```
-
-Define a stack once and reuse it across your codebase. `compose()` with no decorators returns the function unchanged, and a non-callable argument raises `TypeError`. The order rules in [Stacking decorators](../stacking.md) apply to the arguments.
-
-!!! note "Share state deliberately"
- `compose(memoize(...))` creates its `memoize` decorator once, but `memoize` still gives each decorated function its own cache. A `CircuitBreaker` instance, on the other hand, is shared by everything it decorates. That is usually what you want for a breaker, but check it.
-
-## `wrap_methods`
-
-Apply decorators to the methods of a class in one line. Decorators are listed outermost first, like `compose`, and a [policy](policies.md) works as well.
-
-```python
-import asyncio
-from python_wrap import policy, retry, timeout, wrap_methods
-
-calls = []
-
-@wrap_methods(
- policy(retry=retry(max_attempts=2, delay=0)),
- exclude=["close"],
-)
-class Client:
- def get(self, key: str) -> str:
- calls.append(key)
- return key
-
- async def fetch(self, key: str) -> str:
- return key
-
- @staticmethod
- def parse(raw: str) -> int:
- return int(raw)
-
- def close(self) -> None: ...
-
-client = Client()
-assert client.get("a") == "a"
-assert asyncio.run(client.fetch("b")) == "b"
-assert Client.parse("3") == 3
-```
-
-Plain, `async def`, `@staticmethod` and `@classmethod` methods are decorated correctly (the underlying function is wrapped and put back in its descriptor). By default these are skipped: dunder methods, names starting with `_`, properties, non-function attributes and inherited methods.
-
-| Option | Meaning |
-| --- | --- |
-| `include` | Names (list/set) or a predicate. Restricts the selection. Naming a dunder or a property here opts it in. |
-| `exclude` | Names or a predicate to leave alone. Wins over `include`. |
-| `include_private` | Also select `_single_underscore` methods. |
-
-## `no_debug`
-
-A deprecated no-op kept so v0.1 code keeps importing. It returns the function unchanged and warns when applied. It will be removed in 0.3. To hide debug output, configure [logging levels](observability.md#configuring-logging) instead.
diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md
deleted file mode 100644
index 1fd5793..0000000
--- a/docs/guides/metrics.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# Metrics and events
-
-Two tools for seeing what your code does in production, with no dependencies:
-
-- `count_calls` keeps in-process call counts and latency percentiles for one function.
-- `python_wrap.events` publishes what the resilience decorators do (retries, cache hits, circuit changes, rejections) so you can forward them to Prometheus, OpenTelemetry, statsd or your logs.
-
-## `count_calls`
-
-```python
-from python_wrap.metrics import count_calls
-
-@count_calls(window=500)
-def work(n: int) -> int:
- if n < 0:
- raise ValueError("negative")
- return n * 2
-
-work(1)
-work(2)
-try:
- work(-1)
-except ValueError:
- pass
-
-stats = work.stats
-assert (stats.calls, stats.errors, stats.in_flight) == (3, 1, 0)
-assert stats.last_duration >= 0
-assert 0 <= stats.p50 <= stats.p95 <= stats.p99
-work.reset()
-assert work.stats.calls == 0
-```
-
-`.stats` is a live view with `calls`, `errors`, `in_flight`, `last_duration`, `mean`, `p50`, `p95` and `p99` (seconds). `calls` and `errors` count everything since the last `reset()`; the latency figures cover the last `window` finished calls (default 1000), failed ones included. Percentiles use the nearest-rank method. All of it is thread-safe.
-
-`async def` functions work the same, timed across the whole awaited coroutine, and `in_flight` shows how many calls are running right now:
-
-```python
-import asyncio
-from python_wrap.metrics import count_calls
-
-@count_calls
-async def fetch(n: int) -> int:
- await asyncio.sleep(0)
- return n
-
-async def main() -> None:
- await asyncio.gather(*(fetch(i) for i in range(5)))
- assert fetch.stats.calls == 5
-
-asyncio.run(main())
-```
-
-On methods the statistics belong to the function, so they are shared by every instance. Cancellation counts as an error.
-
-## Events
-
-Decorators publish `Event(name, function, fields)` objects. Subscribe to receive them:
-
-```python
-from python_wrap.retry import retry
-from python_wrap.events import Event, listening
-
-seen: list[Event] = []
-calls = 0
-
-@retry(max_attempts=3, delay=0)
-def flaky() -> str:
- global calls
- calls += 1
- if calls < 3:
- raise ConnectionError("down")
- return "ok"
-
-with listening(seen.append):
- assert flaky() == "ok"
-
-names = [e.name for e in seen]
-assert names == ["retry.attempt_failed", "retry.attempt_failed", "retry.succeeded"]
-assert seen[0].fields["attempt"] == 1
-```
-
-- `subscribe(listener)` registers a listener and returns a function that unsubscribes it.
-- `listening(listener)` is the same as a `with` block.
-- `filter_events("retry.", listener)` wraps a listener so it only sees names with that prefix (or any of a tuple of prefixes).
-
-Listeners run synchronously in the thread that triggered the event, so keep them fast and never block. An exception in a listener is logged and swallowed: monitoring code can not break the decorated function. With no listeners registered, emitting is a single list check.
-
-```python
-from python_wrap.events import Event, filter_events, subscribe
-
-retries: list[Event] = []
-unsubscribe = subscribe(filter_events("retry.", retries.append))
-unsubscribe()
-```
-
-### Event catalogue
-
-`python_wrap.events.EVENT_NAMES` maps each name to a description; this table is the same data. `Event.function` is the decorated function's qualified name (for `circuit.*` events it is the breaker's name).
-
-| Event | Fields |
-| --- | --- |
-| `retry.attempt_failed` | `attempt` (int, 1-based), `exception`, `next_delay` (seconds) |
-| `retry.succeeded` | `attempts` (total made) |
-| `retry.gave_up` | `attempts`, `exception` |
-| `timeout.expired` | `seconds` (the limit) |
-| `circuit.state_changed` | `old`, `new` (`closed`, `open`, `half_open`) |
-| `circuit.rejected` | `retry_after` (seconds) |
-| `rate_limit.rejected` | `retry_after` (seconds) |
-| `rate_limit.waited` | `seconds` |
-| `throttle.skipped` | none |
-| `cache.hit` | none |
-| `cache.miss` | none |
-| `cache.evicted` | `reason` (`lru` or `ttl`) |
-| `bulkhead.rejected` | `active`, `waiting` |
-
-```python
-from python_wrap.events import EVENT_NAMES
-
-assert "cache.hit" in EVENT_NAMES
-for name, description in EVENT_NAMES.items():
- assert description
-```
-
-## Recipes
-
-None of these libraries are dependencies of python-wrap, so the blocks below are not run by the docs checker. Each is a few lines: subscribe once at start-up.
-
-### Prometheus
-
-
-```python
-from prometheus_client import Counter
-from python_wrap.events import Event, subscribe
-
-_counters: dict[str, Counter] = {}
-
-def to_prometheus(event: Event) -> None:
- name = "python_wrap_" + event.name.replace(".", "_") + "_total"
- counter = _counters.get(name)
- if counter is None:
- counter = _counters[name] = Counter(name, event.name, ["function"])
- counter.labels(function=event.function).inc()
-
-subscribe(to_prometheus)
-```
-
-Use a fixed set of names (for instance built from `EVENT_NAMES`) to avoid the lookup race on first use, and keep `function` as the only label: never label with exception messages.
-
-### OpenTelemetry
-
-
-```python
-from opentelemetry import metrics, trace
-from python_wrap.events import Event, subscribe
-
-counter = metrics.get_meter("python_wrap").create_counter("python_wrap.events")
-
-def to_otel(event: Event) -> None:
- counter.add(1, {"event": event.name, "function": event.function})
- span = trace.get_current_span() # annotate the active span, if any
- if span.is_recording():
- span.add_event(event.name, {"function": event.function})
-
-subscribe(to_otel)
-```
-
-### statsd
-
-
-```python
-from statsd import StatsClient
-from python_wrap.events import Event, subscribe
-
-statsd = StatsClient()
-
-def to_statsd(event: Event) -> None:
- statsd.incr(f"python_wrap.{event.name}")
-
-subscribe(to_statsd)
-```
-
-### structlog or logging
-
-
-```python
-import structlog
-from python_wrap.events import Event, subscribe
-
-log = structlog.get_logger("python_wrap.events")
-
-def to_structlog(event: Event) -> None:
- fields = {k: v for k, v in event.fields.items() if k != "exception"}
- log.info(event.name, function=event.function, **fields)
-
-subscribe(to_structlog)
-```
-
-With the standard library instead, call `logging.getLogger("myapp.events").info("%s %s", event.name, event.function, extra={"fields": event.fields})`.
diff --git a/docs/guides/observability.md b/docs/guides/observability.md
deleted file mode 100644
index 24f813d..0000000
--- a/docs/guides/observability.md
+++ /dev/null
@@ -1,369 +0,0 @@
-# Observability
-
-Find out how long calls take, how much they allocate, what they were called with, and who called them.
-
-## Configuring logging
-
-python-wrap **never configures logging**. Importing it does not touch the root logger, and the `python_wrap` logger only carries a `NullHandler`. Until your application sets logging up, the decorators below are silent.
-
-Every logging decorator takes two options:
-
-- `logger=` a `logging.Logger`, a logger name, or `None` for the package logger `python_wrap`.
-- `level=` the log level (`timing` and `audit` default to `INFO`; `log_args`, `log_return` and `trace` default to `DEBUG`).
-
-Turn everything on for development:
-
-
-```python
-import logging
-
-logging.basicConfig(level=logging.DEBUG)
-```
-
-Or route and filter like any other library:
-
-
-```python
-import logging
-
-# Only python-wrap's messages, to their own handler, at their own level.
-logger = logging.getLogger("python_wrap")
-handler = logging.StreamHandler()
-handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
-logger.addHandler(handler)
-logger.setLevel(logging.INFO)
-logger.propagate = False
-
-# Or silence it completely.
-logging.getLogger("python_wrap").setLevel(logging.CRITICAL + 1)
-```
-
-To send one decorator's output somewhere specific, name the logger:
-
-```python
-import logging
-from python_wrap import timing
-
-@timing(logger="myapp.perf", level=logging.DEBUG)
-def work() -> None: ...
-
-work()
-```
-
-The examples below attach a small handler that collects log records, so they can show exactly what is logged. In your own code you configure logging once, as above.
-
-## `timing`
-
-Measure and log how long each call takes. Async functions are timed across the whole awaited coroutine. A call that raises is logged as ` raised ValueError after 12.34 ms` and the exception propagates.
-
-=== "Sync"
-
- ```python
- import logging
- from python_wrap import TimingRecord, timing
-
- records: list[TimingRecord] = []
-
- @timing(logger="demo", threshold=0.5, callback=records.append)
- def fast() -> int:
- return 1
-
- fast()
- assert records[0].success is True
- assert records[0].name.endswith("fast")
- assert records[0].elapsed < 0.5
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import TimingRecord, timing
-
- records: list[TimingRecord] = []
-
- @timing(callback=records.append)
- async def pause() -> None:
- await asyncio.sleep(0.02)
-
- asyncio.run(pause())
- assert records[0].elapsed >= 0.015 # the awaited time is measured
- ```
-
-| Option | Meaning |
-| --- | --- |
-| `threshold` | Only log successful calls slower than this many seconds. Failed calls are always logged. |
-| `callback` | Called with a `TimingRecord(name, elapsed, success)` for **every** call, regardless of `threshold`. Use it to feed a metrics system. Errors in the callback are logged and swallowed. |
-
-`timing` uses `time.perf_counter`.
-
-## `benchmark`
-
-Run a function repeatedly and get statistics. It is a **plain function, not a decorator** (see [Migration](../migration.md#benchmark-is-a-function)).
-
-=== "Sync"
-
- ```python
- from python_wrap import benchmark
-
- result = benchmark(sorted, args=([3, 1, 2],), runs=10, warmup=2)
- assert result.runs == 10
- assert result.min <= result.median <= result.max
- print(result) # sorted: 10 runs, mean 1.2 us, median 1.1 us, ...
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import benchmark_async
-
- async def work(n: int) -> int:
- await asyncio.sleep(0)
- return n
-
- async def main() -> None:
- result = await benchmark_async(work, args=(1,), runs=3)
- assert result.runs == 3
-
- asyncio.run(main())
- ```
-
-`BenchmarkResult` has `name`, `runs`, `timings`, `mean`, `median`, `min`, `max` and `stdev`, all in seconds. `benchmark` raises `TypeError` for an `async def` function (use `benchmark_async`).
-
-## `measure_memory`
-
-Measure the Python memory a call allocates, using `tracemalloc`. Works on every OS and logs ` peak memory 1.00 MiB (net +12.00 KiB)`.
-
-```python
-from python_wrap import MemoryRecord, measure_memory
-
-records: list[MemoryRecord] = []
-
-@measure_memory(callback=records.append)
-def build() -> list[int]:
- return [0] * 100_000
-
-data = build()
-assert records[0].peak_bytes >= 100_000 * 8 * 0.5 # at least roughly the list's size
-assert records[0].net_bytes > 0 # the list is still alive
-```
-
-`peak_bytes` is the highest traced allocation during the call, relative to its start; `net_bytes` is the memory still held at the end minus at the start (it can be negative).
-
-!!! note
- `tracemalloc` is process-global. Allocations by other threads or tasks while the call runs are counted too, and tracing slows the program. See [Limitations](../limitations.md#measure_memory-and-profile).
-
-## `profile`
-
-Profile each call with `cProfile` and log the top rows.
-
-```python
-import pstats
-from python_wrap import profile
-
-captured: list[pstats.Stats] = []
-
-@profile(sort="tottime", limit=5, callback=captured.append)
-def work() -> int:
- return sum(i * i for i in range(1000))
-
-work()
-assert isinstance(captured[0], pstats.Stats)
-```
-
-`sort` is any `pstats.SortKey` value (`"cumulative"` by default, or `"tottime"`, `"calls"`, ...); `limit` is the number of rows logged (`None` for all). If another profiler is already active, a warning is logged once and the function runs unprofiled.
-
-## `log_args` and `log_return`
-
-`log_args` logs each call's arguments by parameter name, before running it. `log_return` logs the return value, or the exception type if it raises.
-
-```python
-import logging
-from python_wrap import log_args, log_return
-
-class ListHandler(logging.Handler):
- def __init__(self) -> None:
- super().__init__(logging.DEBUG)
- self.messages: list[str] = []
-
- def emit(self, record: logging.LogRecord) -> None:
- self.messages.append(record.getMessage())
-
-log = logging.getLogger("demo")
-log.setLevel(logging.DEBUG)
-handler = ListHandler()
-log.addHandler(handler)
-
-@log_args(logger=log)
-@log_return(logger=log)
-def add(a: int, b: int) -> int:
- return a + b
-
-add(1, b=2)
-assert "Calling add(a=1, b=2)" in handler.messages
-assert "add returned 3" in handler.messages
-```
-
-Both truncate long values (`max_length`, default 200), and both default to `DEBUG` (in v0.1, `log_args` logged at `INFO`).
-
-### Redaction
-
-`log_args` (and `audit`) mask parameters whose **name contains** any of the redaction fragments, case-insensitively. The masked value is shown as `'***'`.
-
-The default list is `DEFAULT_REDACT`: `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `authorization`, `auth`, `credential`, `private_key`, `cookie`, `session`.
-
-```python
-import logging
-from python_wrap import DEFAULT_REDACT, log_args
-
-class ListHandler(logging.Handler):
- def __init__(self) -> None:
- super().__init__(logging.DEBUG)
- self.messages: list[str] = []
-
- def emit(self, record: logging.LogRecord) -> None:
- self.messages.append(record.getMessage())
-
-log = logging.getLogger("redaction-demo")
-log.setLevel(logging.DEBUG)
-sink = ListHandler()
-log.addHandler(sink)
-
-@log_args(logger=log)
-def login(user: str, password: str) -> None: ...
-
-login("ana", "hunter2")
-assert "hunter2" not in sink.messages[0]
-assert "password='***'" in sink.messages[0]
-
-# Extend the defaults, or pass () to disable redaction.
-@log_args(logger=log, redact=(*DEFAULT_REDACT, "ssn"))
-def register(name: str, ssn: str) -> None: ...
-
-register("ana", "123-45-6789")
-assert "123-45-6789" not in sink.messages[1]
-```
-
-To **add** names without replacing the defaults, use `redact_extra=`, on `log_args` and on `audit`:
-
-```python
-from python_wrap import audit
-
-records = []
-
-@audit(redact_extra=["ssn"], sink=records.append)
-def register(name: str, ssn: str, password: str) -> None: ...
-
-register("ana", "123-45-6789", "hunter2")
-args = records[0].arguments
-assert args["ssn"] == "***" and args["password"] == "***" and args["name"] == "ana"
-```
-
-To apply the same names to every `log_args` and `audit` in the project, set them once with `configure(redact_extra=[...])` (or replace the whole list with `configure(redact=[...])`); see [Project-wide configuration](configuration.md).
-
-!!! warning "`redact=` replaces the defaults"
- Passing `redact=("ssn",)` masks only `ssn`; `password` is no longer masked. Prefer `redact_extra=`. Redaction works by parameter **name**; it does not scan values, and it does not touch what the function itself logs or returns.
-
-## `audit`
-
-Record who called a function, with what, and how it ended. Each call produces an `AuditRecord`, delivered to your `sink`, and also attached to the log line as `LogRecord.audit`.
-
-```python
-from python_wrap import AuditRecord, audit
-
-records: list[AuditRecord] = []
-
-@audit(sink=records.append, include_result=True)
-def delete_user(user_id: int, token: str) -> str:
- if user_id < 0:
- raise ValueError("bad id")
- return "deleted"
-
-delete_user(7, token="abc123")
-try:
- delete_user(-1, token="abc123")
-except ValueError:
- pass
-
-ok, failed = records
-assert ok.status == "success" and ok.result == "deleted"
-assert ok.arguments == {"user_id": 7, "token": "***"} # sensitive values are masked
-assert failed.status == "error" and "ValueError" in failed.error
-assert ok.caller.endswith(tuple("0123456789")) # "file:line" of the calling code
-```
-
-`AuditRecord` fields: `function`, `timestamp` (UTC, timezone-aware), `caller` (`"file:line"`), `arguments`, `status` (`"success"` or `"error"`), `error` (the `repr` of the exception, or `None`), `duration` and `result` (only with `include_result=True`).
-
-An exception raised by the sink is logged and swallowed, so a broken audit store never breaks the audited function. Exceptions from the function itself are recorded and re-raised.
-
-To read the record from a log handler instead, use `record.audit` on the `LogRecord`.
-
-## `trace`
-
-Log entry, the call stack that led to the call, and exit. Three messages per call: `Entering f`, the stack (python-wrap's own frames left out, outermost first), and `Exiting f` (or `Exiting f (raised ValueError)`).
-
-```python
-from python_wrap import trace
-
-@trace(limit=5) # keep the 5 innermost frames
-def handler() -> int:
- return 1
-
-assert handler() == 1
-```
-
-Use it to answer "who is calling this?" while debugging. It defaults to `DEBUG`, so it is silent unless you enable that level.
-
-## `log_context`
-
-Bind fields such as a request id once, and have every python-wrap log line made underneath carry them. It works as a context manager (`with` and `async with`) and as a decorator, and blocks nest.
-
-```python
-import logging
-from python_wrap import log_args
-from python_wrap.logs import current_log_context, log_context
-
-class ListHandler(logging.Handler):
- def __init__(self) -> None:
- super().__init__(logging.DEBUG)
- self.records: list[logging.LogRecord] = []
-
- def emit(self, record: logging.LogRecord) -> None:
- self.records.append(record)
-
-log = logging.getLogger("context-demo")
-log.setLevel(logging.DEBUG)
-handler = ListHandler()
-log.addHandler(handler)
-
-@log_args(logger=log)
-def charge(amount: int) -> None: ...
-
-with log_context(request_id="r-42"):
- with log_context(user="ana"):
- assert current_log_context() == {"request_id": "r-42", "user": "ana"}
- charge(5)
-
-record = handler.records[0]
-assert record.getMessage() == "Calling charge(amount=5) [request_id=r-42 user=ana]"
-assert record.context == {"request_id": "r-42", "user": "ana"} # type: ignore[attr-defined]
-assert current_log_context() == {}
-```
-
-`log_args`, `log_return`, `audit`, `trace` and `timing` append ` [key=value ...]` to their message and attach the dict as `LogRecord.context`, so a JSON formatter can emit the fields as proper keys. `AuditRecord.context` holds the fields bound when the call started (an empty dict when none).
-
-As a decorator, the fields are bound for each call:
-
-```python
-import asyncio
-from python_wrap.logs import current_log_context, log_context
-
-@log_context(job="nightly")
-async def run() -> dict[str, object]:
- return current_log_context()
-
-assert asyncio.run(run()) == {"job": "nightly"}
-```
-
-The fields live in a `contextvars.ContextVar`: every asyncio task and thread sees its own. A new thread starts **empty**; to carry the fields over, start it in a copied context with `contextvars.copy_context().run(...)` (`run_in_thread` and `asyncio.to_thread` already do this).
diff --git a/docs/guides/policies.md b/docs/guides/policies.md
deleted file mode 100644
index 8d96539..0000000
--- a/docs/guides/policies.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Policies
-
-A **policy** is a reusable, named bundle of decorators. You say *which* protections a call needs; the policy applies them in the [recommended order](../stacking.md), however you list them.
-
-```python
-from python_wrap import CircuitBreaker, policy, retry, timeout
-
-payments = policy(
- timeout=timeout(2), # listed first on purpose
- retry=retry(max_attempts=3, delay=0.01, on=ConnectionError),
- breaker=CircuitBreaker(failure_threshold=5, on=ConnectionError),
- name="payments",
-)
-
-@payments
-def charge(amount: int) -> int:
- return amount
-
-assert charge(5) == 5
-print(payments) # Policy('payments', breaker > retry > timeout)
-```
-
-## Why keyword roles
-
-Each decorator goes into a named **role**. The role decides the position, so the code cannot be mis-ordered, and the arguments are checked by the type checker. Guessing a role from a decorator object would be fragile (factories return plain closures), so python-wrap does not guess.
-
-Outermost first:
-
-| Role | Typical decorators | Why here |
-| --- | --- | --- |
-| `observe` | `timing`, `audit`, `log_args` | See the outcome the caller gets, retries included. |
-| `validate` | `validate_args`, `enforce_types` | Bad input is rejected before anything else runs. |
-| `fallback` | `fallback`, `suppress` | Catches what the layers below could not fix. |
-| `cache` | `memoize`, `once`, `singleflight` | A hit skips everything below. |
-| `limiter` | `rate_limit`, `throttle` | One token per logical call, not per attempt. |
-| `bulkhead` | `Bulkhead`, `limit_concurrency` | Caps concurrent logical calls. |
-| `breaker` | `CircuitBreaker`, `circuit_breaker` | Counts calls whose retries were exhausted. |
-| `retry` | `retry` | Repeats everything below. |
-| `timeout` | `timeout` | A deadline per attempt. |
-| `guard` | `revert_on_failure`, `transactional` | Each attempt starts from clean state. |
-
-A role accepts one decorator or a list (outermost first). For a decorator with no role, `outer=` places it outside every role and `inner=` just above the function.
-
-## Deriving variants
-
-Policies are immutable. `with_()` returns a changed copy; `None` removes a role.
-
-```python
-from python_wrap import policy, retry, timeout
-
-base = policy(retry=retry(max_attempts=3, delay=0), timeout=timeout(2), name="io")
-patient = base.with_(timeout=timeout(30), name="io-patient")
-no_retry = base.with_(retry=None)
-
-assert base.order == ("retry", "timeout")
-assert no_retry.order == ("timeout",)
-```
-
-## Async and methods
-
-A policy is a decorator for plain, `async def`, instance, static and class methods, and keeps the function's type. Combine it with [`wrap_methods`](devtools.md#wrap_methods) to protect a whole class.
-
-```python
-import asyncio
-from python_wrap import policy, retry
-
-p = policy(retry=retry(max_attempts=2, delay=0))
-
-@p
-async def load(x: int) -> int:
- return x
-
-assert asyncio.run(load(1)) == 1
-```
-
-!!! note "Shared state"
- A `CircuitBreaker` instance is shared by everything the policy decorates, which is usually what you want for one dependency. Other decorators create state per decorated function.
diff --git a/docs/guides/resilience.md b/docs/guides/resilience.md
deleted file mode 100644
index 7fbd2bf..0000000
--- a/docs/guides/resilience.md
+++ /dev/null
@@ -1,579 +0,0 @@
-# Resilience
-
-Calls to networks, disks and other services fail. These decorators decide what happens next: try again, return a default, give up quietly, stop calling a broken dependency, or stop waiting.
-
-## `retry`
-
-Call the function again when it raises. The last exception is re-raised unchanged, with its original traceback, once attempts run out.
-
-=== "Sync"
-
- ```python
- from python_wrap import retry
-
- calls = []
-
- @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError)
- def fetch() -> str:
- calls.append(1)
- if len(calls) < 3:
- raise ConnectionError("down")
- return "payload"
-
- assert fetch() == "payload"
- assert len(calls) == 3
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import retry
-
- calls = []
-
- @retry(max_attempts=4, delay=0.01, backoff=2, on=ConnectionError)
- async def fetch() -> str:
- calls.append(1)
- if len(calls) < 3:
- raise ConnectionError("down")
- return "payload"
-
- assert asyncio.run(fetch()) == "payload"
- assert len(calls) == 3
- ```
-
-The wait before attempt `n + 1` is `min(delay * backoff ** (n - 1), max_delay) + random.uniform(0, jitter)`.
-
-| Option | Default | Meaning |
-| --- | --- | --- |
-| `max_attempts` | `3` | Total calls allowed. `None` retries forever. |
-| `delay` | `1.0` | Base wait in seconds. |
-| `backoff` | `1.0` | Multiplier applied after each failure (use `2` for exponential). |
-| `max_delay` | `None` | Cap on the backoff wait (before jitter). |
-| `jitter` | `0.0` | Up to this many random extra seconds, so many clients do not retry in lockstep. |
-| `max_time` | `None` | Total time budget in seconds from the first attempt. |
-| `on` | `Exception` | Exception type(s) that trigger a retry. Anything else propagates immediately. |
-| `retry_if` | `None` | Extra predicate on the exception; retry only if it returns `True`. |
-| `on_retry` | `None` | Called with a `RetryState` before each wait. |
-| `delay_from` | `None` | `exc -> seconds or None`: exact wait for this attempt (e.g. `Retry-After`). |
-| `logger` | package logger | Logger or logger name for the per-retry message. |
-| `log_level` | `WARNING` | Level of the per-retry message; `None` disables it. |
-| `log_exc_info` | `False` | Attach the traceback to the per-retry log record. |
-
-`retry` works bare (`@retry`), with options, and as a plain call: `retry(func, max_attempts=5)`.
-
-!!! warning "Retry only what is safe to repeat"
- Retrying a non-idempotent call (charging a card, sending an email) can do it twice. Narrow `on=` to errors that mean "it did not happen", and use `retry_if` to inspect the exception.
-
-### Deciding what is retryable
-
-```python
-from python_wrap import RetryState, retry
-
-class HttpError(Exception):
- def __init__(self, status: int) -> None:
- super().__init__(f"HTTP {status}")
- self.status = status
-
-seen: list[RetryState] = []
-responses = iter([503, 502, 200])
-
-@retry(
- max_attempts=5,
- delay=0.01,
- on=HttpError,
- retry_if=lambda exc: exc.status >= 500,
- on_retry=seen.append,
-)
-def get() -> int:
- status = next(responses)
- if status != 200:
- raise HttpError(status)
- return status
-
-assert get() == 200
-assert [s.attempt for s in seen] == [1, 2]
-assert isinstance(seen[0].exception, HttpError)
-```
-
-`RetryState` carries `attempt`, `exception`, `next_delay` and `elapsed`, which is enough to feed a metric or a custom log line.
-
-### Honour `Retry-After`
-
-Servers that answer 429 or 503 often say how long to wait. Pass `delay_from` a function that maps the exception to a number of seconds, or to `None` to fall back to the normal backoff. A number is the exact wait for that attempt (no backoff, no jitter), still capped by `max_delay` and `max_time`.
-
-`retry_after_header` reads `exc.retry_after` or `exc.response.headers["Retry-After"]` (seconds or an HTTP-date) and never raises, so it works with `httpx.HTTPStatusError`, `requests.HTTPError` and similar without importing them. The example uses a stand-in exception with the same shape:
-
-```python
-from python_wrap import retry
-from python_wrap import retry_after_header
-
-class Response:
- def __init__(self, headers: dict[str, str]) -> None:
- self.headers = headers
-
-class HTTPStatusError(Exception): # shaped like httpx / requests errors
- def __init__(self, headers: dict[str, str]) -> None:
- super().__init__("429 Too Many Requests")
- self.response = Response(headers)
-
-replies = iter([{"Retry-After": "0.01"}, {}, None])
-
-@retry(on=HTTPStatusError, delay=0.01, delay_from=retry_after_header, max_delay=30)
-def fetch() -> str:
- headers = next(replies)
- if headers is not None:
- raise HTTPStatusError(headers)
- return "payload"
-
-assert fetch() == "payload"
-```
-
-Any function works as `delay_from`, e.g. `delay_from=lambda exc: getattr(exc, "wait", None)`. If it raises, the normal backoff is used.
-
-### Time budget
-
-`max_time` is a total budget in seconds, measured from the first attempt. `retry` never sleeps past it: if the next wait would end after the deadline, the last exception is re-raised at once.
-
-```python
-from python_wrap import retry
-
-calls = []
-
-@retry(max_attempts=None, delay=0.01, backoff=2, max_time=0.05)
-def flaky() -> None:
- calls.append(1)
- raise ConnectionError("down")
-
-try:
- flaky()
-except ConnectionError:
- pass
-assert len(calls) >= 2
-```
-
-`retry` also honours an enclosing [`deadline`](../api/deadline.md): it never sleeps past `remaining()` (from `python_wrap.deadline`). If the next wait would outlast the deadline, or none is left, the last exception is re-raised at once (the same rule as `max_time`).
-
-```python
-from python_wrap import retry
-from python_wrap.deadline import deadline
-
-calls = []
-
-@retry(max_attempts=None, delay=0.02)
-def flaky() -> None:
- calls.append(1)
- raise ConnectionError("down")
-
-with deadline(0.05):
- try:
- flaky()
- except ConnectionError:
- pass
-assert 1 <= len(calls) <= 4
-```
-
-### Project-wide defaults
-
-Set `max_attempts`, `delay`, `backoff`, `max_delay`, `jitter`, `max_time` and `log_level` once for the whole project with `python_wrap.config.configure(retry={...})`. See [Project-wide configuration](configuration.md).
-
-### Cancellation and shutdown
-
-`asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` are **never retried**, even with `on=BaseException` or when you list them in `on`: retrying them would make tasks and processes impossible to stop. Cancelling an async call while it waits between attempts cancels it immediately.
-
-### Logging
-
-Each retry logs one message at `log_level` (default `WARNING`) on `logger`. Use `log_level=logging.INFO` to quiet it, `log_level=None` to turn it off, and `log_exc_info=True` to attach the traceback.
-
-### Statistics
-
-`retry_stats(func)` returns a `RetryStats` with thread-safe counters: `calls`, `attempts_total`, `retries`, `gave_up`, `total_sleep` and `last_exception`. Counters are per decorated function (shared by all instances for methods); `snapshot()` copies them and `reset()` clears them.
-
-```python
-from python_wrap import retry
-from python_wrap.retry import retry_stats
-
-n = []
-
-@retry(delay=0.01)
-def fetch() -> str:
- n.append(1)
- if len(n) < 3:
- raise OSError("flaky")
- return "ok"
-
-assert fetch() == "ok"
-stats = retry_stats(fetch)
-assert stats is not None
-assert (stats.calls, stats.attempts_total, stats.retries, stats.gave_up) == (1, 3, 2, 0)
-```
-
-### Retrying a block of code
-
-`retrying(...)` takes the same options as `retry` and lets you retry code that is not a function of its own. Put the risky part in `with attempt:`; a retryable exception is swallowed and the loop waits and continues, success ends the loop, and the last failure propagates. `attempt.number` is the 1-based attempt number.
-
-=== "Sync"
-
- ```python
- from python_wrap.retry import retrying
-
- tries = []
-
- for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError):
- with attempt:
- tries.append(attempt.number)
- if len(tries) < 3:
- raise ConnectionError("down")
-
- assert tries == [1, 2, 3]
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap.retry import retrying
-
- async def main() -> list[int]:
- tries: list[int] = []
- async for attempt in retrying(max_attempts=3, delay=0.01, on=ConnectionError):
- with attempt:
- tries.append(attempt.number)
- if len(tries) < 3:
- raise ConnectionError("down")
- return tries
-
- assert asyncio.run(main()) == [1, 2, 3]
- ```
-
-Use `async for` inside coroutines: a plain `for` would block the event loop while waiting.
-
-### Metrics and tests
-
-`retry` emits the events `retry.attempt_failed` (`attempt`, `exception`, `next_delay`), `retry.succeeded` (`attempts`) and `retry.gave_up` (`attempts`, `exception`) for `python_wrap.events` listeners. In tests, the `max_attempts` override from `python_wrap.testing` caps the attempts of every `retry` and `retrying` at call time.
-
-## `fallback`
-
-Return a value, or the result of a handler, when the function raises.
-
-=== "Sync"
-
- ```python
- from python_wrap import fallback
-
- @fallback(0, on=KeyError)
- def stock(sku: str) -> int:
- return {"a": 5}[sku]
-
- assert stock("a") == 5
- assert stock("missing") == 0
-
- @fallback(handler=lambda exc: f"failed: {exc!r}")
- def risky() -> str:
- raise RuntimeError("boom")
-
- assert risky() == "failed: RuntimeError('boom')"
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import fallback
-
- @fallback(0, on=KeyError)
- async def stock(sku: str) -> int:
- return {"a": 5}[sku]
-
- assert asyncio.run(stock("missing")) == 0
- ```
-
-Give exactly one of `value` or `handler=`; giving both or neither raises `ValueError`. The exception is logged at WARNING with its traceback. For async functions the handler is still a regular function.
-
-!!! tip
- Pass a fresh object with `handler=lambda exc: []` if the default is mutable, so callers do not share one list.
-
-## `suppress`
-
-Swallow matching exceptions; the call returns `None`. Suppressed errors are logged at DEBUG.
-
-```python
-from python_wrap import suppress
-
-@suppress(KeyError)
-def first(d: dict[str, int]) -> int:
- return d["x"]
-
-assert first({"x": 1}) == 1
-assert first({}) is None
-
-@suppress # bare form suppresses Exception
-def best_effort() -> None:
- raise RuntimeError
-
-assert best_effort() is None
-```
-
-Use `fallback` when you need a value other than `None`. Use `suppress` for best-effort work such as cleanup or telemetry.
-
-## Circuit breaker
-
-When a dependency is down, retrying makes things worse. A circuit breaker counts consecutive failures and, past a threshold, rejects calls immediately with `CircuitOpenError` for a while.
-
-| State | Behaviour | Moves on when |
-| --- | --- | --- |
-| `closed` | Calls run normally. | `failure_threshold` consecutive failures: becomes `open`. |
-| `open` | Calls are rejected with `CircuitOpenError`. | `recovery_timeout` seconds pass: becomes `half_open`. |
-| `half_open` | A limited number of trial calls run. | A trial succeeds: `closed`. A trial fails: `open` again. |
-
-```python
-import time
-from python_wrap import CircuitOpenError, CircuitState, circuit_breaker
-
-@circuit_breaker(failure_threshold=2, recovery_timeout=0.02, on=ConnectionError)
-def call_api() -> str:
- raise ConnectionError("down")
-
-for _ in range(2):
- try:
- call_api()
- except ConnectionError:
- pass
-
-assert call_api.breaker.state is CircuitState.OPEN
-try:
- call_api()
-except CircuitOpenError as exc:
- assert exc.retry_after <= 0.02
-
-time.sleep(0.03)
-assert call_api.breaker.state is CircuitState.HALF_OPEN
-call_api.breaker.reset()
-assert call_api.breaker.state is CircuitState.CLOSED
-```
-
-`@circuit_breaker(...)` gives the function its own breaker, exposed as `wrapper.breaker`. To **share one breaker across several functions** (they all hit the same service), create a `CircuitBreaker` and use the instance as the decorator:
-
-```python
-from python_wrap import CircuitBreaker, CircuitOpenError
-
-payments = CircuitBreaker(failure_threshold=1, recovery_timeout=60, name="payments")
-
-@payments
-def charge() -> None:
- raise RuntimeError("provider down")
-
-@payments
-def refund() -> None:
- ...
-
-try:
- charge()
-except RuntimeError:
- pass
-
-try:
- refund() # never runs: the shared circuit is open
-except CircuitOpenError:
- pass
-else:
- raise AssertionError("expected the circuit to be open")
-
-assert payments.failure_count == 1
-```
-
-You can also call through a breaker without decorating: `breaker.call(func, *args, **kwargs)` (for an `async def` function it returns a coroutine to await).
-
-Only exceptions matching `on=` count as failures; others pass through uncounted. `half_open_max_calls` limits how many trial calls may run at once while half-open.
-
-### Tuning recovery, exclusions and monitoring
-
-| Option | Default | What it does |
-| --- | --- | --- |
-| `success_threshold` | `1` | Consecutive successful trial calls needed to close a half-open circuit. Any counted failure during the trials re-opens it. |
-| `exclude` | `()` | Exception types that are never counted, even when they match `on=` (for example a "not found" error that proves the service is up). They propagate unchanged. |
-| `on_state_change` | `None` | `listener(breaker, old_state, new_state)`, called after every transition, outside the breaker's lock. An exception from the listener is logged, never raised. |
-| `name` | function name | Used in log messages, `CircuitOpenError` messages and events. |
-
-`breaker.stats` returns a frozen `CircuitStats` snapshot: `state`, total `failures`, `successes` and `rejected` calls (since creation or the last `reset()`), `consecutive_failures`, and `opened_at` (the `monotonic()` time the circuit last opened, `None` while closed).
-
-```python
-from python_wrap import CircuitBreaker, CircuitState
-
-class NotFound(Exception):
- pass
-
-changes = []
-inventory = CircuitBreaker(
- failure_threshold=2,
- recovery_timeout=0, # half-open again straight away, for the example
- success_threshold=2, # two good trial calls before trusting it again
- exclude=NotFound, # a 404 means the service is up
- on_state_change=lambda b, old, new: changes.append(new.value),
- name="inventory",
-)
-
-@inventory
-def lookup(sku: str) -> int:
- if sku == "missing":
- raise NotFound(sku)
- if sku == "broken":
- raise ConnectionError("down")
- return 3
-
-for sku in ("missing", "broken", "broken"):
- try:
- lookup(sku)
- except (NotFound, ConnectionError):
- pass
-
-assert inventory.stats.failures == 2 # NotFound was not counted
-assert lookup("ok") == 3 # first trial: still half-open
-assert inventory.state is CircuitState.HALF_OPEN
-assert lookup("ok") == 3 # second trial closes it
-assert changes == ["open", "half_open", "closed"]
-```
-
-The breaker also emits the `circuit.state_changed` (`old`, `new`) and `circuit.rejected` (`retry_after`) [events](observability.md), reported under the breaker's name.
-
-## `timeout`
-
-Raise `TimeoutExpired` (a subclass of the built-in `TimeoutError`) if a call runs too long.
-
-=== "Sync"
-
- ```python
- import time
- from python_wrap import TimeoutExpired, timeout
-
- @timeout(0.02)
- def slow() -> None:
- time.sleep(1)
-
- try:
- slow()
- except TimeoutExpired as exc:
- assert exc.seconds == 0.02
- assert isinstance(exc, TimeoutError)
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import TimeoutExpired, timeout
-
- @timeout(0.02)
- async def slow() -> None:
- await asyncio.sleep(1)
-
- async def main() -> None:
- try:
- await slow()
- except TimeoutExpired:
- pass
- else:
- raise AssertionError("expected a timeout")
-
- asyncio.run(main())
- ```
-
-How the deadline is enforced depends on the function and the platform:
-
-| Situation | Strategy | Stops the work? |
-| --- | --- | --- |
-| `async def` | `asyncio.timeout` (3.11+) or a cancellation timer (3.10) cancels the coroutine | Yes |
-| Sync, Unix, main thread | `SIGALRM` timer interrupts the call | Yes |
-| Sync on Windows, in a non-main thread, or `use_signals=False` | Runs in a daemon worker thread; the caller stops waiting | **No** |
-
-!!! warning "The thread fallback cannot stop the work"
- When signals are unavailable, the function keeps running in the background after the timeout, and its result is discarded. Do not rely on it to cancel side effects. See [Limitations](../limitations.md#timeout).
-
-The worker threads are pooled: a finished worker waits (as an idle daemon thread) for the next call instead of a new thread being started per call. At most 8 idle workers are kept, an idle worker exits after about 5 seconds without work, and a worker still running a call that timed out is abandoned: it exits when that call returns and is never reused. The caller's `contextvars` are copied into the worker on every call.
-
-Nested timeouts work: the previous `SIGALRM` handler and any already-armed timer are restored afterwards. `use_signals=True` forces signals (and raises `RuntimeError` where unavailable); `use_signals=False` forces the thread strategy.
-
-Every expiry emits the `timeout.expired` (`seconds`) [event](observability.md).
-
-### Timing out a block
-
-The object `timeout(...)` returns is also a context manager, so you can limit a block of code without wrapping it in a function. The same object still works as a decorator.
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import TimeoutExpired, timeout
-
- async def main() -> None:
- try:
- async with timeout(0.02):
- await asyncio.sleep(1)
- except TimeoutExpired as exc:
- assert exc.seconds == 0.02
- else:
- raise AssertionError("expected a timeout")
-
- asyncio.run(main())
- ```
-
-=== "Sync"
-
-
- ```python
- import time
- from python_wrap import TimeoutExpired, timeout
-
- try:
- with timeout(0.02): # Unix, main thread only
- time.sleep(1)
- except TimeoutExpired:
- pass
- ```
-
-`async with timeout(...)` uses `asyncio.timeout` on Python 3.11+ and a cancellation timer on 3.10; as with the decorator, a `TimeoutError` raised by your own code inside the block propagates unchanged.
-
-The sync `with timeout(...)` form only exists on top of `SIGALRM`: it needs Unix and the main thread. Elsewhere (Windows, worker threads, `use_signals=False`) entering it raises `RuntimeError`, because a block of code cannot be moved to a worker thread. Decorate a function instead, which falls back to the thread strategy.
-
-### Deadlines: one budget for many steps
-
-A `timeout` limits one call. A **deadline** limits everything inside a block — several calls, retries, sub-steps — to one shared budget. `deadline(seconds)` stores an absolute expiry in a context variable, so it is visible to every function called inside the block, sync or async, and to tasks started from it. Nested deadlines never extend an outer one: the earliest wins.
-
-A deadline does not interrupt anything by itself. Instead:
-
-* `timeout` (decorator or block) clamps its limit to the time the deadline has left. If the deadline has already passed, the call is not started and `TimeoutExpired` is raised at once.
-* Import the helpers from the module: `from python_wrap.deadline import check, deadline, remaining`. The package attribute `python_wrap.deadline` is the `deadline` function itself (the same is true of `retry` and `policy`), so `import python_wrap.deadline as m` does not give you the module.
-* `remaining()` returns the seconds left (or `None` outside any deadline). Pass it on, for example as an HTTP client's timeout, or use it to cap your own retry loop.
-* `check()` raises `TimeoutExpired` once the deadline has passed. Call it between steps of a long job.
-
-```python
-import asyncio
-from python_wrap import TimeoutExpired, timeout
-from python_wrap.deadline import check, deadline, remaining
-
-@timeout(30) # generous per-call limit...
-async def fetch(part: int) -> int:
- await asyncio.sleep(0.01 if part < 2 else 1)
- return part
-
-async def handle_request() -> list[int]:
- results = []
- async with deadline(0.1): # ...but the whole request gets 0.1s
- for part in range(5):
- check() # stop early once the budget is spent
- left = remaining()
- assert left is not None and left <= 0.1
- results.append(await fetch(part)) # clamped to what is left
- return results
-
-async def main() -> None:
- try:
- await handle_request()
- except TimeoutExpired as exc:
- assert exc.seconds <= 0.1
- else:
- raise AssertionError("expected the deadline to run out")
-
-asyncio.run(main())
-```
diff --git a/docs/guides/state.md b/docs/guides/state.md
deleted file mode 100644
index 62f3f49..0000000
--- a/docs/guides/state.md
+++ /dev/null
@@ -1,214 +0,0 @@
-# State safety
-
-Keep objects consistent when something goes wrong halfway.
-
-## `revert_on_failure`
-
-If the function raises, restore the object's attributes to what they were before the call, then re-raise.
-
-```python
-from python_wrap import revert_on_failure
-
-class Account:
- def __init__(self) -> None:
- self.balance = 10
- self.history: list[str] = []
-
- @revert_on_failure
- def withdraw(self, amount: int) -> None:
- self.history.append(f"withdraw {amount}")
- self.balance -= amount
- if self.balance < 0:
- raise ValueError("overdrawn")
-
-account = Account()
-try:
- account.withdraw(50)
-except ValueError:
- pass
-
-assert account.balance == 10 # restored
-assert account.history == [] # the append was undone too
-```
-
-By default it snapshots `vars(self)`: the first argument of the call. Attributes created during the call are deleted again on failure.
-
-| Option | Meaning |
-| --- | --- |
-| `target` | Index or parameter name of the argument to protect (default `0`, normally `self`). |
-| `attrs` | Snapshot only these attribute names. **Required for classes using `__slots__`.** |
-| `deep` | Use `copy.deepcopy` for the snapshot instead of `copy.copy`. |
-
-```python
-from python_wrap import revert_on_failure
-
-class Cart:
- def __init__(self) -> None:
- self.items: list[dict] = [{"qty": 1}]
- self.coupon = None
-
- @revert_on_failure(attrs=["items"], deep=True)
- def bump(self) -> None:
- self.items[0]["qty"] += 1 # a nested object
- self.coupon = "TEN" # not protected: not in attrs
- raise RuntimeError
-
-cart = Cart()
-try:
- cart.bump()
-except RuntimeError:
- pass
-
-assert cart.items[0]["qty"] == 1 # deep=True undoes nested mutation
-assert cart.coupon == "TEN" # outside attrs, so not restored
-```
-
-!!! warning "Copy semantics"
- Each value is copied (`copy.copy`, or `copy.deepcopy` with `deep=True`) and the **copy** is restored. In-place mutation of a container attribute (`self.items.append(x)`) is undone, but the attribute ends up referring to a new list, so other references to the old list still see the mutation. A shallow copy does not protect nested objects: `self.items[0].count += 1` is only undone with `deep=True`. Values that cannot be copied are restored by reference. See [Limitations](../limitations.md#revert_on_failure).
-
-It works on `async def` methods too. It undoes attribute changes only, not side effects such as files written or requests sent; for those, use a transaction.
-
-## Transactions
-
-A `Transaction` collects undo steps (`on_rollback`) and follow-up steps (`on_commit`) and runs the right set exactly once.
-
-- On success, `on_commit` callbacks run **in order**.
-- On exception, `on_rollback` callbacks run **in reverse registration order**; a failing rollback callback is logged and the rest still run; then the original exception propagates.
-- A transaction completes once; a second `commit()` or `rollback()` raises `RuntimeError`.
-
-=== "Sync"
-
- ```python
- from python_wrap import Transaction
-
- log: list[str] = []
-
- try:
- with Transaction() as tx:
- log.append("created file")
- tx.on_rollback(log.append, "removed file")
- log.append("charged card")
- tx.on_rollback(log.append, "refunded card")
- tx.on_commit(log.append, "sent receipt")
- raise RuntimeError("shipping failed")
- except RuntimeError:
- pass
-
- assert tx.state == "rolled_back"
- assert log == ["created file", "charged card", "refunded card", "removed file"]
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import Transaction
-
- log: list[str] = []
-
- async def undo() -> None:
- log.append("undone")
-
- async def main() -> None:
- try:
- async with Transaction() as tx:
- tx.on_rollback(undo) # coroutine callbacks need `async with`
- raise RuntimeError
- except RuntimeError:
- pass
- assert log == ["undone"]
-
- asyncio.run(main())
- ```
-
-`tx.state` is `"pending"`, `"committed"` or `"rolled_back"`. Outside a context manager you can call `tx.commit()` / `tx.rollback()`, or `await tx.acommit()` / `await tx.arollback()` when callbacks are coroutine functions.
-
-## `transactional`
-
-Wrap a function in a fresh `Transaction` per call. Returning normally commits; raising rolls back and re-raises. Receive the transaction as a keyword parameter named `tx` declared as `tx: Transaction = INJECTED`; `INJECTED` is a typed placeholder, so type checkers let callers omit `tx` (passing it yourself raises `TypeError`). Or fetch it anywhere in the call tree with `require_transaction()`, which raises `RuntimeError` outside a transaction, or `current_transaction()`, which returns `None` there.
-
-=== "Sync"
-
- ```python
- from python_wrap.state import INJECTED, Transaction, transactional
-
- steps: list[str] = []
-
- @transactional
- def transfer(amount: int, *, tx: Transaction = INJECTED) -> str:
- steps.append("debit")
- tx.on_rollback(steps.append, "undo debit")
- if amount > 100:
- raise ValueError("limit")
- tx.on_commit(steps.append, "notify")
- return "ok"
-
- assert transfer(50) == "ok"
- assert steps == ["debit", "notify"]
-
- steps.clear()
- try:
- transfer(500)
- except ValueError:
- pass
- assert steps == ["debit", "undo debit"]
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap.state import INJECTED, Transaction, transactional
-
- steps: list[str] = []
-
- @transactional
- async def transfer(amount: int, *, tx: Transaction = INJECTED) -> str:
- steps.append("debit")
- tx.on_rollback(steps.append, "undo debit")
- if amount > 100:
- raise ValueError("limit")
- return "ok"
-
- async def main() -> None:
- try:
- await transfer(500)
- except ValueError:
- pass
- assert steps == ["debit", "undo debit"]
-
- asyncio.run(main())
- ```
-
-!!! warning "Make `tx` keyword-only"
- When the function has a parameter named `tx`, it is passed as the keyword argument `tx=`. Callers must not pass it themselves (a `TypeError` says so). Declaring it keyword-only, with the `INJECTED` default, keeps callers from colliding with it.
-
-### Without a `tx` parameter
-
-`require_transaction()` returns the innermost running transaction and raises `RuntimeError` outside one; `current_transaction()` returns `None` instead. It is backed by a `ContextVar`, so it is correct across threads and asyncio tasks, and lets helper functions register undo steps without the transaction being passed through every layer.
-
-```python
-from python_wrap.state import current_transaction, require_transaction, transactional
-
-undone: list[str] = []
-
-def create_resource(name: str) -> None:
- tx = require_transaction()
- tx.on_rollback(undone.append, f"delete {name}")
-
-@transactional
-def provision() -> None:
- create_resource("bucket")
- create_resource("queue")
- raise RuntimeError
-
-try:
- provision()
-except RuntimeError:
- pass
-
-assert undone == ["delete queue", "delete bucket"]
-assert current_transaction() is None
-```
-
-Nested `@transactional` calls each get their own transaction.
diff --git a/docs/guides/validation.md b/docs/guides/validation.md
deleted file mode 100644
index 653babe..0000000
--- a/docs/guides/validation.md
+++ /dev/null
@@ -1,219 +0,0 @@
-# Validation
-
-Reject bad input at the door, with an error that names the parameter.
-
-| Decorator | Checks | Raises |
-| --- | --- | --- |
-| [`validate_args`](#validate_args) | Your predicate per argument | `ValidationError` (a `ValueError`) |
-| [`check_type`](#check_type) | `isinstance` per argument | `ArgumentTypeError` (a `TypeError`) |
-| [`enforce_types`](#enforce_types) | Arguments and return value against the function's own annotations | `ArgumentTypeError` (a `TypeError`) |
-
-All three work on plain functions, `async def` functions and methods, can be stacked, and keep the wrapped function's signature. A parameter name that does not exist raises `TypeError` **when you decorate**, so typos fail at import time rather than in production.
-
-## `validate_args`
-
-Give each parameter a predicate. A falsy result raises `ValidationError`.
-
-=== "Sync"
-
- ```python
- from python_wrap import ValidationError, validate_args
-
- @validate_args(
- quantity=lambda q: q > 0,
- sku=(lambda s: s.startswith("SKU-"), "sku must start with 'SKU-'"),
- )
- def order(sku: str, quantity: int) -> str:
- return f"{quantity} x {sku}"
-
- assert order("SKU-1", 2) == "2 x SKU-1"
-
- try:
- order("SKU-1", 0)
- except ValidationError as exc:
- assert exc.parameter == "quantity" and exc.value == 0
- assert isinstance(exc, ValueError)
-
- try:
- order("bad", 1)
- except ValidationError as exc:
- assert "must start with" in str(exc)
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import ValidationError, validate_args
-
- @validate_args(n=lambda n: n > 0)
- async def double(n: int) -> int:
- return n * 2
-
- async def main() -> None:
- assert await double(2) == 4
- try:
- await double(-1)
- except ValidationError:
- pass
- else:
- raise AssertionError("expected a ValidationError")
-
- asyncio.run(main())
- ```
-
-Details:
-
-- Use a `(predicate, "message")` tuple for a custom message.
-- Default values are applied first, so defaults are validated too.
-- A validator may raise its own exception instead of returning falsy; it propagates unchanged.
-- For `*args` / `**kwargs` parameters the validator receives the whole tuple / dict.
-
-## `check_type`
-
-`isinstance` checks, one per argument. The value can be a class or a tuple of classes.
-
-```python
-from python_wrap import ArgumentTypeError, check_type
-
-@check_type(x=int, label=(str, bytes))
-def tag(x, label):
- return f"{label}:{x}"
-
-assert tag(1, "a") == "a:1"
-
-try:
- tag("1", "a")
-except ArgumentTypeError as exc:
- assert exc.parameter == "x" and exc.expected is int and exc.value == "1"
- assert isinstance(exc, TypeError)
-```
-
-Only arguments that are actually passed are checked (defaults are not). For `*args` / `**kwargs` the type applies to every element / value.
-
-## `enforce_types`
-
-Check the arguments and the return value against the function's own annotations. No extra configuration: your type hints become runtime checks.
-
-=== "Sync"
-
- ```python
- from python_wrap import ArgumentTypeError, enforce_types
-
- @enforce_types
- def total(items: list[int], scale: float = 1.0) -> float:
- return sum(items) * scale
-
- assert total([1, 2, 3]) == 6.0
-
- try:
- total([1, 2, "3"])
- except ArgumentTypeError as exc:
- assert exc.parameter == "items"
- assert "index 2" in str(exc)
- ```
-
-=== "Async"
-
- ```python
- import asyncio
- from python_wrap import ArgumentTypeError, enforce_types
-
- @enforce_types
- async def name_length(name: str) -> int:
- return len(name)
-
- async def main() -> None:
- assert await name_length("ana") == 3
- try:
- await name_length(42) # type: ignore[arg-type]
- except ArgumentTypeError:
- pass
- else:
- raise AssertionError("expected an ArgumentTypeError")
-
- asyncio.run(main())
- ```
-
-The error message says what was expected and what was found, for example `parameter 'items' expected list[int], got list containing str at index 2`. For a bad return value `error.parameter` is `"return"`; turn that off with `@enforce_types(check_return=False)`.
-
-```python
-from python_wrap import ArgumentTypeError, enforce_types
-
-@enforce_types
-def broken() -> int:
- return "not an int" # type: ignore[return-value]
-
-try:
- broken()
-except ArgumentTypeError as exc:
- assert exc.parameter == "return"
-```
-
-Annotations are resolved lazily on the first call with `typing.get_type_hints`, so forward references work. For async functions the awaited result is checked.
-
-### Supported annotations
-
-| Annotation | How it is checked |
-| --- | --- |
-| Plain classes (`int`, `str`, your own) | `isinstance`. `int` also accepts `bool`; `float` accepts `int`; `complex` accepts `int` and `float` (PEP 484). |
-| `Any`, `None` | Anything / `None` only. |
-| `Optional[T]`, `Union[A, B]`, PEP 604 unions (the `X` bar `Y` syntax) | Any member matches. |
-| `Literal[...]` | The value is one of the literals. |
-| `list[T]`, `set[T]`, `frozenset[T]`, `tuple[T, ...]`, `tuple[A, B]`, `dict[K, V]` | Container type **and each element**. |
-| `Sequence[T]`, `MutableSequence[T]`, `Set[T]`, `Mapping[K, V]` | ABC and each element. |
-| `Iterable[T]`, `Iterator[T]`, `Generator` | `isinstance` only. Iterators are never consumed. |
-| `Callable[...]` | `callable()` only. |
-| `type[T]` | The value is a subclass of `T`. |
-| `TypeVar` | Its bound or constraints; unbound means anything. |
-| `Annotated[T, ...]` | Checks `T`. |
-| `NewType` | Checks the supertype. |
-| `runtime_checkable` protocols | `isinstance`. |
-| `TypedDict`, non-runtime-checkable protocols, unresolved forward references, anything else | **Accepted without checking.** |
-
-A check that cannot be performed never raises: unsupported annotations are accepted rather than rejected.
-
-```python
-from typing import Annotated, Literal, Optional
-from python_wrap import ArgumentTypeError, enforce_types
-
-@enforce_types
-def configure(
- mode: Literal["fast", "safe"],
- retries: Optional[int] = None,
- tags: dict[str, list[int]] | None = None,
- level: Annotated[int, "0 to 9"] = 0,
-) -> None: ...
-
-configure("fast", tags={"a": [1, 2]})
-
-for bad in ({"mode": "turbo"}, {"mode": "fast", "tags": {"a": ["x"]}}):
- try:
- configure(**bad)
- except ArgumentTypeError:
- pass
- else:
- raise AssertionError(f"{bad} should have been rejected")
-```
-
-!!! warning "A runtime cost"
- Checking a `list[int]` inspects every element on every call. Use `enforce_types` at boundaries (public API, request handlers), not inside hot loops.
-
-## Choosing between them
-
-- Business rules (`amount > 0`, "not empty", "valid email"): `validate_args`.
-- "This must be an `int` or `str`": `check_type` is the lightest.
-- "My annotations should be true at runtime": `enforce_types`.
-
-They stack: `enforce_types` for shape, `validate_args` for rules.
-
-```python
-from python_wrap import enforce_types, validate_args
-
-@enforce_types
-@validate_args(amount=(lambda a: a > 0, "amount must be positive"))
-def charge(account: str, amount: float) -> str:
- return f"{account}: {amount}"
-
-assert charge("acme", 5) == "acme: 5"
-```
diff --git a/docs/index.md b/docs/index.md
index 8a34724..4995356 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -16,7 +16,7 @@ bulkhead, batching, async helpers, metrics and test helpers. One import, sync an
fully typed, zero dependencies.
[Get started](#install){ .md-button .md-button--primary }
-[Choose a decorator](choosing.md){ .md-button }
+[Choose a decorator](get-started/choosing.md){ .md-button }
[View on GitHub](https://github.com/LitePy/PythonWrap){ .md-button }
@@ -38,7 +38,7 @@ def load_profile(user_id: int) -> dict:
...
```
-`policy(...)` applies the protections in the [recommended order](stacking.md) whatever order you list them in.
+`policy(...)` applies the protections in the [recommended order](get-started/stacking.md) whatever order you list them in.
## Install
@@ -185,7 +185,7 @@ More of the toolkit, in a few lines each:
assert flaky() == "ok"
```
-Not sure which decorator you need? Start with [Choose a decorator](choosing.md).
+Not sure which decorator you need? Start with [Choose a decorator](get-started/choosing.md).
## Principles
@@ -207,19 +207,19 @@ Not sure which decorator you need? Start with [Choose a decorator](choosing.md).
---
- Async functions get `async def` wrappers, waits use `asyncio` instead of blocking the loop, and caches share concurrent awaits. See [Async support](async.md).
+ Async functions get `async def` wrappers, waits use `asyncio` instead of blocking the loop, and caches share concurrent awaits. See [Async support](get-started/async-and-methods.md).
- :material-text-box-search-outline:{ .lg .middle } __Never configures logging__
---
- Importing python-wrap does not touch the root logger. Decorators log to the `python_wrap` logger, which has a `NullHandler`. See [Observability](guides/observability.md#configuring-logging).
+ Importing python-wrap does not touch the root logger. Decorators log to the `python_wrap` logger, which has a `NullHandler`. See [Observability](features/observability/logging.md#configuring-logging).
- :material-chart-line:{ .lg .middle } __Observable and testable__
---
- `count_calls` and one [event hook](guides/metrics.md) cover retries, caches, circuits and limiters; [`python_wrap.testing`](testing.md) makes tests instant. Overhead numbers: [Performance](performance.md).
+ `count_calls` and one [event hook](features/observability/metrics.md) cover retries, caches, circuits and limiters; [`python_wrap.testing`](testing/index.md) makes tests instant. Overhead numbers: [Performance](project/performance.md).
- :material-alert-octagon-outline:{ .lg .middle } __Failures are explicit__
@@ -229,13 +229,13 @@ Not sure which decorator you need? Start with [Choose a decorator](choosing.md).
-Some of these tools cannot do everything (a thread timeout cannot stop the work, for example). Every such limit is on one page: [Limitations](limitations.md).
+Some of these tools cannot do everything (a thread timeout cannot stop the work, for example). Every such limit is on one page: [Limitations](project/limitations.md).
## Where to go next
-- :material-compass-outline:{ .lg .middle } __[Choose a decorator](choosing.md)__
+- :material-compass-outline:{ .lg .middle } __[Choose a decorator](get-started/choosing.md)__
---
@@ -245,7 +245,7 @@ Some of these tools cannot do everything (a thread timeout cannot stop the work,
---
- [Resilience](guides/resilience.md), [Caching](guides/caching.md), [Concurrency](guides/concurrency.md), [Async toolkit](guides/async-toolkit.md), [Batching](guides/batching.md), [Observability](guides/observability.md), [Metrics](guides/metrics.md), [Validation](guides/validation.md), [State](guides/state.md), [Policies](guides/policies.md), [Dev tools](guides/devtools.md), [Configuration](guides/configuration.md).
+ [Resilience](features/resilience/index.md), [Caching](features/caching/index.md), [Concurrency](features/concurrency/index.md), [Async toolkit](features/concurrency/async-helpers.md), [Batching](features/caching/batching.md), [Observability](features/observability/index.md), [Metrics](features/observability/metrics.md), [Validation](features/correctness/validation.md), [State](features/correctness/state.md), [Policies](features/resilience/policies.md), [Dev tools](features/devtools.md), [Configuration](features/configuration.md).
- :material-chef-hat:{ .lg .middle } __[Cookbook](cookbook/index.md)__
@@ -253,19 +253,19 @@ Some of these tools cannot do everything (a thread timeout cannot stop the work,
Complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests.
-- :material-test-tube:{ .lg .middle } __[Testing](testing.md)__
+- :material-test-tube:{ .lg .middle } __[Testing](testing/index.md)__
---
`fast_mode`, a fake clock, spies and `eventually`: instant, deterministic tests.
-- :material-layers-triple-outline:{ .lg .middle } __[Stacking decorators](stacking.md)__
+- :material-layers-triple-outline:{ .lg .middle } __[Stacking decorators](get-started/stacking.md)__
---
Order matters. The recommended order, and why `policy(...)` applies it for you.
-- :material-swap-horizontal:{ .lg .middle } __[Migrating from 0.1](migration.md)__
+- :material-swap-horizontal:{ .lg .middle } __[Migrating from 0.1](project/migration.md)__
---
diff --git a/docs/project/limitations.md b/docs/project/limitations.md
index a52f7ea..8b9a126 100644
--- a/docs/project/limitations.md
+++ b/docs/project/limitations.md
@@ -18,7 +18,7 @@ Related details:
- Signal-based timeouts need the **main thread**. `use_signals=True` raises `RuntimeError` anywhere else.
- A C call that does not release the interpreter (a long regex, a blocking C extension) is interrupted only when control returns to Python.
-- `TimeoutExpired` is an ordinary exception. A `retry` with the default `on=Exception` that wraps the function inside an outer `timeout` can catch it. See [Stacking](stacking.md#timeout-above-or-below-retry).
+- `TimeoutExpired` is an ordinary exception. A `retry` with the default `on=Exception` that wraps the function inside an outer `timeout` can catch it. See [Stacking](../get-started/stacking.md#timeout-above-or-below-retry).
## Shared state on methods
@@ -73,6 +73,6 @@ If your function has a `tx` parameter, it is passed as the keyword `tx=`. Put `t
## Other things to know
- **`debounce`** (sync) runs the function on a daemon timer thread, so it may be abandoned at interpreter exit with a call still pending. Call `.flush()` before shutting down if the last call matters.
-- **`enforce_types`** accepts, rather than rejects, annotations it cannot check (`TypedDict`, non-runtime-checkable protocols, unresolved forward references). It adds per-call cost proportional to container size. See [Validation](guides/validation.md#supported-annotations).
+- **`enforce_types`** accepts, rather than rejects, annotations it cannot check (`TypedDict`, non-runtime-checkable protocols, unresolved forward references). It adds per-call cost proportional to container size. See [Validation](../features/correctness/validation.md#supported-annotations).
- **`retry`** with `max_attempts=None` retries forever. Pair it with `max_delay` and an `on=` that names only transient errors.
- **Signal timers are process-wide.** `timeout` restores the previous `SIGALRM` handler and any armed timer afterwards, but other code that installs its own `SIGALRM` handler concurrently can conflict.
diff --git a/docs/project/migration.md b/docs/project/migration.md
index 75ec8d1..8ec5ddf 100644
--- a/docs/project/migration.md
+++ b/docs/project/migration.md
@@ -172,7 +172,7 @@ Two changes you may notice as "my logs disappeared":
work(1)
```
-Every logging decorator also accepts `logger=` (a logger or a name) and `level=`. See [Observability](guides/observability.md#configuring-logging).
+Every logging decorator also accepts `logger=` (a logger or a name) and `level=`. See [Observability](../features/observability/logging.md#configuring-logging).
### `no_debug` is deprecated
diff --git a/docs/stacking.md b/docs/stacking.md
deleted file mode 100644
index f08a992..0000000
--- a/docs/stacking.md
+++ /dev/null
@@ -1,171 +0,0 @@
-# Stacking decorators
-
-Decorators apply bottom to top: the one nearest the `def` wraps the function first, and the one at the top is the **outermost** layer, the first to see a call and the last to see the result.
-
-```python
-from python_wrap import retry, timeout
-
-@retry(max_attempts=3, delay=0.01) # outer: sees the final result of all attempts
-@timeout(5) # inner: bounds each single attempt
-def fetch() -> str:
- return "ok"
-```
-
-Order changes behaviour, not just style. A good rule: **put a decorator outside whatever it should observe or protect as a whole, and inside whatever should happen once per attempt.**
-
-## Order matters: four examples
-
-### `timing` above or below `retry`
-
-`timing` above `retry` times the whole call, including every attempt and every wait. Below it, `timing` measures each attempt separately.
-
-```python
-from python_wrap import TimingRecord, retry, timing
-
-outer: list[TimingRecord] = []
-inner: list[TimingRecord] = []
-
-@timing(callback=outer.append) # one record for the whole call
-@retry(max_attempts=3, delay=0.01, on=ValueError)
-@timing(callback=inner.append) # one record per attempt
-def flaky() -> None:
- raise ValueError
-
-try:
- flaky()
-except ValueError:
- pass
-
-assert len(outer) == 1 and outer[0].success is False
-assert len(inner) == 3
-assert outer[0].elapsed >= 0.02 # includes the two 10 ms waits
-```
-
-### `timeout` above or below `retry`
-
-`retry` above `timeout`: each attempt gets its own deadline. `timeout` above `retry`: one deadline covers all attempts and the waits between them.
-
-=== "Per attempt (retry above)"
-
- ```python
- import time
- from python_wrap import TimeoutExpired, retry, timeout
-
- attempts = []
-
- @retry(max_attempts=3, delay=0.001, on=TimeoutExpired)
- @timeout(0.01)
- def slow() -> None:
- attempts.append(1)
- time.sleep(1)
-
- try:
- slow()
- except TimeoutExpired:
- pass
-
- assert len(attempts) == 3 # three separate 10 ms deadlines
- ```
-
-=== "Overall (timeout above)"
-
- ```python
- import time
- from python_wrap import TimeoutExpired, retry, timeout
-
- attempts = []
-
- @timeout(0.03)
- @retry(max_attempts=100, delay=0.01, on=ConnectionError)
- def always_fails() -> None:
- attempts.append(1)
- raise ConnectionError
-
- try:
- always_fails()
- except TimeoutExpired:
- pass
-
- assert 1 <= len(attempts) < 100 # cut off by the overall deadline
- ```
-
-!!! warning "Narrow `on=` when an outer `timeout` wraps a `retry`"
- `TimeoutExpired` is an ordinary exception. A `retry` left at its default `on=Exception` can catch the timeout from an outer `timeout` and keep going, so the overall deadline is swallowed. Name the transient errors you want retried (`on=ConnectionError`) and the timeout passes through.
-
-### `circuit_breaker` above or below `retry`
-
-Above `retry`, the breaker counts one failure per call whose retries were all used up. Below it, every single attempt counts, so one unlucky call can trip the breaker, and `retry` then sees `CircuitOpenError`.
-
-```python
-from python_wrap import CircuitOpenError, circuit_breaker, retry
-
-@circuit_breaker(failure_threshold=2, recovery_timeout=60, on=ConnectionError)
-@retry(max_attempts=3, delay=0, on=ConnectionError)
-def call() -> None:
- raise ConnectionError
-
-for _ in range(2):
- try:
- call()
- except ConnectionError:
- pass
-
-assert call.breaker.failure_count == 2 # two calls, six attempts
-
-try:
- call()
-except CircuitOpenError:
- pass
-```
-
-Usually you want the breaker **above** `retry`: "this dependency has failed N calls in a row, after retrying".
-
-### `rate_limit` above or below `retry`
-
-Above `retry`: one count per logical call. Below it: every attempt counts against the limit, which is right when the limit is the remote service's quota and wrong when it protects your own callers.
-
-## A recommended order
-
-Outermost first. Skip the layers you do not need.
-
-| # | Decorator | Why here |
-| --- | --- | --- |
-| 1 | `deprecate`, `mock_data` | Warn or short-circuit before anything else runs. |
-| 2 | `audit`, `log_args`, `log_return`, `timing` | Observe the whole call, including retries and fallbacks. |
-| 3 | `validate_args`, `check_type`, `enforce_types` | Reject bad input immediately; do not retry or cache it. |
-| 4 | `fallback`, `suppress` | Catch whatever the resilience layers below could not fix. |
-| 5 | `memoize`, `once` | A cache hit skips everything below it. Exceptions are not cached. |
-| 6 | `singleflight` | Concurrent cache misses share one execution. |
-| 7 | `rate_limit`, `throttle`, `debounce` | Cap calls per logical request. |
-| 8 | `circuit_breaker` | Counts calls whose retries were exhausted. |
-| 9 | `retry` | Repeats everything below it. |
-| 10 | `timeout` | A deadline per attempt. |
-| 11 | `revert_on_failure`, `transactional` | Each attempt starts from clean state and undoes itself. |
-| 12 | `timing` (optional second one) | Per-attempt durations. |
-
-```python
-from python_wrap import (
- circuit_breaker, enforce_types, fallback, memoize, retry, timeout, timing,
-)
-
-@timing(threshold=1.0)
-@enforce_types
-@fallback({}, on=ConnectionError)
-@memoize(ttl=30)
-@circuit_breaker(failure_threshold=5, recovery_timeout=30, on=ConnectionError)
-@retry(max_attempts=3, delay=0.2, backoff=2, on=ConnectionError)
-@timeout(5)
-def load_profile(user_id: int) -> dict:
- ...
-```
-
-Put the same stack on many functions with [`compose`](guides/devtools.md#compose).
-
-## Rules of thumb
-
-- **`retry(on=...)` should name transient errors.** With the default `on=Exception` it also retries `ValidationError`, `CircuitOpenError` and `TimeoutExpired`, which rarely helps.
-- **Observers go outside, guards go inside.** An `audit` or `timing` outside the retry loop records the outcome the caller experienced.
-- **Caches go outside the things that are expensive to repeat.** `memoize` above `retry` caches the final success and skips the retry loop on a hit.
-- **`singleflight` goes below `memoize`.** Cache first; only a miss needs de-duplicating.
-- **Decorators that hold state belong to the function.** Stacking never merges state, but see [shared state on methods](limitations.md#shared-state-on-methods).
-- **Async and sync stack the same way.** Every decorator preserves whether the function is `async def`, so the order rules are identical. See [Async support](async.md).
diff --git a/docs/testing.md b/docs/testing.md
deleted file mode 100644
index 6485ef0..0000000
--- a/docs/testing.md
+++ /dev/null
@@ -1,406 +0,0 @@
-# Testing code that uses these decorators
-
-Decorators that wait or remember make tests slow or order-dependent. python-wrap is built so you can fix both without touching production code.
-
-python-wrap ships its own testing helpers in `python_wrap.testing` (standard library only; pytest is optional). They cover the common jobs: skip the waiting, cap retries, switch off rate limits and caches, control time, observe calls and wait for background work.
-
-## Fast mode: instant waits, retries still run
-
-`fast_mode()` makes every wait inside python-wrap instant (`no_sleep`). Retries still run the configured number of times and limiters still count; nothing actually sleeps. Use it as a context manager or a decorator on sync and async tests:
-
-```python
-import time
-from python_wrap import retry
-from python_wrap.testing import fast_mode
-
-calls = []
-
-@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError)
-def flaky() -> str:
- calls.append(1)
- if len(calls) < 4:
- raise ConnectionError
- return "ok"
-
-start = time.monotonic()
-with fast_mode():
- assert flaky() == "ok" # would take 30 + 60 + 120 seconds
-assert time.monotonic() - start < 5
-```
-
-The other switches work the same way:
-
-| Helper | Effect |
-| --- | --- |
-| `fast_mode()` | `_core.sleep` / `_core.async_sleep` return immediately |
-| `disable_retries()` | `retry` makes a single attempt |
-| `disable_rate_limits()` | `rate_limit` / `throttle` let every call through |
-| `disable_caching()` | `memoize` / `cache` always call the wrapped function |
-| `overrides(no_sleep=..., max_attempts=..., ...)` | Any combination, in one scope |
-
-```python
-import asyncio
-from python_wrap import _core
-from python_wrap.testing import disable_retries, fast_mode, overrides
-
-with overrides(no_sleep=True, max_attempts=3):
- with disable_retries():
- assert _core.overrides.max_attempts == 1
- assert _core.overrides.max_attempts == 3 # restored on exit
-assert _core.overrides.max_attempts is None
-
-@fast_mode()
-async def test_something() -> None:
- assert _core.overrides.no_sleep
-
-asyncio.run(test_something())
-```
-
-!!! note "Nesting and threads"
- The overrides are one process-wide state, not context variables, so worker threads started by your test see them. Each scope restores exactly the fields it changed, to the values they had on entry, so scopes nest. They must exit in reverse order of entry (`with` blocks and decorators always do). Overlapping scopes from different threads that touch the same field are not isolated from each other; enter them from the test, not from concurrent workers.
-
-## Fake clock
-
-`fake_clock()` replaces `_core.monotonic`, `_core.perf_counter`, `_core.sleep` and `_core.async_sleep`. Sleeping advances the clock and records the duration, so you can assert the exact backoff schedule. The async sleep yields to the event loop, so other tasks still run.
-
-```python
-from python_wrap import retry
-from python_wrap.testing import fake_clock
-
-calls = []
-
-@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError)
-def flaky() -> str:
- calls.append(1)
- if len(calls) < 4:
- raise ConnectionError
- return "ok"
-
-with fake_clock() as clock:
- start = clock.now
- assert flaky() == "ok" # returns at once; no real waiting
-
-assert clock.sleeps == [1.0, 2.0, 4.0] # the backoff schedule you configured
-assert clock.now == start + 7.0
-```
-
-`FakeClock()` starts at the real `time.monotonic()` of the moment it is created, rounded up to a whole second (so differences stay exact), so entries stamped before it was installed are in its past and still expire; pass `start=` for a fixed reading. It has `.now`, `.advance(seconds)`, `.sleeps` and `.total_slept`; pass your own instance with `fake_clock(clock)`.
-
-## What the overrides affect
-
-python-wrap separates two kinds of waiting:
-
-- **Backoff waits** go through `_core.sleep` / `_core.async_sleep`: `retry` delays and a blocking rate limiter (`block=True`) waiting for a slot. `fast_mode()` makes them instant; `fake_clock()` turns them into clock advances you can assert on.
-- **Limits on how long to wait** use real timers: the deadline of `timeout` (signal timer, worker thread, `asyncio.timeout`), `Bulkhead(timeout=...)` / `limit_concurrency` waiting for a slot, `race(timeout=...)`, the `hedge` delay before a backup attempt, the `batch_calls` window and the `debounce` quiet period. Neither `fast_mode()` nor `fake_clock()` changes them, and sync and async code behave the same. Making them instant would turn "wait at most 5 s" into "fail at once" (or "hedge at once"). Test them with tiny real durations, or call `.flush()` / `.aflush()` on `debounce` and `batch_calls`.
-
-| Behaviour | `fast_mode()` | `fake_clock()` |
-| --- | --- | --- |
-| `retry` backoff | instant | advances the clock |
-| `rate_limit(block=True)` / `RateLimiter(block=True)` waiting | the call goes through at once and is counted | advances the clock, then goes through |
-| `rate_limit` / `throttle` non-blocking rejection | still rejects | windows follow the fake clock |
-| TTLs, circuit breaker recovery, `deadline` remaining time | real time | follow the fake clock |
-| `timeout`, `Bulkhead` / `race` timeouts, `hedge` delay, `batch_calls` / `debounce` windows | real time | real time |
-
-Bulkhead and other concurrency limits are not rate limits: `disable_rate_limits()` leaves them on.
-
-## pytest fixtures
-
-Enable the plugin in your top-level `conftest.py`:
-
-
-```python
-# conftest.py
-pytest_plugins = ["python_wrap.testing"]
-```
-
-You get two fixtures:
-
-- `pw_fake_clock`: installs a `FakeClock` for the test and yields it.
-- `pw_fast_mode`: runs the test under `fast_mode()`.
-
-
-```python
-def test_backoff(pw_fake_clock):
- assert flaky() == "ok"
- assert pw_fake_clock.sleeps == [1.0, 2.0, 4.0]
-
-def test_quick(pw_fast_mode):
- ...
-```
-
-`python_wrap.testing` imports without pytest installed; the fixtures only exist when it is.
-
-## Spies
-
-`spy(func)` records every call without changing behaviour, for sync and async functions. `spy_on(obj, name)` patches a module, class or instance attribute for a block and restores it.
-
-```python
-import math
-from python_wrap.testing import spy, spy_on
-
-@spy
-def add(a: int, b: int) -> int:
- return a + b
-
-add(1, b=2)
-assert add.call_count == 1 and add.called
-add.assert_called_with(1, b=2)
-call = add.calls[0]
-assert (call.args, call.kwargs, call.result, call.exception) == ((1,), {"b": 2}, 3, None)
-add.reset()
-assert add.call_count == 0
-
-with spy_on(math, "floor") as floor:
- math.floor(2.5)
-assert floor.call_count == 1 # and math.floor is the original again
-```
-
-Each `Call` holds `args`, `kwargs`, `result`, `exception` and `duration`. On a class, a spy records `self` as the first argument.
-
-## Waiting for background work
-
-`eventually` (async) and `eventually_sync` retry an assertion until it passes or `timeout` runs out, then raise the last `AssertionError` annotated with how long it waited. They use real time unless a fake clock is installed.
-
-```python
-import asyncio
-import threading
-from python_wrap.testing import eventually, eventually_sync
-
-done = threading.Event()
-threading.Timer(0.02, done.set).start()
-
-def finished() -> None:
- assert done.is_set()
-
-eventually_sync(finished, timeout=2.0, interval=0.005)
-
-async def main() -> None:
- state = {"ready": False}
- asyncio.get_running_loop().call_later(0.02, state.update, {"ready": True})
-
- async def check() -> None: # sync or async assertions both work
- assert state["ready"]
-
- await eventually(check, timeout=2.0, interval=0.005)
-
-asyncio.run(main())
-```
-
-!!! tip "Under `fast_mode()`"
- `eventually` waits through `_core`, so inside `fast_mode()` its polling interval is zero and it spins until `timeout`. Leave it outside fast mode when you wait on real threads.
-
-## Patching the clock by hand
-
-Everything above is built on four module attributes of `python_wrap._core`, always read as attributes so they can be patched:
-
-| Attribute | Used for |
-| --- | --- |
-| `_core.monotonic()` | Rate-limit windows, throttle, cache TTL, circuit recovery, retry `elapsed` |
-| `_core.perf_counter()` | `timing`, `benchmark`, `audit` durations |
-| `_core.sleep(seconds)` | Waiting in sync `retry` and `rate_limit(block=True)` |
-| `_core.async_sleep(seconds)` | The same, for `async def` functions |
-
-If you prefer `unittest.mock`, patch them directly:
-
-```python
-from unittest import mock
-from python_wrap import _core, retry
-
-sleeps: list[float] = []
-calls = []
-
-@retry(max_attempts=4, delay=1.0, backoff=2, on=ConnectionError)
-def flaky() -> str:
- calls.append(1)
- if len(calls) < 4:
- raise ConnectionError
- return "ok"
-
-with mock.patch.object(_core, "sleep", sleeps.append):
- assert flaky() == "ok"
-
-assert sleeps == [1.0, 2.0, 4.0]
-```
-
-With it, you can test expiry and recovery without waiting:
-
-```python
-from unittest import mock
-from python_wrap import _core, memoize
-
-now = [1000.0]
-calls = []
-
-@memoize(ttl=60)
-def load() -> int:
- calls.append(1)
- return len(calls)
-
-with mock.patch.object(_core, "monotonic", lambda: now[0]):
- assert load() == 1
- now[0] += 59
- assert load() == 1 # still fresh
- now[0] += 2
- assert load() == 2 # expired: recomputed
-```
-
-The same trick moves a circuit breaker from open to half-open:
-
-```python
-from unittest import mock
-from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState, _core
-
-now = [0.0]
-breaker = CircuitBreaker(failure_threshold=1, recovery_timeout=30)
-
-@breaker
-def call() -> None:
- raise ConnectionError
-
-with mock.patch.object(_core, "monotonic", lambda: now[0]):
- try:
- call()
- except ConnectionError:
- pass
- assert breaker.state is CircuitState.OPEN
- try:
- call()
- except CircuitOpenError as exc:
- assert exc.retry_after == 30
-
- now[0] += 31
- assert breaker.state is CircuitState.HALF_OPEN
-```
-
-!!! tip "Async"
- With `mock`, patch `_core.async_sleep` with an `async def` that records and returns (as `FakeClock.async_sleep` does) so `async def` retries do not wait either.
-
-## Reset state between tests
-
-State lives on the decorated function, so it outlives a single test. Reset it in a fixture.
-
-| State | Reset with |
-| --- | --- |
-| `memoize` / `cache` entries and statistics | `func.cache_clear()` (or `func.cache_delete(*args)` for one entry) |
-| `once` result | `func.reset()` |
-| Circuit breaker | `breaker.reset()` (or `func.breaker.reset()` for `@circuit_breaker`) |
-| `debounce` pending call | `func.cancel()` |
-
-```python
-from python_wrap import CircuitState, circuit_breaker, memoize, once
-
-@memoize
-def lookup(n: int) -> int:
- return n
-
-@once
-def setup() -> object:
- return object()
-
-@circuit_breaker(failure_threshold=1)
-def call() -> None:
- raise RuntimeError
-
-lookup(1); setup()
-try:
- call()
-except RuntimeError:
- pass
-assert call.breaker.state is CircuitState.OPEN
-
-# What an autouse fixture would do after every test:
-lookup.cache_clear()
-setup.reset()
-call.breaker.reset()
-
-assert lookup.cache_info().currsize == 0
-assert setup.called is False
-assert call.breaker.state is CircuitState.CLOSED
-```
-
-
-```python
-# conftest.py
-import pytest
-
-from myapp.api import call_api, lookup, setup_once
-
-
-@pytest.fixture(autouse=True)
-def reset_python_wrap_state():
- yield
- lookup.cache_clear()
- setup_once.reset()
- call_api.breaker.reset()
-```
-
-`rate_limit`, `throttle` and `singleflight` have no reset method. Use the fake clock to move past the window, or build a fresh decorated function inside the test.
-
-## Disabling `mock_data`
-
-`mock_data(enabled=...)` is evaluated on **every call**, so a test only has to flip whatever it reads:
-
-```python
-import os
-from python_wrap import mock_data
-
-@mock_data({"id": 1}, enabled=lambda: os.environ.get("USE_MOCKS") == "1")
-def fetch() -> dict:
- return {"id": 2}
-
-os.environ["USE_MOCKS"] = "1"
-assert fetch() == {"id": 1}
-
-os.environ["USE_MOCKS"] = "0" # tests of the real behaviour
-assert fetch() == {"id": 2}
-```
-
-In pytest, use `monkeypatch.setenv("USE_MOCKS", "0")` so the change is undone afterwards.
-
-## Bypass a decorator
-
-Wrapped functions keep `__wrapped__` (they use `functools.wraps`), so you can call the undecorated function in a unit test:
-
-```python
-from python_wrap import retry
-
-calls = []
-
-@retry(max_attempts=3, delay=0)
-def flaky() -> None:
- calls.append(1)
- raise ValueError
-
-try:
- flaky.__wrapped__() # skips the retry loop
-except ValueError:
- pass
-assert len(calls) == 1
-```
-
-## Capturing logs
-
-python-wrap logs to the `python_wrap` logger (or your `logger=`), so pytest's `caplog` works as usual:
-
-
-```python
-import logging
-
-def test_retry_logs_a_warning(caplog):
- caplog.set_level(logging.WARNING, logger="python_wrap")
- ...
- assert "retrying in" in caplog.text
-```
-
-## Silence deprecation warnings you expect
-
-
-```python
-import pytest
-
-def test_old_api_warns():
- with pytest.warns(DeprecationWarning, match="old_func"):
- old_func()
-```
-
-`deprecate` points the warning at the caller's line, so `-W error::DeprecationWarning` shows which test line used the old API.
diff --git a/src/python_wrap/validation.py b/src/python_wrap/validation.py
index e887fee..a013c0e 100644
--- a/src/python_wrap/validation.py
+++ b/src/python_wrap/validation.py
@@ -632,7 +632,7 @@ def enforce_types(func: Callable[..., Any] | None = None, /, *, check_return: bo
are not). For ``async def`` functions the awaited result is checked. The supported
subset of annotations is listed under "Annotations supported by `enforce_types`" in
the `python_wrap.validation` module docstring (the top of its API reference page) and
- in the [validation guide](../guides/validation.md#supported-annotations); anything
+ in the [validation guide](../features/correctness/validation.md); anything
unsupported is accepted rather than rejected.
Args:
From 25bbbd2642faf6df32f4d111f6101ef231854281 Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 17:05:48 +0300
Subject: [PATCH 09/10] Point README and home page at the new docs structure;
correct the errors docstring
---
README.md | 20 ++++++++++----------
docs/features/caching/index.md | 2 +-
docs/index.md | 20 ++++++++++----------
docs/project/migration.md | 2 +-
src/python_wrap/errors.py | 6 ++++--
5 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index c024309..5ba5965 100644
--- a/README.md
+++ b/README.md
@@ -19,8 +19,8 @@ Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhea
[**Documentation**](https://python-wrap.readthedocs.io) ·
[Cookbook](https://python-wrap.readthedocs.io/en/latest/cookbook/) ·
-[Performance](https://python-wrap.readthedocs.io/en/latest/performance/) ·
-[Migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) ·
+[Performance](https://python-wrap.readthedocs.io/en/latest/project/performance/) ·
+[Migration guide](https://python-wrap.readthedocs.io/en/latest/project/migration/) ·
[Changelog](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md)
@@ -33,7 +33,7 @@ Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhea
- **Zero dependencies.** Pure Python, nothing to audit or pin.
- **Typed.** Decorators preserve the wrapped signature (`ParamSpec`), and stacking keeps the exact type (`@retry` over `@memoize` still has `.cache_info()`). Ships `py.typed`, checked with `mypy --strict`.
- **Sync and async, one API.** Works on `def`, `async def` and methods. Async wrappers stay coroutine functions and never block the event loop.
-- **Fast.** Cache hits and validation skip signature binding on the hot path ([numbers](https://python-wrap.readthedocs.io/en/latest/performance/)).
+- **Fast.** Cache hits and validation skip signature binding on the hot path ([numbers](https://python-wrap.readthedocs.io/en/latest/project/performance/)).
- **Observable and testable.** One event stream for retries, cache, circuit, limiters and timeouts; `python_wrap.testing` makes tests instant and deterministic.
- **Never configures your logging**, is thread-safe, and is tested on Python 3.10 to 3.14 on Linux, macOS and Windows.
@@ -113,7 +113,7 @@ print(f"{time.monotonic() - start:.2f}s")
## What is in the box
-Everything below is importable from `python_wrap` unless a module is shown. [Choose a decorator](https://python-wrap.readthedocs.io/en/latest/choosing/) is the quick way in.
+Everything below is importable from `python_wrap` unless a module is shown. [Choose a decorator](https://python-wrap.readthedocs.io/en/latest/get-started/choosing/) is the quick way in.
| Area | Name | What it does |
|---|---|---|
@@ -232,7 +232,7 @@ except TimeoutExpired:
print("out of time")
```
-After `recovery_timeout` the breaker goes half-open and lets a trial call through. Share one `CircuitBreaker` across functions for one dependency, or use `@circuit_breaker(...)` for a private one (`func.breaker`). `timeout` interrupts sync code with `SIGALRM` on the Unix main thread, cancels `async def` with `asyncio.wait_for`, and elsewhere falls back to a thread that cannot stop the work (see [Limitations](https://python-wrap.readthedocs.io/en/latest/limitations/)).
+After `recovery_timeout` the breaker goes half-open and lets a trial call through. Share one `CircuitBreaker` across functions for one dependency, or use `@circuit_breaker(...)` for a private one (`func.breaker`). `timeout` interrupts sync code with `SIGALRM` on the Unix main thread, cancels `async def` with `asyncio.wait_for`, and elsewhere falls back to a thread that cannot stop the work (see [Limitations](https://python-wrap.readthedocs.io/en/latest/project/limitations/)).
### Caching
@@ -359,7 +359,7 @@ print(names) # retry.attempt_failed, ..., retry.succeed
print(work.stats.calls, work.stats.p95)
```
-Subscribe once and forward events to Prometheus, OpenTelemetry or statsd (recipes in the [guide](https://python-wrap.readthedocs.io/en/latest/guides/metrics/)). Listeners cannot break the decorated function.
+Subscribe once and forward events to Prometheus, OpenTelemetry or statsd (recipes in the [guide](https://python-wrap.readthedocs.io/en/latest/features/observability/metrics/)). Listeners cannot break the decorated function.
### Validation and logging with redaction
@@ -411,7 +411,7 @@ Decorators are applied bottom-up and run top-down, so order matters:
| `log_args`, `timing`, `audit` | everything | They observe what callers actually experience |
| `enforce_types`, `validate_args` | the rest | Reject bad input before doing any work |
-You do not have to memorise this. `policy(observe=, validate=, fallback=, cache=, limiter=, bulkhead=, breaker=, retry=, timeout=, guard=)` takes each decorator by role and applies them in the recommended order, whatever order you wrote them in. Policies are immutable (`.with_(timeout=timeout(30))`), work on methods and `async def`, and `wrap_methods(policy)` protects a whole class. `compose(a, b, c)(f)` is `a(b(c(f)))` when you just want a named stack. Details: [Stacking](https://python-wrap.readthedocs.io/en/latest/stacking/) and [Policies](https://python-wrap.readthedocs.io/en/latest/guides/policies/).
+You do not have to memorise this. `policy(observe=, validate=, fallback=, cache=, limiter=, bulkhead=, breaker=, retry=, timeout=, guard=)` takes each decorator by role and applies them in the recommended order, whatever order you wrote them in. Policies are immutable (`.with_(timeout=timeout(30))`), work on methods and `async def`, and `wrap_methods(policy)` protects a whole class. `compose(a, b, c)(f)` is `a(b(c(f)))` when you just want a named stack. Details: [Stacking](https://python-wrap.readthedocs.io/en/latest/get-started/stacking/) and [Policies](https://python-wrap.readthedocs.io/en/latest/features/resilience/policies/).
## How it compares
@@ -433,14 +433,14 @@ None of them cover bounded `gather`, bulkheads, batching, hedging, policies or t
## Learn more
- [Cookbook](https://python-wrap.readthedocs.io/en/latest/cookbook/): resilient HTTP client, async crawler, FastAPI, Django, CLI tools, DataLoader batching, testing, observability.
-- [Performance](https://python-wrap.readthedocs.io/en/latest/performance/): per-call overhead and how to benchmark it yourself.
+- [Performance](https://python-wrap.readthedocs.io/en/latest/project/performance/): per-call overhead and how to benchmark it yourself.
- [Testing](https://python-wrap.readthedocs.io/en/latest/testing/): `fast_mode`, `FakeClock`, `spy` and pytest fixtures.
-- [Migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) and [CHANGELOG](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md).
+- [Migration guide](https://python-wrap.readthedocs.io/en/latest/project/migration/) and [CHANGELOG](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md).
- Runnable [examples](https://github.com/LitePy/PythonWrap/tree/main/examples).
## Upgrading from 0.1
-0.2.0 fixes decorators that did not do what their names promised (`transactional`, `revert_on_failure`, async `timing`) and makes a few breaking changes: `run_in_thread` returns a `Future`, `benchmark` is a function, `retry_on_exception` is deprecated and bounded, `rate_limit` raises `RateLimitExceeded`, and the logging decorators default to `DEBUG`. Python 3.8 and 3.9 are no longer supported. `from python_wrap.decorators import ...` still works. See the [migration guide](https://python-wrap.readthedocs.io/en/latest/migration/).
+0.2.0 fixes decorators that did not do what their names promised (`transactional`, `revert_on_failure`, async `timing`) and makes a few breaking changes: `run_in_thread` returns a `Future`, `benchmark` is a function, `retry_on_exception` is deprecated and bounded, `rate_limit` raises `RateLimitExceeded`, and the logging decorators default to `DEBUG`. Python 3.8 and 3.9 are no longer supported. `from python_wrap.decorators import ...` still works. See the [migration guide](https://python-wrap.readthedocs.io/en/latest/project/migration/).
## Contributing
diff --git a/docs/features/caching/index.md b/docs/features/caching/index.md
index f65863b..b608d9a 100644
--- a/docs/features/caching/index.md
+++ b/docs/features/caching/index.md
@@ -1,4 +1,4 @@
-# Caching and deduplication
+# Caching and batching
These decorators avoid doing the same work twice: remembering a result, running setup once, computing an attribute lazily, sharing one in-flight call between duplicate callers, or turning many small lookups into one bulk call.
diff --git a/docs/index.md b/docs/index.md
index 4995356..5fc7662 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,7 +15,7 @@ hide:
bulkhead, batching, async helpers, metrics and test helpers. One import, sync and async,
fully typed, zero dependencies.
-[Get started](#install){ .md-button .md-button--primary }
+[Get started](get-started/quickstart.md){ .md-button .md-button--primary }
[Choose a decorator](get-started/choosing.md){ .md-button }
[View on GitHub](https://github.com/LitePy/PythonWrap){ .md-button }
@@ -225,7 +225,7 @@ Not sure which decorator you need? Start with [Choose a decorator](get-started/c
---
- Its own exceptions (`TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, ...) also subclass the closest built-in, so `except TimeoutError` keeps working.
+ Every error subclasses `PythonWrapError`, and `TimeoutExpired`, `ValidationError` and `ArgumentTypeError` also subclass the matching built-in, so `except TimeoutError` keeps working. See [Errors](get-started/errors.md).
@@ -235,23 +235,23 @@ Some of these tools cannot do everything (a thread timeout cannot stop the work,
-- :material-compass-outline:{ .lg .middle } __[Choose a decorator](get-started/choosing.md)__
+- :material-rocket-launch-outline:{ .lg .middle } __[Quickstart](get-started/quickstart.md)__
---
- A table by problem: what to reach for, and what to combine it with.
+ Build a resilient client step by step: retry, timeout, cache, circuit breaker, policy, metrics and fast tests.
-- :material-book-open-variant:{ .lg .middle } __Guides__
+- :material-compass-outline:{ .lg .middle } __[Choose a decorator](get-started/choosing.md)__
---
- [Resilience](features/resilience/index.md), [Caching](features/caching/index.md), [Concurrency](features/concurrency/index.md), [Async toolkit](features/concurrency/async-helpers.md), [Batching](features/caching/batching.md), [Observability](features/observability/index.md), [Metrics](features/observability/metrics.md), [Validation](features/correctness/validation.md), [State](features/correctness/state.md), [Policies](features/resilience/policies.md), [Dev tools](features/devtools.md), [Configuration](features/configuration.md).
+ A table by problem: what to reach for, and what to combine it with.
-- :material-chef-hat:{ .lg .middle } __[Cookbook](cookbook/index.md)__
+- :material-view-grid-outline:{ .lg .middle } __[Features](features/index.md)__
---
- Complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests.
+ One page per feature: resilience, caching and batching, concurrency and limits, observability, correctness.
- :material-test-tube:{ .lg .middle } __[Testing](testing/index.md)__
@@ -259,11 +259,11 @@ Some of these tools cannot do everything (a thread timeout cannot stop the work,
`fast_mode`, a fake clock, spies and `eventually`: instant, deterministic tests.
-- :material-layers-triple-outline:{ .lg .middle } __[Stacking decorators](get-started/stacking.md)__
+- :material-chef-hat:{ .lg .middle } __[Cookbook](cookbook/index.md)__
---
- Order matters. The recommended order, and why `policy(...)` applies it for you.
+ Complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests.
- :material-swap-horizontal:{ .lg .middle } __[Migrating from 0.1](project/migration.md)__
diff --git a/docs/project/migration.md b/docs/project/migration.md
index 8ec5ddf..5e1a36d 100644
--- a/docs/project/migration.md
+++ b/docs/project/migration.md
@@ -1,6 +1,6 @@
# Migrating from 0.1 to 0.2
-Version 0.2 is a rewrite of the internals with a handful of deliberate breaking changes. Most v0.1 code keeps working: `from python_wrap.decorators import x` still resolves (a compatibility module re-exports everything), and the new exceptions subclass the closest built-ins.
+Version 0.2 is a rewrite of the internals with a handful of deliberate breaking changes. Most v0.1 code keeps working: `from python_wrap.decorators import x` still resolves (a compatibility module re-exports everything), and the exceptions with a natural built-in counterpart subclass it (`TimeoutExpired` is a `TimeoutError`, `ValidationError` a `ValueError`).
**Checklist**
diff --git a/src/python_wrap/errors.py b/src/python_wrap/errors.py
index 0818fb3..8ac648f 100644
--- a/src/python_wrap/errors.py
+++ b/src/python_wrap/errors.py
@@ -1,7 +1,9 @@
"""Exceptions raised by python-wrap.
-Each one also subclasses the closest built-in, so ``except TimeoutError`` or
-``except ValueError`` keeps working for code written against v0.1.
+Every one subclasses `PythonWrapError`. Those with a natural built-in counterpart also
+subclass it (`TimeoutExpired` is a `TimeoutError`, `ValidationError` a `ValueError`,
+`ArgumentTypeError` a `TypeError`), so ``except TimeoutError`` or ``except ValueError``
+keeps working for code written against v0.1.
"""
from __future__ import annotations
From 5da4840bf45e8b785a983949ec73c4b2644b2597 Mon Sep 17 00:00:00 2001
From: Mahmoud Ahmed
Date: Tue, 27 Oct 2026 17:06:01 +0300
Subject: [PATCH 10/10] Match the concurrency section title to the navigation
---
docs/features/concurrency/index.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/features/concurrency/index.md b/docs/features/concurrency/index.md
index 85f9828..e877be5 100644
--- a/docs/features/concurrency/index.md
+++ b/docs/features/concurrency/index.md
@@ -1,4 +1,4 @@
-# Concurrency
+# Concurrency and limits
Control how often a function runs, how many calls run at once, and how you run many awaitables or blocking calls without hand-writing the plumbing. Everything here is built for both threads and `asyncio`.