Files
2026-05-27 23:29:53 +01:00

4.1 KiB

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"