✨ Soft (metered, non-gating) limits
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 0
- Forks
- 0
- Avg merge
- 6h 51m
- Merged PRs (30d)
- 104
Description
Problem or Use Case
While landing #455 (consume is the declared scope of a lease), the question came up whether a caller needs a way to declare a limit as adjustable from the lease without it gating admission — e.g. consume={"tpm": None} meaning "I will reconcile tpm post-hoc, but do not reject this call if the tpm bucket is in debt".
Decision for #455: no. After #455 a configured limit is either:
- declared in
consume→ gates on non-debt via the fast-pathtk >= consumedcondition, and is adjustable from the lease; or - omitted → neither.
A per-call third state was rejected because it just shifts the rejection onto the next caller that declares the limit properly — the debt is still there, and whoever names the limit next eats the RateLimitExceeded.
What this issue proposes instead: model "meter but never block" as a property of the limit, not of the call. A soft limit records consumption and debt, emits a signal when overdrawn, but never causes RateLimitExceeded.
Use cases:
- Billing-style token accounting (
tpm) alongside a hardrpmgate — count every token, never refuse a request because of the count. - Shadow-mode rollout of a new limit: configure it soft, watch how often it would have tripped, then flip it hard.
- Per-tenant overage alerting: let the tenant run over, but surface the overage to ops.
Proposed Solution
A tri-state-free boolean soft on a limit's stored config (default false = hard, today's behaviour). Configurable at every level of the ADR-100 hierarchy (system / resource / entity) and via the declarative manifest.
# Sketch — exact API is a design question below
await repo.set_resource_defaults(
"gpt-4",
limits=[
Limit.per_minute("rpm", 500), # hard: gates admission
Limit.per_minute("tpm", 50_000, soft=True), # soft: metered, never rejects
],
)
async with limiter.acquire("user-1", "gpt-4", consume={"rpm": 1, "tpm": 1200}) as lease:
... # never raises on tpm, even if tpm bucket is in debt
await lease.adjust(tpm=actual_tokens) # still persisted, bucket may go further negative
# Declarative manifest (Issue #405)
resources:
gpt-4:
limits:
rpm:
capacity: 500
tpm:
capacity: 50000
soft: true
Design questions to settle
- Where the flag lives. Composite config items already carry
l_{name}_cp/l_{name}_ra/l_{name}_rp(ADR-114). Isl_{name}_softthe right shape, and does the bucket item need a denormalized copy (ascascade/parent_id/disabledare) so the speculative path can honour it without a config read? - Fast-path condition. Can
speculative_consume()simply omit soft limits from thetk >= consumedcondition while stillADDing their consumption? If so the feature is 0 extra RCU / 0 extra WCU on the fast path. Thewcuinfrastructure limit must stay hard. - Slow path.
try_consume()/_commit_initial()must skip soft limits when deciding admission but still include them in the write set. Confirmbuild_composite_retry'stk >= consumedcondition is only applied to hard limits. - Reporting.
RateLimitExceeded.passed/LimitStatus— how does a soft limit in debt appear? Options:exceeded=Falseplus a newsoft=Truefield onLimitStatus; or a separateoverdrawnlist on the exception / lease. Theretry_after_secondsbottleneck computation must ignore soft limits. - Overdraw signal. Which is the primary observable: an
AuditEvent(newAuditAction), a CloudWatch metric emitted by the aggregator from the stream, a flag on the usage snapshot, or several? The aggregator already sees every bucketMODIFY; atk < 0transition on a soft limit is cheap to detect there. - Cascade parents. Is
softresolved per (entity, resource) likedisabled(ADR-125), so a child'stpmcan be soft while the parent'stpmis hard? Or must the flag agree along the cascade chain? Decide and document, mirroring the ADR-125 note that carve-outs do not extend to parents. - Config resolution. Precedence follows the existing walk (entity(resource) > entity(
_default_) > resource > system). Does the flag resolve independently per level likedisabled, or travel with the limit definition it is attached to?
Cost expectations
| Path | Today (hard) | Soft limit target |
|---|---|---|
| Speculative success | 0 RCU + 1 WCU | 0 RCU + 1 WCU (condition shrinks, ADD set unchanged) |
| Speculative fast rejection on a soft limit | 0 RCU + 0 WCU | n/a — must not reject |
| Slow path | 1 RCU + 1 WCU | unchanged |
| Overdraw signal | — | aggregator-side, 0 client cost |
Out of Scope
- Per-call
Noneinconsume(rejected in #455 — see above). - System-level
disabled-style semantics; this is about gating, not availability.
Alternatives Considered
- Per-call
consume={"tpm": None}— rejected in #455: the debt still lands on the next properly-declared call, so it is not "non-gating", it is "gate someone else". - Configure
tpmwith a huge capacity — works today but loses the overdraw signal entirely and makesRateLimitExceeded.passedmeaningless for that limit. - Track
tpmoutside the limiter — duplicates the bucket write for billing and forfeits usage snapshots / audit integration.
Acceptance Criteria
- A limit can be marked soft at system, resource, and entity level through the existing
set_system_defaults/set_resource_defaults/set_limitsAPIs and their CLI counterparts (-lflag or equivalent) - A limit can be marked soft in the declarative YAML manifest and round-trips through the generated
Custom::ZaeLimiterLimitsCloudFormation resource -
acquire()never raisesRateLimitExceededbecause of a soft limit, on both the speculative and slow paths, and on cascade parents — covered by unit tests intests/unit/test_limiter.py(sync counterpart generated) - Initial consumption and
lease.adjust()/consume()/release()against a soft limit are persisted to the bucket item and appear in usage snapshots — covered by an integration test - Speculative-path success on a soft limit costs 0 RCU + 1 WCU (no additional round trip) — verified with
capacity_counterintests/benchmark/test_capacity.py - An overdrawn soft limit (bucket
tk < 0) is observable via at least one of:AuditEvent, CloudWatch metric, or usage-snapshot field — with a test asserting the signal is emitted -
RateLimitExceeded/LimitStatusexpose a soft limit's in-debt state without listing it inviolations, andretry_after_secondsignores soft limits — unit test -
docs/guide/basic-usage.mdcontains a "Hard vs soft limits" section;docs/api/anddocs/cli.mddocument the flag; CLAUDE.md config section updated - ADR written for the flag's storage location and fast-path semantics, referencing ADR-100, ADR-114, and ADR-125
Related
- #455 —
consumeis the declared scope of a lease (motivating discussion) - #453 — speculative fast path drops
LeaseEntryfor zero estimates - ADR-100 (centralized config), ADR-114 (composite config items), ADR-125 (resource disable — precedent for a per-level flag beside
limits)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with ADR-100, ADR-114, and ADR-125, then read the named entry points: speculative_consume(), try_consume(), _commit_initial(), and build_composite_retry. Run tests/unit/test_limiter.py and tests/benchmark/test_capacity.py, and inspect the integration tests and manifest/config paths. Done means the design questions are resolved and the acceptance criteria cover persistence, admission, reporting, observability, documentation, and capacity cost.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, python
- Domain
- backend, cli, cloud, database, documentation
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100