Skip to content

Commit 4744f11

Browse files
lesnik512claude
andauthored
Extract _RetryPolicy decision module (+ agent-skills config) (#76)
* chore(planning): add agent-skills config under planning/agents Scaffold the per-repo config the engineering skills expect: - issue tracker = GitHub Issues (gh CLI), external PRs not a triage surface - triage labels = canonical defaults (wontfix already exists) - domain docs = single-context, CONTEXT.md at root + ADRs under planning/adr Internal docs live under planning/, not the user-facing docs/ site. Adds an ## Agent skills block to CLAUDE.md pointing at the three files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(planning): add retry-policy-extraction change bundle Full-lane design.md + plan.md for extracting a stateless _RetryPolicy decision module from the duplicated AsyncRetry/Retry __call__ loops, mirroring the _CircuitBreakerState precedent. Design only — no source changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(retry): extract stateless _RetryPolicy decision module Move the ~110 lines of retry decision logic duplicated across AsyncRetry.__call__ and Retry.__call__ into a stateless _RetryPolicy, mirroring the _CircuitBreakerState precedent. Both wrappers shrink to a thin loop (deposit -> try/next -> decide -> sleep) differing only in await/blocking. Behaviour byte-identical; public __init__ unchanged; .budget preserved, six config attributes moved onto the policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(retry): cover _RetryPolicy.decide at the seam Direct decision-matrix tests for the new policy module — no client, no MockTransport: retryable->delay (bounds), non-retryable/non-eligible re-raise, streaming refusal, exhaustion note, Retry-After exact/exceeds, budget refusal with __cause__, and moved max_attempts validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(resilience): promote _RetryPolicy into architecture truth Document the shared stateless _RetryPolicy + thin-wrapper structure in architecture/resilience.md. Includes lint cleanups surfaced by just lint: drop a redundant PLR0912 noqa on decide, fix a docstring mood + magic value in the new seam tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(planning): mark retry-policy-extraction shipped (#76) Set status: shipped, pr: 76, and outcome on the change bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agents): rename triage-labels column to Canonical role Drop the leftover 'Label in mattpocock/skills' seed-template header; the column is the canonical role name, mapped to our tracker's label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c0facb commit 4744f11

9 files changed

Lines changed: 803 additions & 229 deletions

File tree

‎CLAUDE.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,17 @@ Three documented internal boundaries. AI agents must respect them — never cros
9696

9797
- Check the relevant [`architecture/`](architecture/) capability file before adding a new module or extension point.
9898
- Surface ambiguity as a documentation gap rather than improvising.
99+
100+
## Agent skills
101+
102+
### Issue tracker
103+
104+
Issues live in GitHub Issues (`modern-python/httpware`), managed via the `gh` CLI; external PRs are not a triage surface. See `planning/agents/issue-tracker.md`.
105+
106+
### Triage labels
107+
108+
Canonical defaults — `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix` (the last already exists). See `planning/agents/triage-labels.md`.
109+
110+
### Domain docs
111+
112+
Single-context — one `CONTEXT.md` at the repo root + ADRs under `planning/adr/`. See `planning/agents/domain.md`.

‎architecture/resilience.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
`Retry` (and `AsyncRetry`) is a retry middleware backed by a Finagle-style `RetryBudget` — a token bucket that caps the proportion of traffic spent on retries so a degraded backend cannot be amplified into a retry storm. `RetryBudget` is a single thread-safe class shared by both worlds: all mutations go through a `threading.Lock`, so state is never torn. "Safe" here means no corruption, not non-blocking — when one budget is shared across a (sync `Client`, `AsyncClient`) pair, a sync thread holding the lock can briefly block the event-loop thread's acquisition. The critical section is intentionally tiny to bound that latency. Backoff between attempts uses full-jitter.
88

9+
The decision logic — status/method eligibility, streaming-body refusal, exhaustion, Retry-After handling, budget accounting, and the backoff delay — lives once in a stateless private `_RetryPolicy.decide`, the retry analog of how the circuit breaker keeps its transition logic in one shared state object. `Retry` and `AsyncRetry` are thin loop drivers over that policy: they own the attempt loop, the terminal call, and the sleep, and differ only in `await next` vs `next` and `asyncio.sleep` vs `time.sleep`. `decide` returns the delay to sleep before the next attempt, or raises the terminal exception (with its PEP 678 note and event already emitted); because it runs inside the wrapper's `except` block, exception chaining behaves as a direct raise. `_RetryPolicy` holds the immutable config plus the shared `RetryBudget`; per-attempt state stays as wrapper locals, so one instance is safe across the concurrent requests it serves.
10+
911
## Bulkhead
1012

1113
`Bulkhead` / `AsyncBulkhead` is a concurrency limiter. `AsyncBulkhead` uses `asyncio.Semaphore` with a bounded acquire wait; sync `Bulkhead` uses `threading.Semaphore`. A sync instance cannot share with an async one. Both are sharable across clients (one instance = one shared concurrency pool).

‎planning/agents/domain.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Domain Docs
2+
3+
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4+
5+
**Layout: single-context.** One `CONTEXT.md` at the repo root + ADRs under `planning/adr/`.
6+
7+
ADRs live under `planning/` (internal docs) rather than `docs/` (the user-facing mkdocs site). This repo also keeps per-capability living truth in [`architecture/`](../../architecture/) and per-change design under [`planning/changes/`](../changes/) — read those for established context before writing a new ADR.
8+
9+
## Before exploring, read these
10+
11+
- **`CONTEXT.md`** at the repo root.
12+
- **`planning/adr/`** — read ADRs that touch the area you're about to work in.
13+
14+
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
15+
16+
## File structure
17+
18+
Single-context repo (this repo):
19+
20+
```
21+
/
22+
├── CONTEXT.md
23+
├── planning/adr/
24+
│ ├── 0001-some-decision.md
25+
│ └── 0002-another-decision.md
26+
└── src/
27+
```
28+
29+
## Use the glossary's vocabulary
30+
31+
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
32+
33+
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
34+
35+
## Flag ADR conflicts
36+
37+
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
38+
39+
> _Contradicts ADR-0007 (some decision) — but worth reopening because…_

‎planning/agents/issue-tracker.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Issue tracker: GitHub
2+
3+
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
4+
5+
## Conventions
6+
7+
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
8+
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
9+
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
10+
- **Comment on an issue**: `gh issue comment <number> --body "..."`
11+
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
12+
- **Close**: `gh issue close <number> --comment "..."`
13+
14+
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. This repo's remote is `modern-python/httpware`.
15+
16+
## Pull requests as a triage surface
17+
18+
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
19+
20+
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
21+
22+
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
23+
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
24+
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
25+
26+
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
27+
28+
## When a skill says "publish to the issue tracker"
29+
30+
Create a GitHub issue.
31+
32+
## When a skill says "fetch the relevant ticket"
33+
34+
Run `gh issue view <number> --comments`.

‎planning/agents/triage-labels.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Triage Labels
2+
3+
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4+
5+
| Canonical role | Label in our tracker | Meaning |
6+
| -------------------------- | -------------------- | ---------------------------------------- |
7+
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8+
| `needs-info` | `needs-info` | Waiting on reporter for more information |
9+
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10+
| `ready-for-human` | `ready-for-human` | Requires human implementation |
11+
| `wontfix` | `wontfix` | Will not be actioned |
12+
13+
`wontfix` already exists in this repo's GitHub labels. The other four are created on first use by `/triage` (`gh label create <name>`).
14+
15+
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
16+
17+
Edit the right-hand column to match whatever vocabulary you actually use.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
---
2+
status: shipped
3+
date: 2026-06-23
4+
slug: retry-policy-extraction
5+
summary: Extract a stateless _RetryPolicy decision module from the duplicated AsyncRetry/Retry __call__ loops.
6+
supersedes: null
7+
superseded_by: null
8+
pr: 76
9+
outcome: Shipped via #76 — decision logic moved into a stateless _RetryPolicy.decide; AsyncRetry/Retry are now thin loop drivers, the ~110-line sync/async duplication is gone, behaviour byte-identical (718 tests, 100% coverage). New seam suite tests/test_retry_policy.py; promoted into architecture/resilience.md. Internal refactor — no release.
10+
---
11+
12+
# Design: Extract a deep `_RetryPolicy` decision module
13+
14+
## Summary
15+
16+
`AsyncRetry.__call__` and `Retry.__call__` hand-copy ~110 lines of retry
17+
*decision* logic — status eligibility, streaming-body refusal, exhaustion,
18+
Retry-After parsing, budget accounting, backoff — differing only in `await
19+
next` vs `next` and `asyncio.sleep` vs `time.sleep`. This change pulls the
20+
decision logic into a stateless private `_RetryPolicy` in the same module, so
21+
both wrappers shrink to a thin loop and the decision lives once. It mirrors
22+
the precedent already in the package: `CircuitBreaker`/`AsyncCircuitBreaker`
23+
share the lock-free `_CircuitBreakerState`.
24+
25+
## Motivation
26+
27+
- `retry.py:100-210` (`AsyncRetry.__call__`) and `retry.py:213-349`
28+
(`Retry.__call__`) are ~110 lines each, byte-identical except the `await`.
29+
Parity is hand-maintained; drift is undetectable. Both carry
30+
`# noqa: C901, PLR0912, PLR0915` to silence the complexity budget.
31+
- The package already proved the fix: `_CircuitBreakerState`
32+
(`circuit_breaker.py:131-310`) is a deep, synchronous, lock-free decision
33+
module that both breaker wrappers drive. Retry never got the same treatment.
34+
- **Depth:** the retry interface (the `Middleware` protocol — one `__call__`)
35+
is small, but the implementation is duplicated rather than deep. Moving the
36+
decision behind `_RetryPolicy.decide` concentrates it: one place to fix a
37+
retry bug (locality), one interface to test directly without `MockTransport`
38+
(leverage).
39+
40+
## Non-goals
41+
42+
- No behaviour change. The retry policy, defaults, events, notes, and raised
43+
exceptions stay byte-identical.
44+
- Not touching `RetryBudget`, `_backoff.full_jitter_delay`, or
45+
`_parse_retry_after` — they stay as-is.
46+
- Not unifying the sync/async wrappers themselves — the `await`/blocking split
47+
is fundamental and stays in the two thin `__call__` shells.
48+
- Not extending the same treatment to `Bulkhead` in this change.
49+
50+
## Design
51+
52+
### 1. `_RetryPolicy` — stateless decision module
53+
54+
A private class in `retry.py`, holding **immutable config + the shared
55+
budget** and nothing per-call mutable. This is the faithful analog of
56+
`_CircuitBreakerState`: there the *circuit* is the shared state; here the
57+
shared state is the already-thread-safe `RetryBudget`, and `_RetryPolicy` is
58+
the decision logic around it. Because it carries no per-call field, it is
59+
trivially safe under the concurrent requests a single frozen middleware
60+
instance serves.
61+
62+
It owns:
63+
64+
- config: `max_attempts`, `base_delay`, `max_delay`, `retry_status_codes`,
65+
`retry_methods`, `respect_retry_after`, `budget`;
66+
- validation: `max_attempts < 1` → `ValueError` (raised when the wrapper
67+
builds the policy in `__init__`, so construction-time behaviour is
68+
unchanged);
69+
- the `_LOGGER` event emissions and PEP-678 note additions (side effects move
70+
here with the decision).
71+
72+
### 2. The seam — one method
73+
74+
```python
75+
def decide(self, *, attempt: int, request: httpx2.Request, exc: BaseException) -> float
76+
```
77+
78+
- **Returns** the `float` delay to sleep for the retry case.
79+
- **Raises** for every terminal case, having already added the note, emitted
80+
the event, and (for the budget case) constructed `RetryBudgetExhaustedError`
81+
with its `__cause__`. `decide` is called *inside* the wrapper's `except`
82+
block, so implicit `__context__` and explicit `raise ... from exc` chaining
83+
behave exactly as today — no manual `__cause__` fiddling.
84+
85+
Classification is folded in (no separate predicate): derive `last_response`
86+
from `isinstance(exc, StatusError)`; apply method-eligibility and status-set
87+
membership; re-raise non-retryable failures unchanged; otherwise walk
88+
streaming-refusal → exhaustion → Retry-After-exceeds-`max_delay` → budget
89+
`try_withdraw` → delay (Retry-After value or `full_jitter_delay`).
90+
91+
Rejected alternative: a `_Sleep | _Stop` sum type. It defers the raise to
92+
*after* the `except` block, losing the active exception context and forcing
93+
manual chain reconstruction — machinery that exists only to paper over that.
94+
Returning-a-delay-or-raising matches `_CircuitBreakerState.admit()`, which
95+
already raises `CircuitOpenError` rather than returning a rejected value.
96+
97+
### 3. The wrappers shrink to a thin driver
98+
99+
```python
100+
_RETRYABLE_EXCEPTIONS = (StatusError, NetworkError, TimeoutError)
101+
102+
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response:
103+
self.budget.deposit()
104+
for attempt in range(self._policy.max_attempts):
105+
try:
106+
return await next(request)
107+
except _RETRYABLE_EXCEPTIONS as exc:
108+
delay = self._policy.decide(attempt=attempt, request=request, exc=exc)
109+
await self._sleep(delay)
110+
raise AssertionError("unreachable") # pragma: no cover
111+
```
112+
113+
The sync `Retry.__call__` is identical but for `next(request)` and
114+
`self._sleep(delay)`. `_RETRYABLE_EXCEPTIONS` is one module constant
115+
referenced by both — the narrow catch surface stays structural, so anything
116+
not in the tuple (e.g. `httpx2.InvalidURL`, programming errors) propagates
117+
untouched exactly as today. The `# noqa: C901, PLR0912, PLR0915` suppressions
118+
come off `__call__`; `decide` may carry its own.
119+
120+
### 4. Preserved public contract
121+
122+
- `AsyncRetry.__init__` / `Retry.__init__` signatures unchanged (incl.
123+
`_sleep`, `budget`).
124+
- The wrapper keeps `self.budget` (the *same object* the policy holds, so
125+
`r1.budget is r2.budget` identity tests pass) and `self._sleep`.
126+
- The six config attributes (`max_attempts`, `base_delay`, `max_delay`,
127+
`retry_status_codes`, `retry_methods`, `respect_retry_after`) are **dropped**
128+
from the wrapper instances — they live solely on `_RetryPolicy`. They are
129+
read nowhere outside `retry.py` and `docs/resilience.md` documents them only
130+
as constructor parameters, not readable attributes.
131+
132+
## Operations
133+
134+
None — internal refactor, no infra or external changes.
135+
136+
## Out of scope
137+
138+
- `Bulkhead`/`AsyncBulkhead` deduplication.
139+
- Injecting randomness into `full_jitter_delay` (see Testing — only needed if
140+
we want exact-value assertions on the jitter path).
141+
142+
## Testing
143+
144+
- **Parity net:** all existing `MockTransport` suites — `test_retry.py`,
145+
`test_retry_sync.py`, `test_retry_props.py`,
146+
`test_retry_budget_threadsafety.py`, `test_threading_with_shared_budget.py`
147+
— stay green unchanged. Byte-identical behaviour is the bar.
148+
- **New seam tests:** `tests/test_retry_policy.py` drives `decide` directly
149+
(no client, no `MockTransport`) across the decision matrix: retryable →
150+
returns a delay; non-retryable status / non-eligible method → re-raises the
151+
original; streaming-body refusal; exhaustion note on the last attempt;
152+
Retry-After > `max_delay`; budget refusal → `RetryBudgetExhaustedError` with
153+
`__cause__`.
154+
- The jitter path returns a random delay, so assert **bounds**
155+
(`0 ≤ delay ≤ max_delay`) for it; assert exact values only on the
156+
deterministic Retry-After path.
157+
- `just lint` and `just test` both clean.
158+
159+
## Risk
160+
161+
- **Behavioural drift during extraction** (likely × high): a subtle
162+
reordering changes a note string, an event payload, or which exception wins.
163+
*Mitigation:* extract under the existing green suites; they assert notes,
164+
events (via the recording sleeper / caplog), and exception types. Do not
165+
edit the test suites in this change.
166+
- **Exception-chaining regression** (low × medium): moving the raise into
167+
`decide` could drop a `__cause__`/`__context__`. *Mitigation:* `decide` is
168+
called inside the live `except`; an explicit test asserts `__cause__` on the
169+
budget-exhausted path.
170+
- **Concurrency** (low × high): a stray per-call field on `_RetryPolicy` would
171+
make a shared instance unsafe. *Mitigation:* the policy holds only immutable
172+
config + the lock-guarded budget; per-attempt state stays as wrapper locals.
173+
The property/thread-safety suites cover interleaving.

0 commit comments

Comments
 (0)