Rate Limits

InvoiceSpec shapes traffic with three per-plan allowances: a sustained request rate, a bounded burst capacity, and a concurrency cap. Together they protect the invoice engine from bursts and excessive in-flight work while keeping each plan's capacity predictable. Operations govern total paid usage; rate limits govern traffic shape and platform fairness.

Per-plan allowances

Every plan has explicit, authoritative values — no plan is "unlimited".

PlanRequests / minuteBurstConcurrent requests
Free1022
Starter60105
Plus1202010
Pro3003020

Enterprise rate limits are negotiated per customer — contact sales for a tailored allowance. Negotiated per-user overrides are supported separately and take precedence when active.

Enterprise overrides

Enterprise customers can negotiate explicit per-user values that take precedence over the corresponding Plan default. An override targets exactly one rate dimension (requests/minute, burst, or concurrency) for one user and records an explicit positive integer — never null, 0, a negative number, or any sentinel that would mean "unlimited". Overrides may raise or lower a limit, and a partial override (only some dimensions negotiated) leaves the other dimensions at their Plan defaults.

An active override takes precedence over the Plan entitlement while the un-overridden dimensions retain their Plan values. An override with an expiration falls back to the Plan default automatically once it expires — no manual revert is required. The engine resolves the effective value (active override, else Plan default) in the same API-key authentication lookup as the rest of your policy, with no extra round trip per request.

When your effective value changes because an override was added, changed, or expired, the engine reconfigures your bucket preserving proportional usage rather than resetting it to full capacity — the same behavior as a Plan change.

Override creation and changes are an operator/service boundary and are never directly writable by users. The dashboard billing details distinguish a negotiated effective value from a Plan default, but do not expose the operator-only audit metadata (reason, author) behind an override.

One shared pool per user

All API keys owned by one user draw from a single shared allowance. Creating additional API keys does not multiply your capacity — your throughput is governed by your plan, not by your credential count.

How effective limits are resolved

Your effective limits follow your currently effective subscription, not a scheduled future plan. A plan change takes effect at the next billing-period boundary. The engine resolves the three allowances from your plan's entitlements as part of API-key authentication, without an extra lookup on every request.

You can view your effective values at any time on the dashboard Billing page, which shows the requests/minute, burst, and concurrency for your current plan.

Scope

