Rate Limiting
Every CRUD route is rate-limited by default – not just models that opt in. A model with no rate_limit block still gets the manifest’s global default (100 requests/60s per IP, unless changed in kirak.json). Limits are enforced before access control or hooks run.
Configuration
Section titled “Configuration”Global defaults via kirak.json
Section titled “Global defaults via kirak.json”{ "rate_limit": { "enabled": true, "default_max_requests": 100, "default_window_seconds": 60, "trusted_proxy_ips": [] }}| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Global kill switch. false disables rate limiting everywhere – per-model CRUD limits and the auth endpoint limits alike. |
default_max_requests |
integer | 100 |
Applied to any model with no rate_limit block. |
default_window_seconds |
integer | 60 |
Window for the default limit. |
trusted_proxy_ips |
array of strings | [] |
IPs allowed to set X-Forwarded-For/X-Real-IP – see Client IP resolution below. |
Per-model overrides in models.json
Section titled “Per-model overrides in models.json”Add a rate_limit block to any model to override the global default for that model. Two shapes are supported:
Flat (same limit for every operation on the model):
{ "products": { "table": "products", "schema": { "..." }, "rate_limit": { "max_requests": 60, "window_seconds": 60, "per": "user" } }}Per-operation (different limits per CRUD operation, with an optional default fallback for operations not listed):
{ "products": { "rate_limit": { "default": { "max_requests": 100, "window_seconds": 60, "per": "ip" }, "create": { "max_requests": 10, "window_seconds": 60, "per": "user" }, "destroy": { "max_requests": 5, "window_seconds": 3600, "per": "user" } } }}Any of fetch, search, count, exists, create, update, upsert, delete, destroy, restore can have its own sub-config. A rate_limit block only overrides the operations it lists: an operation with no entry and no default still inherits the global kirak.json default, the same as a model with no rate_limit block at all.
| Key | Type | Required | Description |
|---|---|---|---|
enabled |
boolean | No | false disables rate limiting for this model entirely. Absent or true leaves limiting active. |
max_requests |
integer | Yes* | Maximum requests allowed within the window. *Not required when enabled: false. |
window_seconds |
integer | Yes* | Rolling window duration in seconds. *Not required when enabled: false. |
per |
string | No | Counter scope: "ip" (default), "user", or "model". |
To opt a model out of rate limiting entirely, set enabled: false:
{ "internal_jobs": { "rate_limit": { "enabled": false } }}Omitting rate_limit, setting it to {} or to {"enabled": true} all fall back to the global default – none of them disables limiting.
The two shapes cannot be mixed, and a flat block needs max_requests: {"max_requests": 10, "create": {...}} and {"per": "user"} are rejected when the models load, so a limit is never silently ignored. In a per-operation block every entry needs max_requests.
Counter Scopes
Section titled “Counter Scopes”per: "ip" (default)
Section titled “per: "ip" (default)”One counter per remote IP address, per model, per operation. Best for public-facing endpoints.
- Key:
crud:{model_name}:{operation}:ip:{ip_address}
per: "user"
Section titled “per: "user"”One counter per authenticated user, per model, per operation. Best for authenticated endpoints.
- Key:
crud:{model_name}:{operation}:user:{user_id}(from JWTsubclaim) - When the request is unauthenticated, it still uses a
user:-keyed bucket, withuser_idset toanon:{ip_address}– it does not merge into theper: "ip"counter.
per: "model"
Section titled “per: "model"”One global counter shared across all users and IPs, per model, per operation.
- Key:
crud:{model_name}:{operation}
Client IP Resolution
Section titled “Client IP Resolution”By default, the ip scope uses the raw connecting socket address (request.client.host) – headers like X-Forwarded-For are ignored, so a client can’t spoof its way to a fresh counter just by setting one.
Behind a reverse proxy or load balancer, add its IP to trusted_proxy_ips in kirak.json to have Kirak trust that specific hop’s forwarding headers instead:
{ "rate_limit": { "trusted_proxy_ips": ["10.0.0.5"] } }When the direct connecting IP matches an entry in trusted_proxy_ips, Kirak reads X-Forwarded-For (the leftmost entry) or falls back to X-Real-IP. When it doesn’t match, the direct socket IP is used regardless of what headers are present. This same resolution logic also applies to the auth endpoint limits (login, register, etc. – see below), not just per-model CRUD limits.
Backend
Section titled “Backend”Rate limit counters are stored in:
- Redis (if
KIRAK_REDIS_URLis set) – sliding-window algorithm using Redis sorted sets, enforced atomically in a Lua script (ZREMRANGEBYSCORE+ZCARD+ZADD+EXPIRE). More accurate, auto-expires, gives a preciseRetry-After. - Database (fallback) – fixed-window using the
kirak_rate_limitstable, withSELECT ... FOR UPDATErow locking so concurrent requests against the same key can’t both read the same count and both squeak past the limit.
Redis is strongly recommended for rate limiting in production. Fixed-window counters in the database can allow brief bursts at window boundaries.
Both backends fail open: if Redis is unreachable, or the kirak_rate_limits table doesn’t exist, the request is allowed through and a warning is logged – rate limiting never itself becomes an outage.
When a Limit is Exceeded
Section titled “When a Limit is Exceeded”The request is rejected with HTTP 429 before any operation runs:
{ "statusCode": 429, "status": "error", "error": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Please try again later.", "data": null}Response headers:
HTTP/1.1 429 Too Many RequestsRetry-After: 42Retry-After is the number of seconds until the oldest request expires from the window.
Quota headers on successful requests
Section titled “Quota headers on successful requests”Every CRUD response that passes the rate limit check also carries quota headers, so a client can back off before it actually hits the limit:
X-RateLimit-Limit: 100X-RateLimit-Remaining: 87X-RateLimit-Reset: 1735689600X-RateLimit-Reset is a Unix timestamp for when the current window ends.
Rate Limiting Custom Routes
Section titled “Rate Limiting Custom Routes”The same limiter backs a public dependency for routes you add yourself outside the auto-generated CRUD routes:
from fastapi import Depends, Requestfrom kirak.core.utils.crud_rate_limit import rate_limit_dep
@app.post("/search")async def search( request: Request, _: None = Depends(rate_limit_dep("search:global", 10, 60)),): ...rate_limit_dep(key, max_requests, window_seconds) returns a FastAPI dependency that raises HTTPException(429) when the limit is exceeded, using the same Redis/DB backend selection and fail-open behavior as CRUD routes. Pick a key that won’t collide with the crud:* keyspace used internally.
Inspecting and Resetting Limits
Section titled “Inspecting and Resetting Limits”Three admin-only (admin, system or superadmin role) endpoints under /admin let you inspect and clear rate limit counters at runtime, against whichever backend (Redis or DB) is active:
| Method | Path | Description |
|---|---|---|
| GET | /admin/rate-limits |
List current counters. Query params: prefix (filter by key prefix), limit (default 100, max 1000). |
| GET | /admin/rate-limits/{key} |
Get the counter for one specific key. |
| DELETE | /admin/rate-limits/{key} |
Reset (delete) the counter for one key – the next request starts a fresh window. |
{key} is the full internal key, e.g. crud:posts:fetch:ip:192.168.1.1 – exactly as returned by the list endpoint (the kirak:rl: Redis prefix is stripped for display and doesn’t need to be included; the crud: part does). Useful for unblocking a legitimately rate-limited user, or checking why a specific key is being throttled, without needing direct Redis/DB access.
Examples
Section titled “Examples”Public search endpoint – strict IP limit
Section titled “Public search endpoint – strict IP limit”{ "products": { "rate_limit": { "max_requests": 30, "window_seconds": 60, "per": "ip" } }}Authenticated users – generous per-user limit
Section titled “Authenticated users – generous per-user limit”{ "orders": { "rate_limit": { "max_requests": 200, "window_seconds": 60, "per": "user" } }}Expensive export endpoint – global model limit
Section titled “Expensive export endpoint – global model limit”{ "exports": { "rate_limit": { "max_requests": 10, "window_seconds": 3600, "per": "model" } }}Auth Endpoint Limits
Section titled “Auth Endpoint Limits”Auth endpoints use the same default_max_requests and default_window_seconds from kirak.json as all other routes. When no global defaults are set, they fall back to 100 requests per 60 seconds.
| Endpoint | Counter scope |
|---|---|
POST /auth/login |
per IP, per email (two separate counters) |
POST /auth/register |
per IP |
GET /auth/verify-email |
per IP |
POST /auth/request-reset-password |
per email |
POST /auth/request-otp |
per phone |
POST /auth/verify-otp |
per phone |
POST /auth/{provider}/mobile |
per IP |
GET /auth/{provider}/callback |
per IP |
POST /auth/setup-mfa |
per IP |
POST /auth/verify-mfa |
per IP |
POST /auth/disable-mfa |
per IP |
POST /auth/apple/callback (Apple’s form-POST callback, separate from the generic GET /auth/{provider}/callback route) shares the same social:callback:ip:* counter as the row above – it isn’t a distinct limit.
To tighten auth limits for production, set lower global defaults in kirak.json:
{ "rate_limit": { "default_max_requests": 10, "default_window_seconds": 60 }}The global rate_limit.enabled: false flag still disables all limits (CRUD and auth) and is the only override that applies uniformly regardless of the default values.