Skip to main content

Rate limits

Nervly 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, Nervly rejects the request immediately with 429 Too Many Requests and a 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,
"purpose": "workspace"
}

Every non-2xx response is one flat JSON body: an error code, a human-readable message, and a status_code. A 429 also carries a machine-readable purpose naming which limit was hit — one of workspace, subscriber, monthly_quota, ip, email, api_key or session — so a client can tell a workspace budget from the derived per-subscriber abuse cap.

Retry-After is the only response header on every 429 and 503. There are no X-RateLimit-Remaining or X-RateLimit-Reset headers; treat the status code as the signal and Retry-After as the minimum wait. On the gateway ingest surface (POST /v1/events/trigger and POST /v1/events/bulk) the gateway also emits the IETF draft-11 RateLimit and RateLimit-Policy fields, on a successful 2xx as well as on a 429; the /auth, console and edge surfaces emit Retry-After only.

A 429 means a real limit was hit. A limiter store fault on a fail-closed surface is a different class: it returns 503 with "error": "RATE_LIMIT_STORE_UNAVAILABLE" (no purpose) and Retry-After, never a 429. A non-limiter 503 keeps SERVICE_UNAVAILABLE.

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​

The recipes below all do the same three things: inspect the status code, wait at least Retry-After plus randomized jitter, and stop after a bounded number of attempts rather than looping forever. Python, Go, PHP, Java, C# and Ruby also enforce an overall 30-second deadline; cURL, Node.js, Rust and Swift cap the loop with the attempt limit and a 30-second ceiling on each individual backoff.

Where the language has a first-class cancellation primitive the recipe wires it up — an AbortSignal (Node.js), a context.Context (Go), a CancellationToken (Rust), a thread interrupt (Java), a CancellationTokenSource (C#), or a Swift Task. cURL, Python, PHP and Ruby have no such token in a short script, so they rely on the process's own interrupt signal: Ctrl-C stops the loop at once (the terminal signals the whole process group). Python, PHP and Ruby additionally stop at their wall-clock deadline even when no signal arrives, and cURL stops at its attempt cap. None of these loops retry indefinitely.

# Ctrl-C aborts at once (the terminal signals the whole process group). A
# SIGTERM sent directly to this script is deferred by bash until the foreground
# `sleep` returns, so send it to the process group — or run `sleep` in the
# background and `wait` — for prompt SIGTERM shutdown.
trap 'echo "cancelled" >&2; exit 130' INT TERM

# `-D` writes the response headers to a file so Retry-After can be read.
function nervly_trigger() {
local attempt base delay retry_after status headers body
for attempt in 1 2 3 4 5; do
headers=$(mktemp)
body=$(mktemp)
status=$(curl -sS -o "$body" -D "$headers" -w '%{http_code}' \
--connect-timeout 5 \
--max-time 10 \
-X POST https://api.nervly.io/v1/events/trigger \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-ORD-94102" \
-d '{
"name": "order-confirmed",
"to": { "subscriberId": "sub_4920a", "email": "ada@example.com" },
"payload": { "orderId": "ORD-94102" }
}')

if [ "$status" -ne 429 ]; then
cat "$body"
return 0
fi

retry_after=$(awk 'tolower($1) == "retry-after:" { print $2 }' "$headers" | tr -d '\r')
if [ -z "$retry_after" ]; then retry_after=0; fi
base=$((2 ** (attempt - 1)))
delay=$(awk -v b="$base" -v r="$retry_after" 'BEGIN { srand(); d = r + rand() * b; if (d > 30) d = 30; printf "%.3f", d }')
sleep "$delay"
done
echo "still rate limited after 5 attempts" >&2
return 1
}
nervly_trigger

Because the limiter runs before idempotency, a retry after a 429 is always safe: nothing was enqueued, so no duplicate notification can result. The Idempotency-Key above is reused across attempts for exactly that reason. This applies to POST /v1/events/trigger. Bulk submission is not idempotent (see Bulk sending), and a bulk rate-limit rejection is reported per event, not as a batch 429.

Bulk sending throughput​

POST /v1/events/bulk allows sending up to 5,000 notifications in a single HTTP request. Batching reduces HTTP overhead — one TLS round trip, one authentication check, one response — but it does not reduce rate-limit work: the gateway evaluates both rate-limit tiers for every event, exactly as if each had been sent on its own. Because evaluation is per event, a batch can return some events as FAILED while others are QUEUED; read failedCount and events rather than the HTTP status. 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​