Plan quotas apply to the invoice-engine endpoints (/v1/invoice/**). Health, readiness, OpenAPI/Swagger, and internal service-to-service billing and capacity routes are outside customer quotas so operational control paths remain available.

Configuration failures

A missing, malformed, non-positive, or out-of-range entitlement is a platform configuration failure, not a quota reduction. It never implies unlimited access and never silently applies Free limits to a paid plan. When the engine cannot resolve your effective policy, it returns HTTP 503 RATE_LIMIT_CONFIGURATION_UNAVAILABLE — no invoice work is performed, no operations are charged, and the response includes X-Request-Id for support. Retry later; if it persists, contact support.

Enforcement contract

Every successfully authenticated request to /v1/invoice/** consumes exactly one request token before validation, capacity checks, concurrency admission, or engine processing. This means a request that is later rejected by validation or billing still counts against your rate limit — the token was already consumed. A rate-limited rejection performs no engine work and acquires no concurrency permit.

429 Too Many Requests

When your request-token bucket is empty, the engine returns:

  • HTTP 429 with error code RATE_LIMIT_EXCEEDED
  • A JSON body matching the standard error shape (requestId, errorCode, message)
  • Retry-After — the rounded-up seconds until one token becomes available (minimum 1 second)
  • RateLimit-Limit — your plan's burst capacity
  • RateLimit-Remaining — 0 at the rejection point
  • RateLimit-Reset — the rounded-up seconds until the bucket is fully refilled

No invoice work is performed and no operations are charged on a 429. The request is still recorded as a single rejected API log entry.

Response headers on every authenticated request

Every authenticated invoice response — including a 429 — carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset for the request-token bucket. These headers describe only the token bucket and must not be read as concurrency availability; concurrency is enforced separately and is not reflected in these headers.

Concurrency enforcement

In addition to the request-rate token bucket, InvoiceSpec enforces a per-user concurrency cap that limits the number of in-flight invoice requests at any given moment. This protects engine capacity from excessive parallel work.

Enforcement order

Every successfully authenticated request to /v1/invoice/** follows this order:

  1. Authentication — API key validated
  2. Request-rate token consumed — one token deducted from the bucket
  3. Concurrency permit acquired — one in-flight slot reserved
  4. Validation, capacity check, engine execution — only after both 2 and 3 succeed

A request that is rejected by the concurrency cap does not restore its consumed request-rate token. The token was already spent before the concurrency check — this is intentional and prevents rate-bucket gaming through rapid concurrency-rejected retries.

429 Concurrency limit exceeded

When your in-flight concurrency cap is full, the engine returns:

  • HTTP 429 with error code CONCURRENCY_LIMIT_EXCEEDED
  • A JSON body matching the standard error shape (requestId, errorCode, message)
  • Retry-After: 1 — retry after reducing your in-flight parallelism
  • The RateLimit-* headers from the request-token bucket (already applied in step 2 above) remain on the response but describe only the token bucket, not concurrency availability

No validation, capacity operation, or engine work is performed on a concurrency-rejected request. Zero operations are charged. The request is still recorded as a single rejected API log entry.

Permit lifecycle

A concurrency permit is held for the entire duration of the request — from admission through validation, capacity check, engine execution, and response writing — and is released exactly once after the request completes (success, validation failure, capacity rejection, engine failure, timeout, or client disconnect). You do not need to do anything to release a permit; the server handles this automatically.

Retry guidance for concurrency 429s

When you receive a CONCURRENCY_LIMIT_EXCEEDED 429, reduce the number of in-flight parallel requests before retrying. Unlike rate-limit 429s, the issue is not how fast you are sending requests but how many are currently in flight. Wait for one or more existing requests to complete, then retry. The Retry-After: 1 header is a minimum hint — the real signal is that your in-flight count must drop below your plan's concurrency cap.

Retry guidance

When you receive a 429, wait the number of seconds indicated by Retry-After before sending the next request. For sustained throughput above your plan's requests/minute, upgrade to a higher plan or implement client-side throttling to stay within your allowance.

Plan changes and proportional usage

When your effective plan changes between requests (for example, a Free → Pro upgrade at a billing-period boundary), the engine reconfigures your bucket preserving proportional usage rather than resetting it to full capacity. If you had consumed half of your Free burst, you will have half of your new Pro burst available immediately after the upgrade — you do not get a fresh full bucket.

Pre-authentication anonymous throttling

Before your API key is even validated, InvoiceSpec applies a coarse per-client-IP rate and concurrency policy to invoice-engine endpoints (/v1/invoice/**). This protects the engine from unauthenticated abuse — invalid credentials, credential-stuffing, and brute-force key guessing cannot consume unbounded hashing and database capacity.

The anonymous policy is fixed and separate from every user's plan allowance: 30 requests per minute, burst 10, and 5 concurrent in-flight requests per trusted client IP. Anonymous attempts never consume or expose a user's plan allowance, and the anonymous capacity pool is completely independent from authenticated per-user pools.

Generic 429 on anonymous rejection

When an anonymous request exceeds the rate or concurrency limit, the engine returns a generic HTTP 429 with Retry-After and no plan-specific RateLimit-Limit, RateLimit-Remaining, or RateLimit-Reset headers. The response does not disclose any user, plan, or quota information — the body carries RATE_LIMIT_EXCEEDED or CONCURRENCY_LIMIT_EXCEEDED with a generic message and the X-Request-Id correlation id.

No invoice work is performed and no operations are charged on an anonymous rejection. The request is still recorded as a single rejected API log entry without recording credentials or sensitive request content.

Trusted proxy assumptions

The limiter identity is the trusted client IP. Forwarded client-IP headers (X-Forwarded-For, X-Real-IP, RFC 7239 Forwarded) are honored only when the immediate sender is a configured trusted reverse proxy. A direct client can never evade the anonymous limiter by supplying or rotating spoofed forwarding headers — when the sender is not a trusted proxy, the immediate socket peer address is the identity and every forwarding header is ignored.

When the sender is a trusted proxy, the engine walks the X-Forwarded-For chain right-to-left, skipping entries that are themselves trusted proxies, and selects the first non-trusted valid IP as the original client. This defeats the classic leftmost-spoofing attack: a client behind the proxy that injects a fake leftmost X-Forwarded-For entry cannot rotate the limiter identity, because the proxy-appended real client is the rightmost non-trusted entry.

If you deploy InvoiceSpec behind a reverse proxy (nginx, Cloudflare, AWS ALB, Coolify), configure the proxy's IP or CIDR in invoicespec.rate-limit.anonymous.trusted-proxies so forwarded headers are honored. Without this configuration, every request appears to originate from the proxy's IP and shares one anonymous bucket — which may be too coarse for your traffic pattern.

Configuration

The anonymous policy and trusted-proxy list are configurable:

PropertyDefaultDescription
invoicespec.rate-limit.anonymous.requests-per-minute30Sustained anonymous rate per trusted client IP
invoicespec.rate-limit.anonymous.burst-capacity10Anonymous burst capacity per trusted client IP
invoicespec.rate-limit.anonymous.concurrent-requests5Max concurrent anonymous in-flight requests per trusted client IP
invoicespec.rate-limit.anonymous.trusted-proxies(empty)Comma-separated trusted reverse-proxy IPs or CIDR blocks

Health, readiness, OpenAPI/Swagger, and internal service-to-service billing or capacity routes are excluded from anonymous throttling — they are not matched by the /v1/invoice/** security matcher.

Operational characteristics

The rate and concurrency limiters run in-memory inside the single invoice-engine process (ADR-0016). This section documents the operational consequences of that choice: the single-instance invariant, restart behavior, cache sizing, when to migrate to distributed enforcement, and how quotas are reassessed from telemetry. It is intended for operators and platform engineers, not API clients.

Single-instance invariant

The invoice engine MUST run as exactly one service instance while local in-memory limiter storage is configured. A second replica would create an independent second copy of every User's bucket and bulkhead, doubling the effective allowance and breaking the per-User pool contract. The backend enforces this invariant at startup, not only in documentation: when local storage is selected and the configured replica count is greater than one, the application fails to start with an explicit error. This prevents an accidental multi-replica deployment from silently multiplying quotas.

The replica count is declared in deployment configuration (invoicespec.rate-limit.replicas, default 1) and validated against the configured storage mode (invoicespec.rate-limit.storage, default local). The guard is a startup validator, not a runtime health check — a process that passes the guard will not silently start coordinating with a second replica later.

Backend restart and fresh burst capacity

Because limiter state lives in process memory, a backend restart resets every User's bucket to full capacity. A User who had consumed their entire Free burst immediately regains the full burst after a restart. This is an accepted limitation of the single-instance phase and is observable: the invoicespec.rate-limit.startup counter increments once per process start, so operators can alert on restart frequency. Frequent restarts amplify the fresh-burst effect and are a signal to investigate stability or migrate to distributed storage.

The reset affects only the in-memory token bucket and bulkhead state. Plan entitlements, Enterprise overrides, operations, and API logs are persisted in Supabase Postgres and are not affected by a restart.

Local cache capacity and expiry

Per-User buckets and bulkheads are held in a bounded Caffeine cache with a configurable maximum size and idle-expiry duration. The cache prevents unbounded heap growth from authenticated User cardinality.

PropertyDefaultDescription
invoicespec.rate-limit.cache.maximum-size100000Maximum number of per-User entries before the least-recently-used entry is evicted
invoicespec.rate-limit.cache.expire-after-access15mIdle-expiry duration — a bucket or bulkhead unused for this long is eligible for eviction

The default maximum size of 100000 covers the active authenticated User cardinality with headroom; the 15-minute idle expiry reclaims state for Users who have stopped sending traffic. Both values are chosen so that a single instance's heap is bounded regardless of total registered Users, while active Users retain their bucket across normal request spacing.

The cache exposes eviction-pressure as a Micrometer gauge (invoicespec.rate-limit.cache.eviction-pressure, tagged cache=buckets and cache=bulkheads), computed as estimated-size / maximum-size. A pressure approaching 1.0 indicates the bounded store is full and entries are being evicted; if evictions are non-zero under sustained high pressure, raise maximum-size. Eviction count and cache size are also exported as gauges (invoicespec.rate-limit.cache.evictions, invoicespec.rate-limit.cache.size).

Operational metrics

The limiter exports low-cardinality Micrometer metrics so operators can observe health, performance, and configuration failures. No User ID, raw IP address, API key, or other unbounded sensitive value is used as a metric label — the only labels are outcome (admitted/rejected), requests_per_minute (the Plan's requests-per-minute, a small fixed set), limiter (rate or concurrency), reason, and cache.

MetricTypeLabelsDescription
invoicespec.rate-limit.requestscounteroutcome, requests_per_minuteRequest-rate decisions admitted or rejected, by Plan RPM
invoicespec.rate-limit.decisiontimerlimiter, outcomeTime spent inside the rate or concurrency limiter decision
invoicespec.rate-limit.configuration.failurescounterreasonAlertable configuration-failure counter (policy_unavailable from the filter, invalid_entitlement from the resolver)
invoicespec.rate-limit.cache.evictionsgaugecacheCaffeine eviction count (buckets / bulkheads)
invoicespec.rate-limit.cache.sizegaugecacheEstimated cache size
invoicespec.rate-limit.cache.eviction-pressuregaugecachesize / maximumSize — approaching 1.0 means the store is full
invoicespec.rate-limit.startupcounter(none)One-shot increment per process start — signals a fresh in-memory state

Alert on a non-zero rate of configuration.failures (either reason) — it indicates a missing or malformed entitlement that is returning 503 to authenticated Users. Alert on a rising startup counter to surface restart frequency.

When to migrate to distributed enforcement

A second invoice-engine replica is prohibited until request-rate state uses shared Redis/Valkey-backed Bucket4j storage and distributed concurrency has been designed and verified. The migration preserves the project-owned limiter port and the public API contract — only the storage and concurrency adapters and their configuration change. Controllers, filters, error codes, headers, and the per-User pool semantics are unaffected.

Evaluate the migration using measured signals, not assumptions:

  • Limiter latency — the decision-duration timer. If the in-memory decision latency is no longer negligible relative to engine latency, or if a distributed adapter's added latency is acceptable for the traffic shape, migration is viable.
  • Lock/CPU pressure — sustained high concurrency or contention on the in-memory bulkheads.
  • Engine saturation — if a single instance can no longer serve traffic within latency targets, horizontal scaling is required, and distributed limiter state is a prerequisite.
  • Traffic growth — sustained growth beyond single-instance capacity.
  • Availability requirements — if a single-instance restart window is no longer acceptable, a second replica behind distributed state reduces the blast radius.
  • The decision to add a second replica — the migration is triggered by the need to scale horizontally, not by the existence of Redis/Valkey.

The Bucket4j Bucket4j port isolates the storage adapter; the future Redis/Valkey adapter replaces Bucket4jRequestRateLimiter's local Caffeine store with a distributed proxy manager, and the Resilience4j Bulkhead is replaced with a distributed semaphore or equivalent. No controller or filter change is required.

Quota reassessment

The launch defaults of 10/60/120/300 requests per minute (Free/Starter/Plus/Pro) are retained until evidence supports a change. Reassess quotas from measured telemetry, not assumptions:

  • Per-Plan limiter utilization — are Users consistently near their allowance, or is headroom large?
  • 429 frequency — a high rejected rate for a Plan may indicate the allowance is too low for real usage; a near-zero rate may indicate it is generous.
  • Engine saturation — if the engine is saturated under current quotas, raising allowances without capacity is harmful.
  • Latency — if engine latency is degrading under current traffic, raising allowances will worsen it.
  • Capacity-exhaustion patterns — if paid Users exhaust operations well before hitting rate limits, the rate limit is not the binding constraint.
  • Support evidence — concrete customer reports, not speculative demand.

Paid-plan reductions require reasonable notice except for abuse or incidents. Operations govern total paid usage; rate limits govern traffic shape and platform fairness — changing one does not implicitly change the other.