API Rate Limiting & Storage Quotas
This guide covers protecting a shared nova server deployment from noisy
clients: in-process API rate limiting and warn-then-reject storage quotas
(ADR-0179; normative
budgets in design/spec/rate-limiting-quotas-v0.md (private)).
Status: experimental (v0.61, shipped 2026-07-16) — and disabled by default. With the feature off (the default), no middleware is installed at all: no headers, no timing side effects, zero behavior change on upgrade.
Both tracks apply to the nova server app only; the nova serve dashboard
is not rate-limited.
1. Rate limiting
Design in one paragraph
A stdlib-only token bucket per key and limit class, on a monotonic clock, held in a bounded in-process map (LRU-evicted above 10,000 buckets). It is deliberately not distributed — the supported topology is a single-writer server, so an in-process limiter is the honest fit (ADR-0179).
Limit classes and defaults
Every request is classified, in order: exemptions → admin prefixes → OTLP ingest paths → write verbs → reads.
| Class | Matches | Default rate (tokens/s) | Default burst |
|---|---|---|---|
ingest |
write verbs (POST/PUT/…) + any OTLP ingest path, regardless of verb | 100 | 200 |
read |
GET / HEAD | 50 | 100 |
admin |
/v0/admin/*, /v0/roles/* |
10 | 20 |
Always exempt — never limited, never counted, no headers: /health,
/livez, /readyz, /metrics. Probes must not brown out with the API, and
/metrics is how you see the limiting.
Bucket keying
The bucket key is resolved per request in strict fallback order — first available wins:
- principal — the SHA-256 digest of the presented bearer token (the raw token is never used as a key value), once a request carrying that token has authenticated;
- tenant — a resolved tenant id (the slot exists for the ADR-0178 workspace model; no server surface populates it before routing today);
- client IP — unauthenticated surfaces, and every request whose bearer token has not yet authenticated.
A token's first request therefore draws from its client's IP bucket; once it
authenticates, the token has its own bucket. A client that sends a different
invalid token on every request is limited by its IP and creates no buckets.
With insecure_no_auth no token names a principal, so all requests are keyed
by client IP.
Classes never share buckets, so an ingest storm cannot starve reads for the same client.
The 429 contract
A limited request receives 429 with the standard ADR-0017 error envelope
(error.code = "rate_limited") plus:
Retry-After— whole seconds until one token is available (ceiling);X-RateLimit-Limit— the class burst (bucket capacity);X-RateLimit-Remaining— whole tokens still available;X-RateLimit-Reset— delta-seconds until the bucket is full again.
Successful responses on classed routes carry the three X-RateLimit-*
headers too, so well-behaved clients can pace themselves before hitting 429.
Enabling and tuning
In ~/.config/novafabric/server.yaml (or the --config file), as a top-level rate_limits: block:
rate_limits:
enabled: true
ingest: { rate: 100, burst: 200 }
read: { rate: 50, burst: 100 }
admin: { rate: 10, burst: 20 }
audit_threshold_rejections: 100 # rejections per key per window that trigger an audit event
audit_window_seconds: 60or via environment overrides: NOVAFABRIC_SERVER_RATE_LIMITS_ENABLED=1,
NOVAFABRIC_SERVER_RATE_LIMITS_{INGEST,READ,ADMIN}_{RATE,BURST},
NOVAFABRIC_SERVER_RATE_LIMITS_AUDIT_THRESHOLD_REJECTIONS,
NOVAFABRIC_SERVER_RATE_LIMITS_AUDIT_WINDOW_SECONDS.
Watch the effect in Prometheus:
rate(nova_http_requests_total{status="429"}[5m]) — 429 rejections are
counted by the metrics middleware (see monitoring).
Audit trail
Sustained limiting of one key — at least audit_threshold_rejections
rejections within one audit_window_seconds window — emits one audit
record per key per window (rate_limit_sustained), carrying the key
digest (sha256:…), the limit class, the rejection count, and the window
start. The raw key value (token, IP) is never stored. A structured warning is
always logged for operator visibility.
Honest note: these records go to the same append-only JSONL audit log the
server's role-management routes already use. That log is append-only but
not hash-chained; the spec's hash-chaining requirement lands when the
hash-chained AuditLog is wired into the server app (see the ADR-0179
status note).
2. Storage quotas
Semantics
Quotas bound what the capsule store holds — capsule count and total stored bytes — with warn-then-reject enforcement, checked at capsule ingest only (the capsule write routes):
- Soft limit reached (
usage >= soft) — the write succeeds, and the response carries a warning header:X-NovaFabric-Quota-Warning: capsules 950/1000(comma-joined when both kinds warn). One audit event per kind per audit window (quota_soft_exceeded). - Hard limit reached (
usage >= hard) — the write is rejected with429,error.code = "quota_exceeded", anddetailscarryingkind/usage/limit. There is deliberately noRetry-Afterheader — a quota does not decay on a clock; the remedy is deleting or archiving capsules, or raising the limit. Audit event:quota_hard_exceeded.
Usage is derived from the existing capsule store (directories holding a
capsule.yaml, plus every file's lstat size; the ingest spool
.ingest-tmp/ is excluded, so in-flight uploads never count) — no new ledger
to reconcile — and cached with a short monotonic TTL (5 s) so hot ingest paths
don't re-count the store per request. The recount runs on a worker thread, one
at a time (requests arriving mid-recount use the previous value), and examines
at most 1 000 000 entries, past which it enforces against a logged lower bound.
Quota audit events carry no key hash: this global
track is per-deployment; per-workspace budgets are a separate additive
track (ADR-0208, §3 below, experimental) that reads metered counters
instead of the store walk.
Enabling
Quotas require the same master switch as rate limiting
(rate_limits.enabled: true) plus a quota block with at least one non-zero
limit. 0 means unlimited; all-zero limits keep the feature fully inert.
rate_limits:
enabled: true
quota:
max_capsules_soft: 900
max_capsules_hard: 1000
max_bytes_soft: 8_000_000_000 # 8 GB
max_bytes_hard: 10_000_000_000 # 10 GBHard limits must be ≥ their soft counterpart (validated at config load).
3. Per-workspace budgets & usage metering
Status: experimental (ADR-0208;
normative contract in design/spec/usage-metering-v0.md (private)). Behind the same
master switch (rate_limits.enabled); with the switch off there is no
accounting, no middleware, and no new tables — zero behavior change.
Usage metering
Every accepted capsule upload is attributed to a workspace at ingest and
recorded in an append-only usage_ledger + transactional usage_counters
in the registry DB (metrics: capsules_created, bytes_stored = unpacked
size, api_requests via a bounded accumulator flushed at most once per
usage.flush_interval_s). Attribution order: API-key workspace binding →
the principal's single workspace membership → the default workspace —
recorded on each row (attribution = key|membership|default), so
unattributed usage is visible, not laundered. Counting is idempotent by
construction: a retried upload hits the 409-duplicate path, and a replayed
ledger insert is a no-op under the (metric, ref) unique index. Deleting a
capsule (single or bulk, ADR-0206) appends negative ledger rows so
budgets reflect reclaimed storage; capsules uploaded before metering shipped
appear only in the derived global/drift figures. A capsule uploaded again
after a delete is metered again: each stored lifetime of a run has its own ledger
ref (<run_id>, then <run_id>/2, <run_id>/3…), and a delete reverses the
latest one.
Monthly rollups freeze each finished period lazily at the first write of the
next one (no cron) and are retention-bounded
(usage.rollup_retention_months, default 24; raw ledger rows
usage.ledger_retention_months, default 3).
Reporting — GET /v0/usage (?period=YYYY-MM, default current):
per-workspace metric totals, org rollups, and — for admin/auditor only — the
global store-derived figures plus a drift block (derived minus metered).
Other principals get a filtered response (their membership workspaces
only) — filtering, not 403. Accounting is best-effort relative to the
upload: a metering failure is logged + audited and never fails the write.
Reconciliation and chargeback export (ADR-0208 P2/P3)
Status: experimental. Two admin CLI commands read the same registry DB
the server meters into (--db-path, else the server config's db_path).
nova server usage reconcile compares the metered lifetime sums
with measure_capsule_store and prints the drift (derived minus metered).
Both sides cover the whole history: totals of rollups pruned past
usage.rollup_retention_months are carried forward (not forgotten), so a
retention prune never shows up as drift; the drift block of
GET /v0/usage uses the same whole-history figure, so the two agree. Rollups pruned before this carry existed
are not recoverable: the first reconcile after upgrading such a deployment
may report that history once as drift — inspect before --apply. It is
report-only by default. With --apply and a non-zero drift it appends one signed
(positive or negative) adjustment row per drifting metric
(capsules_created, bytes_stored) to the default workspace only,
with attribution = 'reconciliation' and ref = 'recon:<timestamp>'.
Existing ledger rows are never rewritten; the counters move only through the
appended, visible row, and a second --apply finds zero drift and writes
nothing. Concurrent --apply runs are serialized by the SQLite write
lock (the metered side is read and the rows appended in one BEGIN IMMEDIATE transaction), so the same drift is never booked twice; an upload
in flight during the store scan can still leave a ±1-capsule drift for the
next run to report. Two refusals by design: there is no workspace selector (attributing
unmetered bytes to a real workspace would be a guess), and a negative
adjustment that would take the default workspace below zero is refused
(exit 1) — the over-count belongs to another workspace, typically after an
out-of-band deletion. Every run appends a usage.reconcile entry to the
hash-chained audit log (an unkeyed SHA-256 chain — tamper-evident, not a
keyed signature). Note: adjustment rows count toward the default
workspace's budget, if one is configured.
nova server usage reconcile # report only
nova server usage reconcile --apply --actor alice@example.comnova server usage export writes one row per
(period, org, workspace, metric) for a --from/--to range of
YYYY-MM periods, sorted in that order (deterministic: the same DB state
gives byte-identical output). Finalized periods come from the monthly
rollups (status = final); periods not yet finalized — always the current
one — come from the live counters (status = provisional) and can still
grow. --format csv (default) is RFC 4180 (CRLF, header row, quoted
fields) with formula-injection-safe text cells (a leading =, +, -,
@, TAB, CR or LF — checked after NFKC normalization, so full-width
=+-@ count, and also after leading whitespace — gets a ' prefix;
integer totals are never rewritten);
--format ndjson emits one sorted-key JSON object per line. Read-only.
Periods older than usage.rollup_retention_months have been pruned and
export empty.
nova server usage export --from 2026-07 --to 2026-09 -o chargeback-q3.csv
nova server usage export --from 2026-09 --format ndjson --workspace ml-platformHTTP export — GET /v0/usage/export (experimental, ADR-0208 P3)
serves the same rows and bytes over the nova server API, as an attachment:
curl -H "Authorization: Bearer $TOKEN" -OJ \
"https://nova.example.com/v0/usage/export?from=2026-07&to=2026-09&format=csv"
# -> nova-usage-2026-07_2026-09.csv (text/csv; charset=utf-8; header=present)
curl -H "Authorization: Bearer $TOKEN" \
"https://nova.example.com/v0/usage/export?from=2026-09&format=ndjson&workspace=ml-platform"
# -> application/x-ndjsonQuery parameters: from/to (YYYY-MM; from defaults to the current UTC
period, to to from; at most 120 periods), format (csv default, or
ndjson), optional exact-match workspace / org. Access follows
GET /v0/usage: admin and auditor see every workspace; any other
authenticated principal only its membership workspaces — filtering, not
403, and a workspace/org filter never widens that view. A malformed
period, inverted or over-wide range, or unknown format is a 400 error
envelope (invalid_period, invalid_period_range, invalid_format). The
registry is opened read-only (mode=ro): the request never creates the
DB or the usage tables, and the membership lookup for non-privileged callers
is read-only too. A missing DB or missing usage tables export as zero rows;
any other registry failure (locked, I/O error) is a 503 error envelope
(usage_store_unavailable) — never an empty billing file. Exports are not audited (neither is the CLI export
nor GET /v0/usage); with metering on, the request itself still counts
toward api_requests like any other. The rows are materialized and sorted
in memory before streaming — bounded by 120 periods × workspaces × metrics.
Per-org budgets and request-time enforcement of the API-key workspace binding shipped as experimental (ADR-0294, below).
Per-workspace budgets
An additive workspaces map inside the existing quota block; the global
limits keep their exact meaning and both checks run (strictest wins).
Enforcement reads the metered counters, not the store walk:
rate_limits:
enabled: true
quota:
workspaces: # ADR-0208; absent => byte-identical behavior
ml-platform:
max_capsules_soft: 1000
max_capsules_hard: 2000
max_bytes_soft: 0 # 0 = unlimited, per existing convention
max_bytes_hard: 0Same ladder and contract: soft ⇒ write succeeds +
X-NovaFabric-Quota-Warning: <workspace>/<kind> <usage>/<limit> (comma-
joined after any global warning) + one audit event per (workspace, kind)
per window; hard ⇒ 429 quota_exceeded with an additive workspace field
in details, no Retry-After. A config that names a workspace slug that
does not exist is refused at startup. Alerting (ADR-0192): a hard rejection
fires ops.quota.breached (critical, subject quota:<workspace>:<kind> —
per-workspace dedup); the first soft crossing per window fires the same
event at warning severity (subject suffixed :soft). Both are no-ops
unless NOVA_ALERTS_* is configured.
Per-org budgets (experimental, ADR-0294)
An additive orgs map beside workspaces, with the same four keys:
rate_limits:
enabled: true
quota:
orgs: # ADR-0294; absent => byte-identical behavior
acme:
max_capsules_soft: 5000
max_capsules_hard: 8000
max_bytes_hard: 0 # 0 = unlimitedOrg usage is the sum of the all-time metered counters of every workspace in
the org (workspace → org resolved the same way attribution resolves it;
unknown workspaces count toward default). Global, workspace and org checks
all run and the strictest wins. Soft ⇒ the warning header gains an
org:<org>/<kind> <usage>/<limit> part; hard ⇒ 429 quota_exceeded with
an additive org field in details. Alerts use the subjects
quota:org:<org>:<kind> (critical) and …:soft (warning), so an org breach
never deduplicates against a workspace with the same slug. An unknown org
slug in config is refused at startup. Limit: counters are keyed by workspace
slug and slugs are unique only within an org, so two orgs that share a
workspace slug share one counter (assigned to the first org by slug).
API-key workspace-binding enforcement (experimental, ADR-0294)
api_keys:
enforce_workspace_binding: true # env NOVAFABRIC_SERVER_API_KEYS_ENFORCE_WORKSPACE_BINDINGOff by default. When on, a request authenticated by an API key with a
workspace binding gets 403:
workspace_binding_invalid— the bound workspace does not exist (or the workspace store cannot be read: fail closed);workspace_binding_mismatch— the request declares another workspace via theX-NovaFabric-Workspaceheader or theworkspacequery parameter (details: {binding, requested, source}).
Unbound keys and non-key credentials are unaffected. Each refusal is written
to the audit log as api_key.binding_refused (refs only, never the key),
at most once per (subject, reason, requested workspace) per 60 s. List
results are not narrowed to the bound workspace — the capsule store is
not partitioned (ADR-0178).
Env overrides: NOVAFABRIC_SERVER_USAGE_{METERING_ENABLED,FLUSH_INTERVAL_S,
ACCUMULATOR_MAX_ENTRIES,ROLLUP_RETENTION_MONTHS,LEDGER_RETENTION_MONTHS}.
usage.metering_enabled: false keeps rate limits on while switching
accounting off.
Honest limits: attribution consumes the ADR-0193 API-key workspace
binding, which is enforced at request time only when
api_keys.enforce_workspace_binding is on — with it off, metering inherits
that gap; this feature meters and scopes, it does not isolate
the capsule store (ADR-0178 gap unchanged); api_requests counts are
deliberately coarse (bounded accumulator, crash loses at most one interval).
4. What this feature is not
- Not distributed. Buckets and quota caches are in-process. If you run multiple server replicas (not the supported single-writer topology), limits apply per replica.
- Not applied to
nova serve. The dashboard app has no limiter. - Not tenant isolation. Per-workspace budgets and metering (§3) are scoping and accounting on top of the shared capsule store — the ADR-0178 isolation gap is unchanged and Security-Architect gated.
See also
- Monitoring & self-observability — how to see 429s and request rates
- Server admin guide — roles, tokens, and audit trails
- ADR-0179 — the
decision record;
design/spec/rate-limiting-quotas-v0.md(private) — normative defaults and the 429/quota contracts