M1-19: Rate limits per class¶
Status: ready | Depends on: M1-18 | Milestone: M1
Goal¶
After this ticket token buckets per rate-limit class and key run in the chain stage StageRateLimit, before authorize. Every response of a limited REST operation carries RateLimit-Policy and RateLimit; an exhausted bucket answers 429 rate_limited with Retry-After; memory stays bounded however many distinct keys arrive. The limiter is a reusable component: the gateway tickets (M1-64 for rl.gateway_connect, M1-66 for rl.typing) call the same API.
Read first¶
docs/PLAN.md: section "M1 Foundation" (rows M1-17 to M1-20, M1-37, M1-38, M1-64, M1-66, M1-68; exit criteria), "Decisions of this plan" (P-07 to P-12).docs/spec/05-actions.md: "Rate-limit classes", "Decision" (rate limits run in their own layer beforeauthorize), "Configuration values of this chapter".docs/spec/06-api.md: "Rate limits", "Basic rules" (row Client IP), "Errors" (rate_limited), "Decisions of this chapter" (R-API-11), "Sources" (Q5).docs/spec/02-architecture.md: "Request path", "Scaling path" (limits per node), "Decisions of this chapter" (R-AR-12), "Configuration values of this chapter".docs/spec/08-identity.md: "Login protection", test ID-T25.docs/spec/16-operations.md: "Edge and identity".docs/spec/19-security.md: SEC-AI-7, "Tests" (SEC-T03).docs/security/threat-model.md: row T-DS-01.docs/observability/signals.md:bothy.ratelimit.rejectedand attributebothy.ratelimit.class.docs/spec/actions.yaml(classes per action, M1-02).- Code:
internal/platform/httpserverandhttpservertest(M1-18),api/routes(M1-17),internal/kernel/{actor,reqmeta,clock,errs},internal/platform/{config,otel,lifecycle},internal/wire. docs/quality/gates.md,CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: write the failing test, show it red, then make it green. No en or em dashes in any text. White label everywhere. Configuration values only through named keys from
docs/operations/configuration.md. - 05 "Rate-limit classes" and 06 "Rate limits" point 1: one token bucket per class and key with
ratelimit.<class>.per_minuteandratelimit.<class>.burst(for exampleratelimit.rl.read.per_minute); the check runs beforeauthorize. - 06 "Rate limits" point 2 (draft-ietf-httpapi-ratelimit-headers-11, provisional):
RateLimit-Policy: "<class>";q=<per_minute>;w=60andRateLimit: "<class>";r=<whole tokens>;t=<seconds until full>. - 06 "Rate limits" point 3: an exhausted bucket gives
429 rate_limitedwithRetry-After(RFC 9110). - Keys per class as in the 05 table column "Key": client IP, principal, device or token, connection, agent, incoming webhook.
- The client IP comes only from forwarding headers of peers in
server.trusted_proxies(08 "Login protection", ID-T25), resolved by M1-18 intokernel/reqmeta. - R-AR-12: limits live in the process; with several api nodes they apply per node.
Scope¶
Paths are relative to services/server/ unless they start with docs/.
internal/platform/ratelimit(new): limiter, buckets, shards, sweeper, key derivation, header formatting, HTTP middleware constructor;doc.goandREADME.md.internal/wire: build the limiter from configuration, register it withChain.Use(httpserver.StageRateLimit, ...)for rolesapiandall, start the sweeper under the lifecycle of M1-11 and stop it on shutdown, run the start check below.- Configuration schema (M1-07): the keys
ratelimit.rl.<name>.per_minuteand.burstfor every class of the 05 table "Rate-limit classes" (two per row;rl.terminalandrl.hostincluded, although no operation uses them before M2, P-24) of 05 "Configuration values of this chapter" (if not yet present) andratelimit.max_tracked_keysandratelimit.sweep_interval(02 "Configuration values of this chapter"). - Failed credentials (02 "Chain in the api node", 05 "Rate-limit classes"): fill the hook M1-18 gives
StageAuthenticate. A request whose presented credentials fail with401other thantoken_expiredanduse_dpop_noncetakes one token from therl.public_authbucket of its client IP; while that bucket is empty, the stage answers429for requests with credentials from that client IP without verifying them. - Docs: in
docs/spec/06-api.md"Rate limits" the rounding ofr,tandRetry-After, the anonymous fallback to the client IP, and the per-node note;docs/DECISIONS.mdentries;docs/operations/configuration.mdregenerated;services/server/CHANGELOG.md.
Out of scope¶
- Using the limiter in the gateway (
rl.gateway_connectat connection setup in M1-64,rl.typingin M1-66). This ticket provides the API and the key rules. - The per-code and per-account login protections of 08 "Login protection" other than
rl.public_auth(auth.login_fail_limitand similar): identity tickets M1-37 to M1-39. - Upload session limits of 12 (M3), maintenance mode (M7-08), idempotency (M1-20).
- A shared limiter across nodes (02 "Scaling path" keeps it per node).
- Metrics other than
bothy.ratelimit.rejected(M1-68).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-19-rate-limits. - Conventional Commits with the component as scope (for example
feat(ratelimit): ...) and DCO sign-off (git commit -s). Squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside Scope, except the docs and changelog entries named here.
- Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp; remove them when done. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Tests bind only
127.0.0.1and port 0 (P-10). - Do not lower a threshold, disable a gate or delete a test.
- A gap in the core contract (chapters 00, 03, 04, 05) that this ticket needs ends the ticket with
Verdict: BLOCKED(P-11).
Design notes¶
Bucket arithmetic¶
For a class with per_minute P and burst B: rate R = P / 60 tokens per second, capacity B, a new bucket starts full. On each request at time now (from kernel/clock): tokens = min(B, tokens + elapsed seconds x R); if tokens >= 1, subtract 1 and allow, else deny. Header values after the decision: r = floor(tokens); t = ceil((B - tokens) / R) seconds, 0 when full; q = P; w = 60. On deny: Retry-After = ceil((1 - tokens) / R) seconds, at least 1. Store tokens as int64 fixed point (for example nanotokens) or float64; tests check exact header values with a fake clock.
API (internal/platform/ratelimit)¶
type Class string // for example "rl.read"
type KeyKind int // KeyClientIP, KeyPrincipal, KeyAgent, KeyDeviceOrToken, KeyConnection, KeyIncomingWebhook
type Limits struct{ PerMinute, Burst int }
type Config struct {
Classes map[Class]Limits // from ratelimit.<class>.per_minute and .burst
MaxTrackedKeys int // ratelimit.max_tracked_keys
SweepInterval time.Duration // ratelimit.sweep_interval
}
type Decision struct {
Allowed bool
Remaining int // r
ResetSeconds int // t
RetryAfterSeconds int // only when denied
Limits Limits
}
func New(cfg Config, clk clock.Clock, m metric.Meter) (*Limiter, error) // PerMinute >= 1 and Burst >= 1 for every class, MaxTrackedKeys >= 1
func (l *Limiter) Allow(class Class, key string) (Decision, error) // error for an unknown class
func (l *Limiter) Len() int // tracked buckets
func (l *Limiter) Sweep() // removes full buckets
func (l *Limiter) Run(ctx context.Context) // sweeps every SweepInterval until ctx ends
func KindOf(class Class) (KeyKind, bool)
func HeaderValues(class Class, d Decision) (policy, state string)
func Middleware(l *Limiter) func(http.Handler) http.Handler // for httpserver.StageRateLimit
Use the metric API of M1-10 for bothy.ratelimit.rejected (counter, attribute bothy.ratelimit.class, values from the closed class list).
Key per class¶
| Class | Kind | Key value |
|---|---|---|
rl.public_auth |
client IP | reqmeta.ClientIP; IPv6 addresses by their /64 prefix, IPv4-mapped IPv6 unmapped first (05) |
rl.gateway_connect |
device or token | device ID of the verified actor, else its API token ID; the gateway calls the limiter at identify and resume after the credentials are verified (04 "Lifecycle", 05), never at the upgrade |
rl.read, rl.write, rl.message, rl.trigger, rl.interaction, rl.upload, rl.search, rl.admin, rl.export, rl.import |
principal | actor ID |
rl.agent_runtime |
agent | actor ID (the agent) |
rl.typing |
connection | gateway connection ID passed by the caller |
rl.webhook_in |
incoming webhook | path value webhook_id (r.PathValue) |
Principal-keyed classes with the anonymous actor use the client IP (IPv6 by /64), so unauthenticated calls never share one bucket and never borrow a principal's bucket. Internal map key: <class> + | + <kind prefix>:<value>, so classes never share buckets. If docs/spec/actions.yaml (M1-02) carries the key kind per class, take the kind table from it (generated); otherwise keep a hand-written table and add TestKeyKinds_MatchChapter05, which parses the 05 table "Rate-limit classes" and compares class and key column.
Start check in internal/wire: every class used by an enabled operation (routes.Enabled()) and every class of the catalogue has limits in the configuration; an enabled REST operation with class rl.typing or rl.gateway_connect fails the start, because those classes belong to the gateway.
Middleware¶
Reads the routes.Operation that M1-18 puts into the context in StageRoute, derives the key, calls Allow, and sets RateLimit-Policy and RateLimit before calling the next handler, so every response of the route carries them (success and error). On deny it writes 429 rate_limited with Retry-After through the M1-18 problem writer, increments bothy.ratelimit.rejected and does not call the next handler. Because the stage runs before authorize, the answer to a limited request is the same for every resource ID (no existence leak). Rate-limit rejections are not audited (05 audits authorize decisions).
Bounded memory¶
Buckets live in a fixed number of shards (map plus mutex each; shard chosen by FNV-1a of the internal key). A full bucket decides exactly like a missing bucket, so removing it changes no decision: Sweep removes every bucket that is full at sweep time, and Run calls it every ratelimit.sweep_interval. Hard cap: at most ratelimit.max_tracked_keys buckets in total, spread evenly over the shards (at least one per shard). Inserting into a full shard first removes full buckets found in a small sample of that shard (Go map iteration gives a random sample; the sample size is a code constant with a comment), and only if none is full evicts the sampled bucket with the oldest last use. An evicted key gets a fresh bucket on its next request; this happens only when more distinct keys are active within one refill window than the cap allows (a distributed flood). The limiter never blocks or denies because the table is full. Record this policy in docs/DECISIONS.md.
Configuration values in 02 "Configuration values of this chapter": ratelimit.max_tracked_keys and ratelimit.sweep_interval. The README explains how to estimate memory from BenchmarkLimiter_BytesPerKey and ratelimit.max_tracked_keys.
Several nodes¶
Limits are per process. The README and 06 "Rate limits" note that with n api nodes the effective limit is up to n times the configured value (02 "Scaling path").
Decisions to record in docs/DECISIONS.md¶
Rounding of r, t and Retry-After; anonymous fallback to the client IP; key choice for rl.webhook_in; eviction policy. (The IPv6 prefix, failed credentials, the rl.gateway_connect key and the two configuration values are in 02 and 05.)
Tests (write them first)¶
Unit tests with a fake clock, internal/platform/ratelimit:
TestBucket_StartsFull,TestBucket_BurstThenDeny(exactlyburstrequests allowed at a frozen clock, then deny withRetry-After= ceil(60 / per_minute)),TestBucket_Refill,TestBucket_NeverAboveBurst.TestHeaders_Format: exact strings, for example policy"rl.write";q=120;w=60and state"rl.write";r=29;t=1after one request with defaults;TestHeaders_RoundingFloorAndCeil.TestKeys_PerClass(table over every class of the 05 table "Rate-limit classes", read from the chapter, no hard-coded count),TestKeys_AnonymousUsesClientIP,TestKeys_ClassesIndependent(drainingrl.writeleavesrl.readuntouched),TestKeys_WebhookPathValue,TestKeyKinds_MatchChapter05(only with a hand-written table).TestConfig_RejectsZeroValues,TestConfig_EveryCatalogueClassConfigured,TestAllow_UnknownClassErrors.TestMemory_BoundedUnderManyKeys(three timesmax_tracked_keysdistinct keys:Len()never exceeds the cap),TestSweep_RemovesOnlyFullBuckets,TestEvict_PrefersFullThenOldest,TestRun_StopsOnCancel.TestConcurrent_NoOverAdmission(-race; many goroutines on one key at a frozen clock: allowed equalsburst).TestMetric_RejectedCounter(manual metric reader).BenchmarkLimiter_Allow,BenchmarkLimiter_BytesPerKey.
HTTP tests through httpservertest (M1-18):
TestHTTP_429ProblemWithRetryAfter: problem body (coderate_limited,correlation_id),Content-Type: application/problem+json,Retry-After, the SEC-T03 headers.TestHTTP_HeadersOnSuccessAndError: both headers on 200 and on a 404 of the same route.TestHTTP_RateLimitBeforeAuthorize: a limited request never reaches the handler; the 429 is identical for a foreign and a missing resource ID.TestPublicAuth_IPv6KeyedBySlash64: two addresses in the same /64 share one bucket, addresses in different /64 prefixes do not; an IPv4-mapped IPv6 address shares the bucket of its IPv4 form.TestFailedCredentials_CountAgainstPublicAuth: requests with an invalid token from one client IP use uprl.public_auth; then a request with a valid token from that IP gets429without verification (the fake authenticator counts zero calls);token_expiredanduse_dpop_noncefailures do not count.TestIDT25_SpoofedForwardedForCountsPeer: withserver.trusted_proxiesnot containing the test peer, requests with changingX-Forwarded-Forto anrl.public_authoperation share one bucket and get 429 afterburst; with the peer trusted they get separate buckets.TestStart_RejectsGatewayClassOnRESTOperation(internal/wire, fixture operation withrl.typing).
Commands that must pass: cd services/server && go test -race ./internal/platform/ratelimit/... ./internal/platform/httpserver/... ./internal/wire/..., make config-reference (or the M1-08 generator) without a diff afterwards, make docs, make arch, make check.
Acceptance criteria¶
- After
burstrequests the next one gets429 rate_limitedwithRetry-Afteras a problem. -
RateLimit-PolicyandRateLimitmatch 06 "Rate limits" exactly on success and error responses of limited operations. - Keys follow 05 "Rate-limit classes" (client IP with IPv6 by /64, principal, device or token, connection, agent, incoming webhook, human for
rl.terminal, host forrl.host); anonymous requests fall back to the client IP; failed credentials count againstrl.public_auth. - Values come from
ratelimit.<class>.per_minuteand.burst; invalid values stop the start. - A spoofed
X-Forwarded-Forfrom an untrusted peer counts against the peer (ID-T25). - Memory stays bounded under many keys; full buckets disappear with the sweep.
- The check runs before
authorize. -
make checkis green.
Definition of done¶
- Code and tests as above;
make checkgreen. - Documentation:
internal/platform/ratelimit/README.md(classes, keys, headers, memory estimate, per-node note); doc comment on every exported symbol (purpose, parameters, return value, errors, example where useful), comments explain why; the spec rows anddocs/DECISIONS.mdentries; regenerated configuration reference; aservices/server/CHANGELOG.mdentry under Unreleased. An ADR only if you take an architecture decision beyond R-AR-12. - Independent review over the diff (
make reviewplus a code review) and the red team gate (docs/quality/red-team-gate.md) over the diff, with the verdictJAorNEINrecorded in the report. OnNEIN: fix, one test per finding, recheck the delta. - Merge locally into the base branch only if review and red team are green and
make checkpasses; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then commits, files with line counts, test results with counts (including the red run of each new test and the benchmark figures), what is not proven, and follow-ups that could not be done in scope with the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.