Rate limits
NovaVMS rate-limits requests at two layers:
- Per-service-account — a token-bucket keyed on the SA identity, refilled at the configured per-minute rate. Implemented by
middleware.SARateLimit(perMinute)incloud/internal/middleware/ratelimit.go. - Per-camera (manual trigger only) — at most one
POST /api/v1/cameras/{id}/triggerper camera per 5 seconds, enforced incloud/internal/devices/camera_trigger.go:130.
There is no per-user (JWT) rate-limit middleware in v1. A separate per-IP RateLimit middleware exists in code but is not wired into the main authenticated route group as of 2026-05-22.
What gets limited
| Bucket | Rate | Burst | Scope | Source |
|---|---|---|---|---|
| Service-account API key | 600 / minute | 600 (token bucket starts full) | Per service_account.id | main.go calls SARateLimit(600) |
| Camera manual trigger | 1 / 5 sec | 1 | Per camera | camera_trigger.go:130 |
Writes (POST, PATCH, PUT, DELETE) count the same as reads. Health checks at /api/health, /api/v1/health, and /api/v1/health/ready are unauthenticated and are not subject to either limiter.
What 429 looks like
{ "error": { "code": "RATE_LIMITED", "message": "too many requests" }}There is no Retry-After header in v1 (the apierror writer does not emit one). There is also no X-RateLimit-Limit / Remaining / Reset header. Treat 429 as “back off and retry”; do not parse headers for the wait time.
Handling 429 in code
Use exponential backoff with jitter. Start at 1 s, double up to 30 s, cap retries at 5.
Bash:
attempt=0; max=5; backoff=1until [ $attempt -ge $max ]; do resp=$(curl -sS -o /tmp/body -w '%{http_code}' \ -H "Authorization: Bearer $NOVAVMS_KEY" \ "https://novavms.novalien.com/api/v1/cameras") if [ "$resp" = "429" ]; then sleep $(( backoff + RANDOM % 2 )) backoff=$(( backoff * 2 )) attempt=$(( attempt + 1 )) else cat /tmp/body break fidoneTypeScript:
// @novavms/sdk >= 1.0.0 retries on 429 with exponential backoff up to 5 times.// Raw-fetch example:async function call(url: string, token: string, attempt = 0): Promise<Response> { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 429 && attempt < 5) { const wait = (2 ** attempt) * 1000 + Math.random() * 500; await new Promise((r) => setTimeout(r, wait)); return call(url, token, attempt + 1); } return res;}Python:
# novavms >= 1.0.0 retries on 429 automatically (max_retries=5).import httpx, time, random
def call(url: str, token: str, attempt: int = 0) -> httpx.Response: r = httpx.get(url, headers={"Authorization": f"Bearer {token}"}) if r.status_code == 429 and attempt < 5: wait = (2 ** attempt) + random.random() time.sleep(wait) return call(url, token, attempt + 1) return rWhat’s NOT in v1
These were described in earlier doc drafts but are not implemented as of 2026-05-22:
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Resetresponse headers.Retry-Afteron 429 responses.- Per-SA quota overrides via admin UI (
rate_limit_rpm/rate_limit_burst). - Per-email login attempt lockout — the per-IP
RateLimitmiddleware exists but is not applied to the auth route group. (ACCOUNT_LOCKED423 still fires on 5 failed logins per the accounts service, but the lockout policy is in service code, not in the rate-limit middleware.)
If you need any of the above, file a feature request — the bucket implementation in middleware.SARateLimit is straightforward to extend.
Quota follows the credential
NovaVMS does not limit by source IP for SA callers. The bucket follows the credential, so rotating outbound IPs, running from Lambda, or NAT’ing through a load balancer all behave the same. JWT callers are not bucketed at all in v1 (the implementation lives in SARateLimit only — JWT requests pass through without bucketing).
Related
- Rate-limit handling — tracing a 429 back to the noisy caller
- Webhook setup — switch from polling to push to cut RPM
- API keys — service-account lifecycle