89 lines
4.1 KiB
Markdown
89 lines
4.1 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-rate-limit (api-rate-limit)
|
|
|
|
**Package**: `@suaveplan/api-rate-limit`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Token-bucket gate with route-declared rpm and burst
|
|
|
|
The package SHALL produce a `Middleware` enforcing a token bucket per key, parametrised by the route's `rateLimit.rpm` and `rateLimit.burst`. The bucket SHALL refill at `rpm/60` tokens per second up to `burst` tokens. Each request consumes one token; an empty bucket returns 429 with a `Retry-After` header equal to the refill ETA in seconds.
|
|
|
|
#### Scenario: burst window allows N requests
|
|
|
|
- WHEN a route declares `rpm: 60, burst: 5` and 5 requests arrive within 100ms for the same key
|
|
- THEN all 5 requests pass
|
|
|
|
#### Scenario: 6th request in burst window is rejected
|
|
|
|
- WHEN a 6th request arrives in the same 100ms window
|
|
- THEN the response is 429 with `Retry-After` set to a positive integer AND `api.rate_limit.exceeded_total{key_kind}` increments by 1
|
|
|
|
**Verified by:** `src/middleware/middleware.test.ts::"burst window allows N requests"` AND `src/middleware/middleware.test.ts::"sixth request in burst window is rejected with Retry-After"`
|
|
|
|
---
|
|
|
|
### Requirement: Refill is deterministic against a FakeClock
|
|
|
|
The package SHALL refill the bucket based on the supplied `Clock`. Advancing the clock by 1 second on an empty bucket with `rpm: 60` MUST restore exactly 1 token.
|
|
|
|
#### Scenario: clock advance refills tokens
|
|
|
|
- WHEN the bucket is empty and `clock.advance("1s")` runs
|
|
- THEN the next request consuming 1 token succeeds
|
|
|
|
**Verified by:** `src/middleware/middleware.test.ts::"clock advance refills tokens deterministically"`
|
|
|
|
---
|
|
|
|
### Requirement: Key selector resolves per route
|
|
|
|
The package SHALL resolve the rate-limit key from the route's `rateLimit.key` option. Selector kinds: `"tenant"`, `"principal"`, `"ip"`, and `{ kind: "custom", select }`. When the route does not specify a kind, the default is `"principal"` if a principal is resolved, else `"ip"`.
|
|
|
|
#### Scenario: tenant key isolates buckets
|
|
|
|
- WHEN two requests with different `tenantId` hit a route declaring `key: "tenant"`
|
|
- THEN each tenant has its own bucket AND neither affects the other
|
|
|
|
#### Scenario: default falls back to IP when no principal
|
|
|
|
- WHEN no principal is resolved AND the route does not specify a key
|
|
- THEN the IP address is used
|
|
|
|
**Verified by:** `src/selector/selector.test.ts::"tenant key isolates buckets"` AND `src/selector/selector.test.ts::"default falls back to IP without principal"`
|
|
|
|
---
|
|
|
|
### Requirement: Denial counter increments only on the denial transition
|
|
|
|
The package SHALL increment `api.rate_limit.exceeded_total` once per denied request — every denied request contributes one increment. The counter MUST be labelled with `plugin`, `route`, and `key_kind`.
|
|
|
|
#### Scenario: each denied request increments by exactly 1
|
|
|
|
- WHEN 10 requests are denied for the same key within the same window
|
|
- THEN the counter increases by exactly 10 AND each increment carries the correct label set
|
|
|
|
**Verified by:** `src/middleware/middleware.spec-conformance.test.ts::"denial increments api.rate_limit.exceeded_total exactly once per denied request"`
|
|
|
|
---
|
|
|
|
### Requirement: Host-level defaults override route declarations cleanly
|
|
|
|
The package SHALL accept default budgets from `host.json` (per-tenant default rpm/burst, per-IP default rpm/burst). Route-declared budgets MUST override the defaults. When a route declares no `rateLimit` and no host default applies, the middleware is a no-op.
|
|
|
|
#### Scenario: route override beats host default
|
|
|
|
- WHEN a host default is `rpm: 100` and a route declares `rpm: 10`
|
|
- THEN the route's limit (10) applies to that route
|
|
|
|
#### Scenario: no declaration, no host default → no-op
|
|
|
|
- WHEN neither route nor host declares a budget
|
|
- THEN the middleware passes the request through without checking any bucket
|
|
|
|
**Verified by:** `src/middleware/middleware.test.ts::"route override beats host default"` AND `src/middleware/middleware.test.ts::"no-op when neither route nor host declares"`
|