Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #483 +/- ##
==========================================
- Coverage 94.32% 91.08% -3.24%
==========================================
Files 49 49
Lines 10958 10613 -345
==========================================
- Hits 10336 9667 -669
- Misses 622 946 +324
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
Covers the mental model most likely to be got wrong — a cron expression here is a match PATTERN describing a window, not a timer describing instants, so `0 9 * * MON-FRI` is a one-minute window rather than a daily event. Then: scale vs absolute entries, first-match-wins ordering, timezones and DST, reset_schedule for daily quotas (and why a token bucket cannot express one), override-not-merge precedence, the YAML and CFN round trip, CLI display, boundary cost, and limitations. Lands with the feature rather than ahead of it: pushing to main deploys mike's `dev` with the `latest` alias and sets it default, so merging this before v0.14.0 ships would document an API that exists nowhere. Refs #222
…ence Task 5 (#504) tightened `parse_cron` to reject six-field expressions. cronsim supports an optional leading *seconds* field, so `30 5 9 * * *` previously constructed fine and then matched the whole of 09:05 — sixty times the window the author asked for. It now raises at construction. The guide's limitations section listed `L`/`W`/`#` as rejected and said "seconds are not addressable", which a reader could fairly take to mean a six-field expression is accepted with the seconds field ignored — the exact behaviour that was just removed. Anyone arriving from Quartz or Spring writes six fields by habit, so the rejection is worth naming outright. Also noted that `0`, `7` and `SUN` are not merely all accepted but store identically. That is the property that keeps a manifest re-apply from reading one schedule as two (design §4.3); saying only "may be written" leaves a reader to wonder whether the spelling is preserved. Verified against merged `main` rather than inferred: bare Sunday stores as `w7`; a list keeps 7 (`SAT,SUN` -> `w6,7`); ranges and steps use 0 (`SUN-THU` -> `w0-4`, `SUN/2` -> `w0/2`, since `7-4` is rejected and `7/2` would collapse to {7}). All spellings display back as `SUN`. No behaviour change — documentation only. PR remains held until v0.14.0 ships. Refs #222 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The quota section was built around `Limit.per_day(...).with_reset_schedule(...)`, which raises at construction: a limit drips or resets, never both (ADR-137), so a quota's amount and its reset arrive in one `Limit.quota(name, amount, cron=..., tz=...)` call (surface-plan Task 1). Reframed from "daily quotas" to quotas, because nothing about it is daily — the period is whatever the cron expression says. The worked example is a monthly plan resetting on the 1st, with a session cap, a weekly cap and a daily cap as one-liners, so the period reads as the author's choice rather than as midnight. ADR-138's Consequences require the guide to state the limitation it creates, and `## Limitations` said nothing: a quota period is a fixed calendar window, so `0 */5 * * *` resets at 00:00, 05:00, 10:00 for everyone alike. The window anchored to each caller's own first use — what a reader hears in "five-hour reset window" — is not supported. The YAML form gets the same treatment (surface-plan Task 6): a `reset_schedule` makes the limit a quota, so `refill_amount` defaults to 0 and is not written; a non-zero rate beside a reset is an error. The sample's `refill_period: 86400` is dropped, since a quota has no rate for it to denominate. Removed the promise that `retry_after_seconds` reports the time until a quota's reset. #530 shows the estimate is computed from a refill rate, which a quota does not have, so an exhausted quota reports zero. Stated as a limitation instead — a backoff built on a confident zero generates load rather than suppressing it. `Limit.quota()` is decided (surface-plan Group A) but not yet built; it is covered by the existing v0.14.0 version admonition. Fixes #524 Refs #222 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
Read against merged `main`, four more claims in this guide are wrong or missing. **The one-timezone rule was absent.** "Every entry carries its own IANA timezone" is not the whole story: `Limit.__post_init__` rejects entries on one limit that disagree, and `hoisted_schedule_timezone()` rejects scheduled limits written in the same call that disagree. A reader following the guide would write the rejected form. **Sunday was described as uniformly interchangeable.** Bare `0`, `7` and `SUN` do store identically, and so do list members. Inside a range or step Sunday must be `0` — `SUN-THU` is `0-4`, `SUN/2` is `0/2` — and `MON-SUN`, `SAT-SUN` and `7-4` are rejected as backwards ranges. Verified against `encode()`/`to_cron()`. **The CLI sample invented an output format.** `_format_limit` renders `rpm: 1,000/min`, not `rpm: 1000 capacity, 1000/60s refill`. Corrected, with a note on a quota's line, which reads as a zero rate with the allowance as burst. **A boundary costs two extra round trips, not one.** A `vu`-expired bucket fails the speculative condition and takes the full slow path: 1 round trip becomes 3. Also trimmed rationale that belongs in the design records rather than a how-to. Separately, every Python block in this file failed `tests/doctest/test_docs_run.py`: `ScheduleEntry` is not exported from the `zae_limiter` package root and is not in `doctest_globals`, so the imports raise and later blocks reference an undefined name. Tagged `.lint-only` per the existing convention, which is correct in any case while `Limit.quota()` does not yet exist; the tags should come off when surface-plan Group A lands. The missing root export is a real gap for v0.14.0 and cannot be fixed from a docs change. Refs #222 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
7c80532 to
3c85cc3
Compare
…fied
The previous commit listed "scheduled limits in one `limits:` block that
disagree on `tz`" among what `limits plan` rejects at parse time. Nothing
specifies that check: `hoisted_schedule_timezone()` lives in `models.py`, and
the provisioner never constructs a `Limit` — `applier.py` writes
`l_{name}_{cp,ra,rp,sched}` straight onto the config item from the manifest
dict, and surface-plan Task 6 adds no timezone rule.
The constraint itself is real on both paths, since `sched_tz` is one attribute
per stored item, so the rule stays stated. Only the claim about where it is
caught is narrowed to the Python API, which does enforce it today.
Refs #222
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
## Summary Implements surface-plan Task 1 (`docs/plans/2026-09-13-scheduled-limits-surface-plan.md`): `reset_schedule` on `Limit`, `ScheduleEntry.reset()`, and the `Limit.quota(name, amount, cron=..., tz=...)` factory. `reset_schedule` is a second, independent tuple whose entries name calendar instants at which the balance returns to the effective capacity (#222 §3.6). This is the one thing a token bucket cannot express, and it is what makes "10,000 a day, back to 10,000 at midnight" different from a 24-hour refill period. - **[ADR-137](docs/adr/137-reset-replaces-drip.md) (a limit drips *or* resets, never both and never neither)** is enforced as a cross-field rule in `Limit.__post_init__`: `refill_amount = 0` is valid only alongside a non-empty `reset_schedule`, and a positive rate alongside one is rejected. `refill_amount <= 0` is widened to `< 0`. Because `__post_init__` runs at construction, every chained spelling dies on its intermediate value — `per_day(...)` then attach is a rate with a reset; `custom(refill_amount=0, ...)` then attach is a zero rate with none. That leaves `Limit.quota()` as the only shape standing. - **[ADR-138](docs/adr/138-fixed-reset-windows-only.md) (fixed calendar windows only)** constrains no code here; it is recorded in the `Limit` and `quota()` docstrings. - **`with_reset_schedule()`** survives only as a *replacement* operator on a limit that is already a quota; clearing one with `()` is rejected rather than silently restoring a drip and overruling a number the caller passed. - **`ScheduleEntry.reset(cron, tz)`** sets a private `_reset` flag. `__post_init__` validates by opposite rules: exactly one modifier when the flag is false, none at all when it is true. Cron and timezone are validated in both cases (the reset branch sits *after* `parse_cron`, not before it). - **Both directions of misplacement are rejected**, not just the one the plan names. A parameter entry in `reset_schedule` has nowhere to store its modifier; a reset entry in `schedule` is the dangerous direction — `effective_params` is first-match-wins, so an entry matching its window and supplying no override returns the base and silently shadows every entry below it. Every entry is checked and the message counts them. These structural checks are ordered *before* the ADR-137 pairing rule, so a misplaced entry is diagnosed as misplaced. - **`per_shard()`** keeps `reset_schedule` while still clearing `schedule`, and carves the `max(1, ...)` share floor out for a zero rate — flooring would invent a drip nobody configured and make the result unconstructible, so the slip would raise from inside a rejection path. The carve-out tests the *base* rate, since `effective_params` floors a scaled refill at one milli-unit. - **`to_dict()`/`from_dict()`** carry the tuple as standard 5-field cron rather than deferring to the storage task: dropping it would break `from_dict(to_dict(x)) == x` at this commit, and would make the audit record for attaching a daily quota reset byte-identical to the record for attaching nothing. Under ADR-137 the round trip is now atomic as well as lossless. `Limit._carrier` sets both tuples explicitly, as it bypasses `__init__`. - A quota stores `_QUOTA_REFILL_PERIOD_SECONDS = 1` as an inert denominator ("0 per second") because `refill_period_seconds` is still validated positive and a zero rate has no denominator. It is a constant, not a `quota()` keyword — a knob that changes nothing is worse than a constant. `per_day` stays a drip factory and grows no reset parameter. ## Deliberately not done here - **`schema.calculate_bucket_ttl_seconds()`** computes `capacity / refill_amount` and so raises `ZeroDivisionError` for a quota. It is reached from `lease.py:365` only when the bucket is **not** on custom entity config — i.e. exactly the resource- and system-level case ADR-137 names as its own consequence and assigns to #222. Entity-level quotas are unaffected: [ADR-136](docs/adr/136-entity-config-bucket-ttl.md) gives them no TTL at all (`ttl_seconds = 0`, REMOVE). Not fixed here, because ADR-137 requires #222 to give those limits an expiry that does not divide by the rate, and inventing a TTL rule was out of scope for this task. - **`hoisted_schedule_timezone()`** still votes on `limit.schedule[0].tz` only, so a quota carrying only a reset does not vote and its stored schedule would decode as UTC. Surface Task 4 owns widening it. - **`Limit.from_bucket_state()`** still omits `reset_schedule` because bucket items do not carry one yet (surface Task 5). Its docstring now records *why*: the `max(1, ...)` floor there turns a quota's zero rate into one token, so adding `reset_schedule=state.reset_sched` on its own would raise. - **User-facing docs** (`docs/guide/`, `docs/api/`, `CLAUDE.md`) are deliberately untouched. Surface Task 12 and #524 own the docs pass, and #524's held PR #483 leads with the now-invalid `Limit.per_day(...).with_reset_schedule(...)` form, so it has to be rewritten onto `Limit.quota()` there rather than here. ## Test plan Run on this commit (15744df): - [x] `uv run pytest tests/unit/ -q` → **3930 passed** (4m46s) - [x] `uv run pytest tests/unit/ -m gevent -n 0 -q` → **26 passed** - [x] `uv run mypy` → **Success: no issues found in 58 source files** - [x] `pre-commit run --files src/zae_limiter/models.py src/zae_limiter/schedule.py tests/unit/test_models.py tests/unit/test_schedule.py` → ruff, ruff-format, mypy all pass - [x] Pre-push gate (full unit suite under coverage + `diff-cover --fail-under=100`) passed - [x] 49 `Limit`-level quota/reset cases in `tests/unit/test_models.py`; 14 `ScheduleEntry`-level cases in `tests/unit/test_schedule.py` - [x] `Limit.from_dict(q.to_dict()) == q` for a quota; restoring the zero rate without the reset (or the reset without the zero rate) raises rather than comparing unequal - [x] `per_shard(4)` on a quota preserves `reset_schedule`, clears `schedule`, gives `capacity == 2500`, and leaves `refill_amount == 0` `hatch run generate-sync` is not applicable: `models.py` and `schedule.py` are not sync-codegen sources, and the pre-commit "Verify generated sync code is up-to-date" hook reported "no files to check". Refs #222 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
|
Note for whoever picks this up: the #534 is fixed by #537 (ready, CI green, held from merge). Once #537 lands on Verified on #537's branch against this file with all seven tags stripped: The export alone was not sufficient — four of the seven blocks are continuations with no import line, taking their names from The removal was not stacked onto this branch because the repo squash-merges, which would leave this PR carrying a duplicate of #537's commit to untangle afterwards. |
## Summary `ScheduleEntry` was listed in `src/zae_limiter/schedule.py`'s own `__all__` but never re-exported from `src/zae_limiter/__init__.py`, so the import every scheduled-limits example opens with — `from zae_limiter import Limit, ScheduleEntry` — raised `ImportError`. The public surface the whole #222 feature is documented against did not exist. ## The public-surface decision A module's `__all__` governs `from .module import *`; `__init__.py`'s `__all__` is the contract frozen at v1.0.0. The test applied to each name: **would a user writing application code ever type it?** **Exported: `ScheduleEntry` only.** It is the argument to `Limit.with_schedule()` and `Limit.reset_schedule`, so every documented example needs it. **Not exported**, and why — each stays reachable as `zae_limiter.schedule.*` for tests and internal callers: | Name | Reason | |------|--------| | `parse_cron`, `ParsedCron` | Parse artifact. `ScheduleEntry.__post_init__` already parses and raises `ValueError`, so a user never needs to validate a cron string separately. | | `matches` | Takes a `ParsedCron`, so it is unusable without exporting the parse artifact too. Evaluation internal. | | `effective_params` | The evaluation engine, in milli-units. Its result is what `acquire()` enforces; callers read limits through `LimitStatus`. | | `next_boundary` | Computes a bucket's `vu` (valid-until). Purely a materialisation concern. | | `encode`, `decode` | The compact storage encoding (§4.1). Exporting it would freeze the on-item format as public API. | | `to_cron` | The near miss. It renders a stored schedule back as cron, which sounds like a legitimate display concern — but its input is a *compact entry string*, obtainable only from `encode()` or a raw DynamoDB attribute, neither of which is public. A user holding a `ScheduleEntry` reads `entry.cron`. It is a helper for tooling that reads stored items, not for application code. | Recorded in a new **Public API** section in `CLAUDE.md` (none existed before). `schedule.py` still imports nothing from `models.py` — the one-way dependency that lets it be vendored into both Lambda packages is untouched. ## Second half of the bug Exporting `ScheduleEntry` is necessary but not sufficient. Four of the seven Python blocks in `docs/guide/scheduled-limits.md` are continuations that carry no import line and take their names from `doctest_globals`, so with the `.lint-only` tags removed they still raised `NameError`. `tests/doctest/conftest.py` now supplies `ScheduleEntry` alongside `Limit`. Verified against the held guide (PR #483) with all seven `.lint-only` tags stripped: **7 passed, 0 skipped**. ## `.lint-only` removal deferred to #483 The tags live in `docs/guide/scheduled-limits.md`, which is only on PR #483's branch, not on `main`. This repo squash-merges, so stacking #483 on top of this branch would leave it carrying a duplicate of this commit to untangle after the squash. The removal is a two-line change on #483 once this merges; the verification above proves it will be green then. ## Test plan - `uv run pytest tests/unit/ -q` → **3996 passed** - `uv run pytest tests/unit/ -m gevent -n 0 -q` → **26 passed**, 3996 deselected - `uv run pytest tests/doctest/ -q` → **346 passed, 224 skipped** - `uv run pytest tests/unit/test_public_api.py -q` → 59 passed - Guide blocks with tags stripped: `pytest tests/doctest/test_docs_run.py -k scheduled-limits` → 7 passed, 0 skipped - pre-push 100% patch-coverage gate passed New `tests/unit/test_public_api.py` pins both directions: every name in `__all__` resolves from the root, and no excluded `schedule` name leaks onto it. Fixes #534 Refs #222 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The guide stated ADR-137's rule — a limit drips or resets, never both — but not that the library enforces it, so a reader had no way to know that writing the pairing fails immediately rather than silently granting a double allowance. States the rejection and its reason in one sentence, and the mirror-image rejection (a zero rate with no reset) alongside it. Fixes #524 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The guide told readers an exhausted quota reports a wait of zero and that no client backoff should be built on it. That stopped being true when the boundary-aware retry estimate landed: a quota's `retry_after_seconds` is now the time to its next reset edge, which under ADR-137 is the only finite answer a limit with no drip can give. Verified against main: a `0 0 * * *` America/New_York quota exhausted at 18:00 reports 21600.001 seconds, and a `0 */5 * * *` one reports 7200.001. Refs #530 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The `entity get-limits` sample showed a quota as `rpd: 0/sec (burst: 10,000)` under an indented `Reset:` block. `_format_limit` special-cases a quota and emits `rpd: 10,000 quota (resets "0 0 * * *" America/New_York)` on the headline instead — the zero rate and the placeholder period are internal artefacts and never reach a terminal. The parameter-schedule lines in the same block were also stale: entries render quoted, and the gloss reads `scale 50%` and `capacity 2,000`. Replaced with the real output of `_echo_limit` on the two limits the sample configures. Refs #539 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The seven Python blocks were fenced `.lint-only` because `from zae_limiter import Limit, ScheduleEntry` raised ImportError and the four continuation blocks took their names from `doctest_globals`, which did not carry `ScheduleEntry` either. Both halves are fixed on main, so the tags come off and the samples are executed rather than only linted. Verified against main with this file in place: `pytest tests/doctest/ -q -k scheduled-limits` -> 14 passed, 7 skipped (7 lint + 7 run; the 7 skips are the LocalStack-only integration variants), and the whole suite `pytest tests/doctest/ -q` -> 360 passed, 231 skipped. Refs #534 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
This reverts commit 4dbc7cf. Removing the tags needs BOTH halves of the precondition in the owner's note on #483: "Once #537 lands on `main` **and this branch is rebased**, the tags come off with no other change." #537 has landed; the rebase has not. `git merge-base --is-ancestor origin/main origin/docs/222-scheduled-limits-guide` fails, so this branch carries neither the `ScheduleEntry` export in `zae_limiter/__init__.py` nor its entry in `tests/doctest/conftest.py`. 4dbc7cf's "14 passed" was measured against a checkout of `main` with the guide copied into it, which proves the file is green *after* a rebase, not that it is green here. Measured from within this branch it is: 7 failed, 7 passed, 7 skipped Error: name 'ScheduleEntry' is not defined The tags come off when #483's branch has `main` merged into it. That is a change to a held PR and the owner's call, not this PR's. Refs #534 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
ADR-135 records the one architectural choice #222 turns on: a limit carries its own cron schedule, the schedule is denormalized onto the bucket item, and every refiller resolves base + schedule to effective parameters at read time while materializing only the token balance. The fast path therefore evaluates no schedule and is gated by the `vu` stamp alone, which is what keeps the advertised per-request cost. Proposed; ADR-137 and ADR-138 already settled what a reset means and what kind of window it may name, so this references them rather than re-deciding either. The user-facing half carries none of that reasoning. `docs/api/`, `docs/cli.md` and `docs/infra/deployment.md` gain only the surface that exists: `ScheduleEntry` in the component table and as its own mkdocstrings section, quota periods shown across session/daily/weekly/monthly rather than led by midnight, the `schedule` / `reset_schedule` manifest keys and their CloudFormation round trip, and the `Schedule:` block and quota line that `get-limits` / `get-defaults` already render. CLAUDE.md gains the reasoning: a Scheduled Limits section pointing at where each piece is already documented, the manifest keys with the `refill_amount` shorthand flip that keeps the natural manifest ADR-137-valid, and cronsim, tzdata and the test-only croniter oracle in Dependencies. `docs/guide/scheduled-limits.md` and `mkdocs.yml` are deliberately untouched — both belong to PR #483, which is held until v0.14.0 ships. Refs #222 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
ADR-135 records the one architectural choice #222 turns on: a limit carries its own cron schedule, the schedule is denormalized onto the bucket item, and every refiller resolves base + schedule to effective parameters at read time while materializing only the token balance. The fast path therefore evaluates no schedule and is gated by the `vu` stamp alone, which is what keeps the advertised per-request cost. Proposed; ADR-137 and ADR-138 already settled what a reset means and what kind of window it may name, so this references them rather than re-deciding either. The user-facing half carries none of that reasoning. `docs/api/`, `docs/cli.md` and `docs/infra/deployment.md` gain only the surface that exists: `ScheduleEntry` in the component table and as its own mkdocstrings section, quota periods shown across session/daily/weekly/monthly rather than led by midnight, the `schedule` / `reset_schedule` manifest keys and their CloudFormation round trip, and the `Schedule:` block and quota line that `get-limits` / `get-defaults` already render. CLAUDE.md gains the reasoning: a Scheduled Limits section pointing at where each piece is already documented, the manifest keys with the `refill_amount` shorthand flip that keeps the natural manifest ADR-137-valid, and cronsim, tzdata and the test-only croniter oracle in Dependencies. `docs/guide/scheduled-limits.md` and `mkdocs.yml` are deliberately untouched — both belong to PR #483, which is held until v0.14.0 ships. Refs #222 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
## Summary Task 12 of the #222 scheduled-limits surface plan: record the design, then carry the surface into user-facing docs and the reasoning into `CLAUDE.md`. > [!IMPORTANT] > **Gated on open bugs — do not merge ahead of them.** The owner's ordering is quota-semantics > fixes first, docs second. These pages describe the semantics ADR-137/138 decided, which is > what will be true when v0.14.0 ships, not current behaviour. Specifically: the repeated claim > that *a quota does not drip* is gated on **#556** (`effective_params` gives a scaled quota a > phantom 1-millitoken drip). No example in this PR shows a scaled quota, a 429 body, or a > rejection payload, so nothing here is contradicted by **#545** or **#564**. **#541** (an > unscheduled limit inheriting its item's `sched`/`rsched`) is why > `docs/contributing/architecture.md` describes the on-item attributes without asserting an > inheritance rule. - **New `docs/adr/135-scheduled-limits.md`**, status **Proposed** (96 lines, within ADR-000's 100-line cap). It records the one architectural choice and nothing else: a limit carries its own cron schedule, denormalized onto the bucket item; every refiller resolves base + schedule to effective parameters **at read time** and materializes only the token balance; the fast path evaluates no schedule and is gated by the `vu` valid-until stamp alone. It references ADR-137 and ADR-138 rather than restating what they already settled. - **User-facing docs carry the surface only** — no design reasoning, no history: - `docs/api/index.md` — `ScheduleEntry` in the component table, plus new "Quota Periods" and "Scheduled Limits" quick-reference blocks - `docs/api/models.md` — new `## ScheduleEntry` mkdocstrings section - `docs/cli.md` — `schedule` / `reset_schedule` in the YAML manifest format section, plus a new "## Schedules and Quotas" section showing the real `get-limits` / `get-defaults` rendering - `docs/infra/deployment.md` — the same two manifest keys in the limit-fields reference - **`CLAUDE.md` carries the reasoning**: a "Scheduled Limits (#222, ADR-135)" section, the manifest keys plus the `refill_amount` shorthand flip that keeps the natural manifest ADR-137-valid, and cronsim / tzdata / the test-only croniter oracle in Dependencies. ## Second commit — corrections found by a parity sweep `0dc59541` came out of a docs-parity sweep and adds 8 more files — `docs/contributing/architecture.md`, `docs/getting-started.md`, `docs/index.md`, `docs/guide/{basic-usage,config-hierarchy,hierarchical,token-bucket}.md`, `docs/operations/rate-limits.md`, `docs/performance.md` — plus further edits to `docs/cli.md`, `docs/infra/deployment.md`, `docs/api/index.md` and `CLAUDE.md`. **Operationally sharp:** - `-l` cannot express a schedule and a set is a full-replace `PutItem`, so `entity set-limits -l rpm:1000` against a scheduled level silently drops the schedule and turns a stored quota into a dripping limit. Warned in `docs/cli.md` and `CLAUDE.md`. - The manifest's `refill_amount` shorthand default is `0`, not `capacity`, when a limit carries a `reset_schedule`, and a positive value is rejected there. `docs/infra/deployment.md` stated the unconditional rule. - Deleting a bucket item to "reset" it is an unscheduled reset of a quota — a second month's allowance mid-window for a monthly one. - Schedules are enforced in the client's conditional write exactly as ADR-125's disable is, so `docs/infra/deployment.md` Rolling Upgrades gains the sibling subsection ADR-125 already had. - `b_rpm_cp` is the base, before schedule scaling and shard division; `vu` / `sched` / `rsched` / `sched_tz` added to the operator's bucket-attribute table in `docs/operations/rate-limits.md`. **Reference and contributor pages:** `docs/contributing/architecture.md`'s fast-path condition was missing both `vu` and (pre-existing, since v0.12.0) `disabled`; the failure classification had no `SCHEDULE_BOUNDARY` branch; `calculate_retry_after` was listed as the live retry path though it has had no production callers since #222 §7; the bucket and config item examples carried no schedule attributes. **User-facing:** `Limit.quota()` reaches `getting-started.md` and the landing-page feature list; `token-bucket.md` no longer claims every limit is rate-plus-burst; `basic-usage.md` and `performance.md` list the boundary fallback; `config-hierarchy.md` warns that a rebuilt `Limit` carries no schedule. ## Deliberately untouched `docs/guide/scheduled-limits.md` and `mkdocs.yml` belong to open PR #483, which is held until v0.14.0 ships. Editing either here would conflict with it. Consequence: ADR-135 has no `mkdocs.yml` nav entry — but neither do ADR-136, 137 and 138 on `main`, and `mkdocs build --strict` passes regardless. A single follow-up commit should add all four once #483 lands. ## Test plan Re-run on `0dc59541`: - [x] `uv run pytest tests/doctest/ -q` — 352 passed, 227 skipped, 16 warnings in 280.29s - [x] `uv run mkdocs build --strict` — built in 7.16s, zero WARNING/ERROR lines - [x] `uv run ruff check .` — All checks passed - [x] `uv run ruff format --check .` — 34 files would be reformatted, 319 already formatted; pre-existing local-ruff-version drift (#486), and this branch changes no Python file - [x] CI on `0dc59541`: `lint`, `build`, `Analyze (actions)`, `Analyze (python)` and `CodeQL` all pass; `deploy` skipping per path filter. Unit/e2e do not report on a docs-only PR by design. - [x] `codecov/patch` passes — "Coverage not affected when comparing 7561224...0dc5954". `codecov/project` reports a failure: 91.28% (-0.57%) compared to `7561224`. This branch changes zero Python files, so that delta cannot come from the diff — it is an artifact of the base coverage report, not of anything this branch changed. Not run / not applicable: - `tests/unit/` — nothing under `src/` was touched. Refs #222 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
|
Issue #524 has moved to v0.15.0, and this PR's chain follows it in practice. #524's fix is PR #567, which stacks on this one, and this one cannot merge until v0.14.0 has shipped — merging earlier would let |
|
Correction to the note above: it was based on a misreading of |
The seven Python blocks were tagged `.lint-only` because `ScheduleEntry` was not re-exported from `zae_limiter` and not in the doctest globals, so every example would have failed to import. #537 landed both halves, and this branch now carries them, so the fences come off and the blocks execute under `tests/doctest/test_docs_run.py`. Refs #222, #537
…code (#567) ## Summary Stacked on #483 (`docs/222-scheduled-limits-guide`), which is deliberately held until v0.14.0 ships. One file (`docs/guide/scheduled-limits.md`), net +15/−17. No source changes. **Closes #524.** Three of its four acceptance criteria were already met on #483's branch (no `per_day` in the file; the ADR-138 fixed-calendar-window exclusion is stated; the samples are correct). The fourth was half-written: the guide stated ADR-137's drip-or-reset rule and cited the ADR, but never said the library *enforces* it. One sentence adds the rejection and its reason. Two further defects found reading the section against current `main`: | Commit | Defect | |---|---| | `2ef38a1d` | The guide claimed a quota reports no `retry_after_seconds` and told readers not to build a backoff on it, citing #530. **#530 is closed** — a quota now reports the wait to its next reset edge, the only finite answer under ADR-137. | | `0d1a6bb1` | The `entity get-limits` sample rendered a quota as `rpd: 0/sec (burst: 10,000)` under an indented `Reset:` block. `_format_limit` special-cases a quota and emits `rpd: 10,000 quota (resets "0 0 * * *" America/New_York)`; the parameter-schedule lines were stale too. | ## Why the `.lint-only` fences stay `4dbc7cfb` removed them and has been reverted by `68b5b3a1`. Removing them needs **both** halves of the precondition in the owner's note on #483 — *"Once #537 lands on `main` **and this branch is rebased**, the tags come off with no other change."* #537 has landed; the rebase has not. `git merge-base --is-ancestor origin/main origin/docs/222-scheduled-limits-guide` fails, so this branch carries neither the `ScheduleEntry` export in `zae_limiter/__init__.py` nor its entry in `tests/doctest/conftest.py`, and all seven blocks fail here. The tags come off when #483's branch has `main` merged into it. That is a change to a held PR and the owner's call, not this PR's. ## Verification Every number below is measured **from a checkout of this branch**, not from `main`: ``` $ uv run pytest tests/doctest/ -q -k scheduled-limits 7 passed, 14 skipped, 17 warnings in 37.02s $ uv run mkdocs build --strict exit=0 — zero WARNING/ERROR lines, none mentioning this page $ uv run ruff check . → All checks passed! $ uv run ruff format --check . → 191 files already formatted ``` The 7 passes are the lint checks; the 14 skips are the 7 `.lint-only` run-blocks plus the 7 LocalStack-only integration variants. Behaviour claims in the prose were checked against `main` (`b736e428`), since that is the code this guide ships with: ``` daily quota exhausted at 18:00 NY -> retry_after_seconds = 21600.001 (6.00 h) 5-hourly quota exhausted at 18:00 NY -> retry_after_seconds = 7200.001 (2.00 h) ``` CLI rendering, via `_echo_limit` on `main`: ``` Limits for user-123 (gpt-4): rpm: 1,000/min Schedule: "* 9-17 * * MON-FRI" America/New_York → scale 50% "* 0-6 * * *" America/New_York → capacity 2,000 rpd: 10,000 quota (resets "0 0 * * *" America/New_York) ``` ## Known gap, not addressed here **#545** is still open: `RateLimitExceeded.as_dict()` emits a quota as `refill_amount: 0, refill_period_seconds: 1`, with no `kind` and no `resets_at_ms`. `retry_after_seconds` itself is correct (per #545's own note, since #533), which is all this guide claims — the guide deliberately shows no 429 body. Two more open issues touch the quota semantics documented here but do not contradict anything written: **#556** (a *scaled* quota gets a phantom 1-millitoken drip; the guide shows no scaled quota) and **#541** (an unscheduled limit sharing a bucket item inherits the item's `sched`/`rsched`, which bears on the "limits without a schedule can sit alongside any zone" note). ## Note on closing #524 `Fixes #524` will not auto-close the issue, because this PR targets a non-default branch. #524 closes when #483 reaches `main` carrying this commit, or needs closing by hand. ## Test plan - [x] `uv run pytest tests/doctest/ -q -k scheduled-limits` from this branch — 7 passed, 14 skipped, 0 failed - [x] `uv run mkdocs build --strict` from this branch — exit 0 - [x] `uv run ruff check .` / `ruff format --check .` — clean - [x] Every behaviour claim executed against `main` and the real output pasted above Fixes #524 Refs #530, #534, #539, #483 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
The seven ```python fences in the scheduled-limits guide went live once #537's two halves — the `ScheduleEntry` re-export and its `doctest_globals` entry — reached the branch via the `main` merge, discharging the condition `68b5b3a1` named ("keep the .lint-only fences until #483 is rebased"). Live fences are code, and #486 pinned ruff 0.16.6, whose `ruff-format` hook declares `types_or: [python, pyi, jupyter, markdown]`. So the blocks are now formatted like every other Python file in the repo: `with_schedule((...))` calls split across lines, `Limit.quota(...)` trailing comments re-spaced to a single space, and the `set_resource_defaults` / `set_limits` calls re-wrapped one argument per line. Pure re-wrapping, no semantic change — the guide's doctests report the same 14 passed / 7 skipped before and after. Refs #222, #486 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5
Caution
🚨 DO NOT MERGE UNTIL v0.14.0 SHIPS 🚨
This documents a feature that does not exist yet. Merging it early publishes a
user guide (and an API reference for
ScheduleEntry/Limit.quota) describingan API that a v0.13.0 user cannot call.
Pushing to
mainruns:So
latest— the version a visitor lands on by default — would immediately startserving docs for an unimplemented surface. There is no staging step to catch this.
Merge this either:
v0.14.0tag.Depends on: #222 implementation landing first.
Summary
A single new user guide,
docs/guide/scheduled-limits.md, plus itsmkdocs.ymlnav entry.No source changes.
The guide leads with the mental model most likely to be got wrong: cron here is a match
pattern describing a window, not a timer describing instants.
0 9 * * MON-FRIis not"fires at 9am" — it is a one-minute-wide window, matching only 09:00–09:00:59. Readers
arriving from crontab intuition will write exactly that expression and wonder why the limit
snaps back a minute later, so the guide addresses it before anything else.
Sections, in order:
0 9 * * MON-FRItrap worked through explicitly.
scheduled entry, and when each reads better.
combine.
Limit.quota(name, amount, cron=..., tz=...): an allowance that is handed backwhole at a calendar instant instead of dripping. The period is whatever the cron expression
says — a session cap every few hours, a monthly plan on the 1st, a weekly cap — not a
midnight-only feature.
not merge field-by-field into it.
Addresses #524
The guide was written before ADR-137 and ADR-138 were accepted, and its headline quota example
(
Limit.per_day(...).with_reset_schedule(...)) is the one configuration ADR-137 rejects atconstruction. Fixed here:
Limit.quota()(surface-plan Task 1) — a limit drips or resets, never both.## Limitationsnow states ADR-138's exclusion: a quota period is a fixed calendar window sharedby every entity on it; a window anchored to each caller's own first use is not supported.
reset_schedulemakes the limit a quota, sorefill_amountdefaults to 0 and is not written.retry_after_secondsreports the time until a quota's reset is removed — 🐛 Every "retry after" path reports 0 seconds for a quota once ADR-137 lands #530 showsit reports zero — and stated as a limitation instead.
A second commit fixes four further defects found reading the whole guide against merged
main:the one-timezone-per-limit/per-config-item rule (absent entirely), Sunday's two spellings (
0insideranges and steps,
MON-SUN/SAT-SUN/7-4rejected as backwards), the CLI sample's invented outputformat (
_format_limitrendersrpm: 1,000/min), and a boundary costing two extra round trips ratherthan one.
Limit.quota()does not exist yet (surface-plan Group A). Every Python block in the guide is tagged.lint-only— they also failedtests/doctest/test_docs_run.pyon the old text, becauseScheduleEntryis not exported from thezae_limiterpackage root. That export is a releaseblocker for v0.14.0 and is not fixable from a docs PR. The
.lint-onlytags should come off whenGroup A lands and the export exists.
Test plan
main(the entire ✨ Support time-based dynamic rate limits with scheduling #222 core plan, 14 tasks, plus ADR-137/138) — no conflicts.uv run --extra docs mkdocs build --strictexits 0; no warnings attributable to this page, and the renamed## Quotasheading resolves the in-page#quotaslink.uv run pytest tests/doctest/ -k scheduled— 7 passed (lint), 14 skipped. Previously 6 of 6 Python blocks failed..lint-onlytags and re-runtests/doctest/once surface-plan Group A lands andScheduleEntryis exported from the package root.Refs #222
🤖 Generated with Claude Code
https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5