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/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/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/batching.md b/docs/features/caching/batching.md
similarity index 59%
rename from docs/guides/batching.md
rename to docs/features/caching/batching.md
index dda8dc0..d1241bf 100644
--- a/docs/guides/batching.md
+++ b/docs/features/caching/batching.md
@@ -1,15 +1,8 @@
-# Batching and hedging
+# Batching
-Two decorators for calls that go over the network:
+Fold many single-item calls made close together into one bulk call.
-| 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.
+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"
@@ -52,16 +45,22 @@ You often have code that loads one thing at a time — `await get_user(id)` in m
assert queries == [["a", "bb", "ccc", "dddd"]] # the 4th caller filled the batch
```
-### When a batch is sent
+## 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 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 output contract
The batch function receives a `list` of the **distinct** items, in the order they first arrived. It must return one of:
@@ -99,7 +98,7 @@ async def main() -> None:
asyncio.run(main())
```
-### Keys: deduplication and unhashable items
+## 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.
@@ -117,28 +116,11 @@ async def main() -> None:
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
-### 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.
+* `.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
@@ -158,7 +140,23 @@ async def main() -> None:
asyncio.run(main())
```
-### Methods
+## 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.
@@ -181,83 +179,16 @@ async def main() -> None:
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.
+## Gotchas
-!!! 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
+!!! 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.
-@hedge(0.01)
-def lookup(key: str) -> str:
- return key.upper()
+!!! 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.
-assert lookup("a") == "A"
-```
+## See also
-`hedge` keeps the decorated function's signature and type, so it stacks with other decorators and works on methods.
+- [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..b608d9a
--- /dev/null
+++ b/docs/features/caching/index.md
@@ -0,0 +1,55 @@
+# 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.
+
+
+
+- :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)
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..e877be5
--- /dev/null
+++ b/docs/features/concurrency/index.md
@@ -0,0 +1,58 @@
+# 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`.
+
+
+
+- :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)
diff --git a/docs/guides/configuration.md b/docs/features/configuration.md
similarity index 81%
rename from docs/guides/configuration.md
rename to docs/features/configuration.md
index 2eb27d4..af6a56b 100644
--- a/docs/guides/configuration.md
+++ b/docs/features/configuration.md
@@ -1,4 +1,4 @@
-# Project-wide configuration
+# Configuration
Repeating `retry(max_attempts=5, delay=0.5, ...)` or `redact=(...)` on every decorator is how defaults drift. `python_wrap.config` sets them once.
@@ -15,6 +15,11 @@ 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.
@@ -59,7 +64,7 @@ except TypeError as 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
+## Testing
`configured()` applies defaults for a block and restores the previous state on exit, even after an error. Apply the decorators inside the block.
@@ -75,3 +80,14 @@ 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/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/guides/state.md b/docs/features/correctness/state.md
similarity index 85%
rename from docs/guides/state.md
rename to docs/features/correctness/state.md
index 62f3f49..3f74add 100644
--- a/docs/guides/state.md
+++ b/docs/features/correctness/state.md
@@ -2,10 +2,6 @@
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
@@ -31,7 +27,15 @@ 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.
+## 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 |
| --- | --- |
@@ -63,11 +67,11 @@ 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.
+!!! 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.
@@ -125,7 +129,7 @@ A `Transaction` collects undo steps (`on_rollback`) and follow-up steps (`on_com
## `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.
+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"
@@ -181,7 +185,7 @@ Wrap a function in a fresh `Transaction` per call. Returning normally commits; r
```
!!! 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.
+ 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
@@ -212,3 +216,9 @@ 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/guides/validation.md b/docs/features/correctness/validation.md
similarity index 92%
rename from docs/guides/validation.md
rename to docs/features/correctness/validation.md
index 653babe..f7bc341 100644
--- a/docs/guides/validation.md
+++ b/docs/features/correctness/validation.md
@@ -2,6 +2,28 @@
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`) |
@@ -199,11 +221,7 @@ for bad in ({"mode": "turbo"}, {"mode": "fast", "tags": {"a": ["x"]}}):
!!! 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`.
+## Combining them
They stack: `enforce_types` for shape, `validate_args` for rules.
@@ -217,3 +235,9 @@ def charge(account: str, amount: float) -> str:
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/guides/devtools.md b/docs/features/devtools.md
similarity index 85%
rename from docs/guides/devtools.md
rename to docs/features/devtools.md
index f705010..61dae5f 100644
--- a/docs/guides/devtools.md
+++ b/docs/features/devtools.md
@@ -1,10 +1,6 @@
# 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).
+Tools for evolving an API, faking data, and keeping decorator stacks tidy: `deprecate`, `mock_data`, `compose`, `wrap_methods` and `no_debug`.
```python
import warnings
@@ -23,6 +19,17 @@ 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"
@@ -73,7 +80,10 @@ The structured parts are combined into `" is deprecated since ... and will
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.
+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`
@@ -115,7 +125,7 @@ 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).
+ `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`
@@ -142,14 +152,14 @@ 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.
+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](policies.md) works as well.
+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
@@ -191,4 +201,11 @@ Plain, `async def`, `@staticmethod` and `@classmethod` methods are decorated cor
## `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.
+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/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/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/guides/observability.md b/docs/features/observability/logging.md
similarity index 51%
rename from docs/guides/observability.md
rename to docs/features/observability/logging.md
index 24f813d..21b1982 100644
--- a/docs/guides/observability.md
+++ b/docs/features/observability/logging.md
@@ -1,10 +1,46 @@
-# Observability
+# Logging
-Find out how long calls take, how much they allocate, what they were called with, and who called them.
+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 below are silent.
+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:
@@ -50,166 +86,15 @@ 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
-```
+`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
+## Redaction
-`log_args` (and `audit`) mask parameters whose **name contains** any of the redaction fragments, case-insensitively. The masked value is shown as `'***'`.
+`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`.
@@ -260,45 +145,11 @@ 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).
+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)`).
@@ -366,4 +217,11 @@ async def run() -> dict[str, object]:
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).
+!!! 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/guides/metrics.md b/docs/features/observability/metrics.md
similarity index 80%
rename from docs/guides/metrics.md
rename to docs/features/observability/metrics.md
index 1fd5793..978af66 100644
--- a/docs/guides/metrics.md
+++ b/docs/features/observability/metrics.md
@@ -1,11 +1,6 @@
# 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`
+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
@@ -31,6 +26,14 @@ 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:
@@ -51,7 +54,8 @@ async def main() -> None:
asyncio.run(main())
```
-On methods the statistics belong to the function, so they are shared by every instance. Cancellation counts as an error.
+!!! 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
@@ -84,8 +88,6 @@ assert seen[0].fields["attempt"] == 1
- `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
@@ -94,6 +96,9 @@ 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).
@@ -197,3 +202,9 @@ 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)
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/guides/policies.md b/docs/features/resilience/policies.md
similarity index 79%
rename from docs/guides/policies.md
rename to docs/features/resilience/policies.md
index 8d96539..ab52513 100644
--- a/docs/guides/policies.md
+++ b/docs/features/resilience/policies.md
@@ -1,6 +1,6 @@
# 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.
+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
@@ -20,7 +20,13 @@ assert charge(5) == 5
print(payments) # Policy('payments', breaker > retry > timeout)
```
-## Why keyword roles
+## 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.
@@ -58,7 +64,7 @@ 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.
+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
@@ -73,5 +79,16 @@ async def load(x: int) -> int:
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)
diff --git a/docs/async.md b/docs/get-started/async-and-methods.md
similarity index 79%
rename from docs/async.md
rename to docs/get-started/async-and-methods.md
index d301456..7d7679e 100644
--- a/docs/async.md
+++ b/docs/get-started/async-and-methods.md
@@ -1,4 +1,4 @@
-# Async support
+# 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.
@@ -35,7 +35,7 @@ for decorator in (
assert inspect.iscoroutinefunction(decorator(probe)), decorator
```
-Methods work too, including `async def` methods; `self` is just the first argument.
+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
@@ -52,7 +52,7 @@ Methods work too, including `async def` methods; `self` is just the first argume
| `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).) |
+| `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. |
@@ -75,7 +75,16 @@ async def job() -> int:
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`.
+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
diff --git a/docs/choosing.md b/docs/get-started/choosing.md
similarity index 61%
rename from docs/choosing.md
rename to docs/get-started/choosing.md
index 90a12bc..5d13b66 100644
--- a/docs/choosing.md
+++ b/docs/get-started/choosing.md
@@ -1,4 +1,4 @@
-# Which decorator do I need?
+# Choosing a decorator
Find your problem, use the decorator in the second column.
@@ -6,94 +6,94 @@ Find your problem, use the decorator in the second column.
| 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) |
+| 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](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) |
+| 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](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) |
+| `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](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) |
+| 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](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) |
+| 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](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) |
+| 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](guides/state.md#revert_on_failure) |
-| Register undo steps and run them if anything fails | `@transactional` / `Transaction` | [State safety](guides/state.md#transactions) |
+| 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.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) |
+| 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
@@ -152,4 +152,4 @@ Find your problem, use the decorator in the second column.
...
```
- The roles decide the order, so listing them differently changes nothing. See [Policies](guides/policies.md).
+ 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/stacking.md b/docs/get-started/stacking.md
similarity index 96%
rename from docs/stacking.md
rename to docs/get-started/stacking.md
index f08a992..77c6625 100644
--- a/docs/stacking.md
+++ b/docs/get-started/stacking.md
@@ -159,7 +159,7 @@ def load_profile(user_id: int) -> dict:
...
```
-Put the same stack on many functions with [`compose`](guides/devtools.md#compose).
+Put the same stack on many functions with [`compose`](../features/devtools.md).
## Rules of thumb
@@ -167,5 +167,5 @@ Put the same stack on many functions with [`compose`](guides/devtools.md#compose
- **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).
+- **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/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/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/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/index.md b/docs/index.md
index 8a34724..5fc7662 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,8 +15,8 @@ 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 }
-[Choose a decorator](choosing.md){ .md-button }
+[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 }
@@ -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,65 +207,65 @@ 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__
---
- 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).
-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-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](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).
+ 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.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-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](migration.md)__
+- :material-swap-horizontal:{ .lg .middle } __[Migrating from 0.1](project/migration.md)__
---
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 96%
rename from docs/limitations.md
rename to docs/project/limitations.md
index a52f7ea..8b9a126 100644
--- a/docs/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/migration.md b/docs/project/migration.md
similarity index 97%
rename from docs/migration.md
rename to docs/project/migration.md
index 75ec8d1..5e1a36d 100644
--- a/docs/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**
@@ -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/performance.md b/docs/project/performance.md
similarity index 100%
rename from docs/performance.md
rename to docs/project/performance.md
diff --git a/docs/testing.md b/docs/testing/index.md
similarity index 98%
rename from docs/testing.md
rename to docs/testing/index.md
index 6485ef0..c18a7b9 100644
--- a/docs/testing.md
+++ b/docs/testing/index.md
@@ -4,6 +4,8 @@ Decorators that wait or remember make tests slow or order-dependent. python-wrap
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:
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/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/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:]))
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
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:
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 = [