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
- Tier 1 (Workspace Quota): Enforces your plan's total throughput capacity across all subscribers (
rate_limit_rps = 100on active subscriptions,1on free tier). - Tier 2 (Per-Subscriber Cap): Limits any single
to.subscriberIdto 20% of your workspace quota (limit / 5). A runaway retry loop or buggy client notifying a single user cannot exhaust your entire workspace bandwidth. - 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.
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# 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
async function triggerWithRetry(
body: TriggerRequest,
idempotencyKey: string,
signal?: AbortSignal,
): Promise<Response> {
const maxAttempts = 5;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch('https://api.nervly.io/v1/events/trigger', {
method: 'POST',
signal,
headers: {
Authorization: `Bearer ${process.env.NERVLY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.status !== 429) return res;
const retryAfter = Number.parseInt(res.headers.get('Retry-After') ?? '0', 10);
const ceiling = Math.min(2 ** attempt, 30);
const delay = Math.min(Math.max(retryAfter, 0) * 1000 + Math.random() * ceiling * 1000, 30_000);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new Error('aborted'));
},
{ once: true },
);
});
}
throw new Error(`still rate limited after ${maxAttempts} attempts`);
}
import os
import random
import signal
import time
import requests
def _cancel(signum, frame):
raise KeyboardInterrupt("cancelled")
# Ctrl-C and SIGTERM both break out of `sleep` and end the retry loop.
signal.signal(signal.SIGINT, _cancel)
signal.signal(signal.SIGTERM, _cancel)
payload = {
"name": "order-confirmed",
"to": {"subscriberId": "sub_4920a", "email": "ada@example.com"},
"payload": {"orderId": "ORD-94102"},
}
MAX_ATTEMPTS = 5
DEADLINE = time.monotonic() + 30
for attempt in range(MAX_ATTEMPTS):
response = requests.post(
"https://api.nervly.io/v1/events/trigger",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "order-ORD-94102",
},
json=payload,
timeout=10,
)
if response.status_code != 429:
print(response.json())
break
retry_after = float(response.headers.get("Retry-After", 0))
ceiling = min(2 ** attempt, 30)
delay = min(retry_after + random.uniform(0, ceiling), 30)
if time.monotonic() + delay > DEADLINE:
raise TimeoutError("retry deadline exceeded")
time.sleep(delay)
else:
raise RuntimeError("rate limit retry budget exhausted")
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"os"
"time"
)
func main() {
payload := map[string]any{
"name": "order-confirmed",
"to": map[string]string{
"subscriberId": "sub_4920a",
"email": "ada@example.com",
},
"payload": map[string]string{"orderId": "ORD-94102"},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
client := &http.Client{Timeout: 10 * time.Second}
deadline := time.Now().Add(30 * time.Second)
const maxAttempts = 5
for attempt := 0; attempt < maxAttempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), time.Until(deadline))
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.nervly.io/v1/events/trigger", bytes.NewBuffer(body))
if err != nil {
cancel()
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-ORD-94102")
resp, err := client.Do(req)
if err != nil {
cancel()
panic(err)
}
if resp.StatusCode != http.StatusTooManyRequests {
// Read the body before cancelling: the request context governs the
// body read, so cancelling first would surface as "context canceled".
var result map[string]any
decodeErr := json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
cancel()
if decodeErr != nil {
panic(decodeErr)
}
fmt.Println(result)
return
}
resp.Body.Close()
cancel()
retryAfter := 0.0
if value := resp.Header.Get("Retry-After"); value != "" {
fmt.Sscanf(value, "%f", &retryAfter)
}
ceiling := math.Min(math.Pow(2, float64(attempt)), 30)
delay := math.Min(retryAfter+rand.Float64()*ceiling, 30)
if time.Now().Add(time.Duration(delay * float64(time.Second))).After(deadline) {
panic("retry deadline exceeded")
}
time.Sleep(time.Duration(delay * float64(time.Second)))
}
panic("rate limit retry budget exhausted")
}
<?php
// When the CLI exposes PCNTL, a Ctrl-C or SIGTERM cancels the loop instead of
// waiting out the backoff. Without PCNTL the deadline below is the only bound.
$cancelled = false;
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () use (&$cancelled) { $cancelled = true; });
pcntl_signal(SIGTERM, function () use (&$cancelled) { $cancelled = true; });
}
$payload = [
'name' => 'order-confirmed',
'to' => ['subscriberId' => 'sub_4920a', 'email' => 'ada@example.com'],
'payload' => ['orderId' => 'ORD-94102'],
];
$maxAttempts = 5;
$deadline = microtime(true) + 30;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
if ($cancelled) {
throw new RuntimeException('cancelled');
}
$handle = curl_init('https://api.nervly.io/v1/events/trigger');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-ORD-94102',
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($handle);
$status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
curl_close($handle);
if ($status !== 429) {
echo substr($response, $headerSize);
return;
}
$retryAfter = 0.0;
if (preg_match('/^Retry-After:\s*([0-9.]+)/mi', substr($response, 0, $headerSize), $matches)) {
$retryAfter = (float) $matches[1];
}
$ceiling = min(2 ** $attempt, 30);
$delay = min($retryAfter + (mt_rand() / mt_getrandmax()) * $ceiling, 30);
if (microtime(true) + $delay > $deadline) {
throw new RuntimeException('retry deadline exceeded');
}
usleep((int) ($delay * 1_000_000));
}
throw new RuntimeException('rate limit retry budget exhausted');
use reqwest::Client;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let payload = json!({
"name": "order-confirmed",
"to": {
"subscriberId": "sub_4920a",
"email": "ada@example.com"
},
"payload": { "orderId": "ORD-94102" }
});
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let cancel = CancellationToken::new();
let shutdown = cancel.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
shutdown.cancel();
});
let max_attempts = 5u32;
for attempt in 0..max_attempts {
let response = tokio::select! {
_ = cancel.cancelled() => return Err("cancelled".into()),
result = client
.post("https://api.nervly.io/v1/events/trigger")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "order-ORD-94102")
.json(&payload)
.send() => result?,
};
if response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
let body: serde_json::Value = response.json().await?;
println!("{body}");
return Ok(());
}
let retry_after: f64 = response
.headers()
.get("Retry-After")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(0.0);
let ceiling = 2f64.powi(attempt as i32).min(30.0);
let delay = (retry_after + rand::random::<f64>() * ceiling).min(30.0);
tokio::select! {
_ = cancel.cancelled() => return Err("cancelled".into()),
_ = tokio::time::sleep(Duration::from_secs_f64(delay)) => {}
}
}
Err("rate limit retry budget exhausted".into())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
public class TriggerWithRetry {
public static void main(String[] args) throws Exception {
String idempotencyKey = "order-ORD-94102";
String payload = """
{
"name": "order-confirmed",
"to": {
"subscriberId": "sub_4920a",
"email": "ada@example.com"
},
"payload": {
"orderId": "ORD-94102"
}
}
""";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
int maxAttempts = 5;
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
for (int attempt = 0; attempt < maxAttempts; attempt++) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("cancelled");
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/events/trigger"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 429) {
System.out.println(response.body());
return;
}
double retryAfter = response.headers()
.firstValue("Retry-After")
.map(Double::parseDouble)
.orElse(0.0);
double ceiling = Math.min(Math.pow(2, attempt), 30);
double delay = Math.min(retryAfter + ThreadLocalRandom.current().nextDouble() * ceiling, 30);
long wakeAt = System.nanoTime() + (long) (delay * 1_000_000_000L);
if (wakeAt > deadline) {
throw new IllegalStateException("retry deadline exceeded");
}
Thread.sleep((long) (delay * 1000));
}
throw new IllegalStateException("rate limit retry budget exhausted");
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
const int maxAttempts = 5;
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var deadline = DateTime.UtcNow.AddSeconds(30);
var payload = new
{
name = "order-confirmed",
to = new { subscriberId = "sub_4920a", email = "ada@example.com" },
payload = new { orderId = "ORD-94102" },
};
var body = JsonSerializer.Serialize(payload);
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.nervly.io/v1/events/trigger");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Headers.Add("Idempotency-Key", "order-ORD-94102");
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request, cts.Token);
var responseBody = await response.Content.ReadAsStringAsync(cts.Token);
if ((int)response.StatusCode != 429)
{
Console.WriteLine(responseBody);
return;
}
var retryAfter = 0.0;
if (response.Headers.RetryAfter?.Delta is TimeSpan delta)
{
retryAfter = delta.TotalSeconds;
}
else if (response.Headers.RetryAfter?.Date is DateTimeOffset date)
{
// HTTP-date form: wait until that instant, clamped to zero.
retryAfter = Math.Max(0, (date - DateTimeOffset.UtcNow).TotalSeconds);
}
var ceiling = Math.Min(Math.Pow(2, attempt), 30);
var delay = Math.Min(retryAfter + Random.Shared.NextDouble() * ceiling, 30);
if (DateTime.UtcNow + TimeSpan.FromSeconds(delay) > deadline)
{
throw new TimeoutException("retry deadline exceeded");
}
await Task.Delay(TimeSpan.FromSeconds(delay), cts.Token);
}
throw new InvalidOperationException("rate limit retry budget exhausted");
}
}
require 'json'
require 'net/http'
require 'uri'
# Make SIGTERM as prompt as Ctrl-C: either one raises out of `sleep` and ends
# the retry loop instead of waiting for the backoff to elapse.
Signal.trap('INT') { raise Interrupt, 'cancelled' }
Signal.trap('TERM') { raise Interrupt, 'cancelled' }
payload = {
name: 'order-confirmed',
to: {
subscriberId: 'sub_4920a',
email: 'ada@example.com'
},
payload: { orderId: 'ORD-94102' }
}
max_attempts = 5
deadline = Time.now + 30
(0...max_attempts).each do |attempt|
uri = URI('https://api.nervly.io/v1/events/trigger')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'order-ORD-94102'
request.body = payload.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
unless response.code.to_i == 429
puts response.body
break
end
retry_after = response['Retry-After'].to_f
ceiling = [2**attempt, 30].min
delay = [retry_after + rand * ceiling, 30].min
raise 'retry deadline exceeded' if Time.now + delay > deadline
sleep(delay)
end
import Foundation
struct Recipient: Codable {
let subscriberId: String
let email: String
}
struct TriggerRequest: Codable {
let name: String
let to: Recipient
let payload: [String: String]
}
let payload = TriggerRequest(
name: "order-confirmed",
to: Recipient(subscriberId: "sub_4920a", email: "ada@example.com"),
payload: ["orderId": "ORD-94102"]
)
let body = try JSONEncoder().encode(payload)
// Run inside a Task so cancellation is cooperative: calling `retryTask.cancel()`
// stops the loop at the next Task.checkCancellation().
let retryTask = Task {
for attempt in 0..<5 {
try Task.checkCancellation()
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/events/trigger")!)
urlRequest.httpMethod = "POST"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("order-ORD-94102", forHTTPHeaderField: "Idempotency-Key")
urlRequest.httpBody = body
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else { continue }
if http.statusCode != 429 {
print(String(data: data, encoding: .utf8) ?? "")
return
}
let retryAfter = Double(http.value(forHTTPHeaderField: "Retry-After") ?? "0") ?? 0
let ceiling = min(pow(2.0, Double(attempt)), 30.0)
let delay = min(retryAfter + Double.random(in: 0...ceiling), 30.0)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
print("still rate limited after 5 attempts")
}
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
- Idempotency — safe retry strategies
- Quotas and billing
- Errors