Skip to main content

Rate limits

Nerve enforces two-tier rate limiting in a single atomic Redis pipeline round-trip at the gateway edge:

Request Ingest


┌─────────────────────────────────────────────────────────────┐
│ Tier 1: Workspace Global Quota │
│ Evaluates: rl:ws:{workspace_id}:{sec} │
│ Active Pro = 100 RPS | Free / Degraded = 1 RPS │
└─────────────────────────────┬───────────────────────────────┘
│ Allowed

┌─────────────────────────────────────────────────────────────┐
│ Tier 2: Subscriber Abuse Cap │
│ Evaluates: rl:sub:{workspace_id}:{subscriber_id}:{sec} │
│ Limit = workspace_limit / 5 (e.g. 20 RPS per subscriber) │
└─────────────────────────────┬───────────────────────────────┘
│ Allowed

Enqueued to NATS

How the two tiers protect you

  1. Tier 1 (Workspace Quota): Enforces your plan's total throughput capacity across all subscribers (rate_limit_rps = 100 on active subscriptions, 1 on free tier).
  2. Tier 2 (Per-Subscriber Cap): Limits any single to.subscriberId to 20% of your workspace quota (limit / 5). A runaway retry loop or buggy client notifying a single user cannot exhaust your entire workspace bandwidth.
  3. Atomic Evaluation: Both counters are evaluated within a single Redis pipeline round trip, adding less than 0.1ms to ingest overhead.

What a rejection looks like

When either limit is exceeded, Nerve rejects the request immediately with 429 Too Many Requests and a standard Retry-After header:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1

{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Workspace rate limit exceeded. Retry after 1 second.",
"status_code": 429
}

The rate limiter runs before message persistence and idempotency recording, so a 429 means nothing was enqueued — your Idempotency-Key remains unconsumed and safe to retry.

Handling 429 properly

Always inspect the Retry-After header and back off before retrying:

async function triggerWithRetry(body: TriggerRequest, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch('https://api.nervly.io/v1/events/trigger', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NERVE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});

if (res.status !== 429) return res;

// Respect the Retry-After header (seconds) with jitter
const retryAfterSeconds = parseInt(res.headers.get('Retry-After') || '1', 10);
const delayMs = retryAfterSeconds * 1000 + Math.random() * 200;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error('Rate limit exceeded after 5 retries');
}

Bulk sending throughput

POST /v1/events/bulk allows sending up to 1,000 notifications in a single HTTP request. The gateway performs a single rate-limit evaluation against bulk ingestion rules rather than individual per-event evaluations, dramatically reducing round-trip latency. See Bulk sending.

Failure posture during Redis outages

  • Live Mode: The limiter fails open if Redis is unreachable, allowing live customer traffic through rather than causing an artificial outage.
  • Test Mode: The limiter fails closed, ensuring integration tests do not proceed unchecked when dependencies are down.

Next