Skip to content

📝 docs(guide): add the scheduled (cron) limits user guide - #483

Open
sodre wants to merge 15 commits into
mainfrom
docs/222-scheduled-limits-guide
Open

sodre wants to merge 15 commits into
mainfrom
docs/222-scheduled-limits-guide

Conversation

@sodre

@sodre sodre commented Sep 14, 2026

Copy link
Copy Markdown
Member

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) describing
an API that a v0.13.0 user cannot call.

Pushing to main runs:

mike deploy --push --update-aliases dev latest
mike set-default --push latest

So latest — the version a visitor lands on by default — would immediately start
serving docs for an unimplemented surface. There is no staging step to catch this.

Merge this either:

  1. as part of the scheduled-limits surface work (that plan's Task 12 covers docs), or
  2. immediately before the v0.14.0 tag.

Depends on: #222 implementation landing first.


Summary

A single new user guide, docs/guide/scheduled-limits.md, plus its mkdocs.yml nav 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-FRI is 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:

  • When to use it — the cases scheduling is for, and the cases it is not.
  • The mental model: a pattern, not a timer — window-vs-instant, with the 0 9 * * MON-FRI
    trap worked through explicitly.
  • Scaling a limit during a window vs. Absolute values — the two ways to express a
    scheduled entry, and when each reads better.
  • Several windows: first match wins — ordering is significant; overlapping entries do not
    combine.
  • Timezones — including DST, where a local-time window can be skipped or repeated.
  • QuotasLimit.quota(name, amount, cron=..., tz=...): an allowance that is handed back
    whole 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.
  • Precedence: override, not merge — a matching schedule entry replaces the limit; it does
    not merge field-by-field into it.
  • Declarative configuration — YAML manifest and the CloudFormation round trip.
  • Viewing a schedule — CLI display.
  • What happens at a boundary — the cost of a window transition.
  • Limitations — the known sharp edges, stated up front rather than discovered.

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 at
construction. Fixed here:

  • Quotas are built with Limit.quota() (surface-plan Task 1) — a limit drips or resets, never both.
  • ## Limitations now states ADR-138's exclusion: a quota period is a fixed calendar window shared
    by every entity on it; a window anchored to each caller's own first use is not supported.
  • The YAML form is documented (surface-plan Task 6): a reset_schedule makes the limit a quota, so
    refill_amount defaults to 0 and is not written.
  • The claim that retry_after_seconds reports the time until a quota's reset is removed — 🐛 Every "retry after" path reports 0 seconds for a quota once ADR-137 lands #530 shows
    it 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 (0 inside
ranges and steps, MON-SUN/SAT-SUN/7-4 rejected as backwards), the CLI sample's invented output
format (_format_limit renders rpm: 1,000/min), and a boundary costing two extra round trips rather
than one.

Limit.quota() does not exist yet (surface-plan Group A). Every Python block in the guide is tagged
.lint-only — they also failed tests/doctest/test_docs_run.py on the old text, because
ScheduleEntry is not exported from the zae_limiter package root. That export is a release
blocker for v0.14.0 and is not fixable from a docs PR.
The .lint-only tags should come off when
Group A lands and the export exists.

Test plan

  • Branch rebased onto current 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 --strict exits 0; no warnings attributable to this page, and the renamed ## Quotas heading resolves the in-page #quotas link.
  • uv run pytest tests/doctest/ -k scheduled — 7 passed (lint), 14 skipped. Previously 6 of 6 Python blocks failed.
  • Remove the .lint-only tags and re-run tests/doctest/ once surface-plan Group A lands and ScheduleEntry is exported from the package root.

Refs #222

🤖 Generated with Claude Code

https://claude.ai/code/session_01QdVj8nPhUwTz2aNJzMFqt5

@sodre sodre added this to the v0.14.0 milestone Sep 14, 2026
@sodre sodre added api-design API surface changes area/limiter Core rate limiting logic labels Sep 14, 2026
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.08%. Comparing base (a169747) to head (84f78d9).
✅ All tests successful. No failed tests found.

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     
Flag Coverage Δ
doctest 32.17% <ø> (+0.80%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

sodre and others added 2 commits September 15, 2026 04:05
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
sodre and others added 2 commits September 15, 2026 04:31
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
@sodre
sodre force-pushed the docs/222-scheduled-limits-guide branch from 7c80532 to 3c85cc3 Compare September 15, 2026 08:39
…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
sodre added a commit that referenced this pull request Sep 15, 2026
## 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
@sodre

sodre commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Note for whoever picks this up: the .lint-only tags on the seven Python blocks in docs/guide/scheduled-limits.md were a workaround for #534ScheduleEntry was never re-exported from zae_limiter/__init__.py, so from zae_limiter import Limit, ScheduleEntry raised ImportError.

#534 is fixed by #537 (ready, CI green, held from merge). Once #537 lands on main and this branch is rebased, the tags come off with no other change:

sed -i '' 's/```{\.python \.lint-only}/```python/' docs/guide/scheduled-limits.md

Verified on #537's branch against this file with all seven tags stripped: pytest tests/doctest/test_docs_run.py -k scheduled-limits7 passed, 0 skipped.

The export alone was not sufficient — four of the seven blocks are continuations with no import line, taking their names from doctest_globals — so #537 also adds ScheduleEntry to tests/doctest/conftest.py. Removing the tags before #537 merges will be red.

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.

sodre added a commit that referenced this pull request Sep 15, 2026
## 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
sodre and others added 4 commits September 15, 2026 12:09
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
sodre added a commit that referenced this pull request Sep 15, 2026
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
sodre added a commit that referenced this pull request Sep 15, 2026
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
sodre added a commit that referenced this pull request Sep 15, 2026
## 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
@sodre sodre modified the milestones: v0.14.0, v0.15.0 Sep 15, 2026
@sodre

sodre commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

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 mike deploy ... latest publish the unreleased guide as the default docs version. So the chain can only land after the release; leaving it in v0.14.0 would block that release on itself.

@sodre sodre modified the milestones: v0.15.0, v0.14.0 Sep 15, 2026
@sodre

sodre commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Correction to the note above: it was based on a misreading of .github/workflows/docs.yml, and there is no circular dependency. Every push to main already runs mike deploy --push --update-aliases dev latest + mike set-default --push latest, so merging this PR before the tag publishes nothing that main is not publishing already; the tag job then overwrites latest with the real version. This guide ships with v0.14.0, so the PR stays on that milestone (as does #567, which stacks on it). Only #596 moves to v0.15.0.

sodre and others added 4 commits September 15, 2026 20:04
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
@sodre
sodre marked this pull request as ready for review September 16, 2026 06:08
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-design API surface changes area/limiter Core rate limiting logic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant