Rate Limiting
The "depends" project implements rate limiting to protect the API from abuse and provides usage quotas through a dedicated endpoint for monitoring resource consumption. Rate limiting applies globally to API requests, keyed by client IP address, while usage statistics offer namespace-specific metrics for billing, auditing, and self-regulation. These features distinguish between self-hosted and hosted modes, with rate limiting bypassed for local development.
Rate Limiting Mechanism
Rate limiting uses a simple in-memory sliding-window counter per IP address. Each window spans 1 minute (WINDOW_MS = 60_000), allowing up to 120 requests (MAX_REQUESTS = 120) before rejection. Exceeding the limit returns a 429 "Too Many Requests" response with a Retry-After header indicating seconds until the window resets.
Stale windows (expired beyond resetAt) are pruned every 5 minutes via a background setInterval. The implementation stores windows in a Map, where keys are IP addresses and Window tracks count and resetAt.
interface Window {
count: number;
resetAt: number;
}
const windows = new Map<string, Window>();
const WINDOW_MS = 60_000; // 1 minute
const MAX_REQUESTS = 120; // per window
/** Prune stale entries every 5 minutes */
setInterval(() => {
const now = Date.now();
for (const [key, w] of windows) {
if (now > w.resetAt) windows.delete(key);
}
}, 5 * 60_000);
/**
* Returns a 429 Response if the IP has exceeded the limit, or null if allowed.
*/
export function rateLimit(ip: string): Response | null {
const now = Date.now();
let w = windows.get(ip);
if (!w || now > w.resetAt) {
w = { count: 0, resetAt: now + WINDOW_MS };
windows.set(ip, w);
}
w.count++;
if (w.count > MAX_REQUESTS) {
return Response.json(
{ error: "Rate limit exceeded. Try again shortly." },
{
status: 429,
headers: { "Retry-After": String(Math.ceil((w.resetAt - now) / 1000)) },
},
);
}
return null;
}
This design prioritizes simplicity and low overhead, suitable for the project's lightweight server. Limits are hardcoded, with no external configuration, reflecting a focus on default protection without user tuning.
Application in the Server
Rate limiting hooks into Elysia's onBeforeHandle middleware in src/server.ts, applied to all routes after public static files and before authentication. It extracts the client IP from X-Forwarded-For (first address) or the server's requestIP method (e.g., Cloudflare-specific).
Local requests—detected via self-hosted-mode.md or localhost IPs (127.0.0.1, ::1, etc.)—bypass limiting to support development and cli-commands.md usage.
.onBeforeHandle(({ request, server }) => {
if (isLocalRequest(request, server)) return;
const forwarded = request.headers.get("X-Forwarded-For");
const ip = forwarded
? forwarded.split(",")[0].trim()
: ((
server as { requestIP?(req: Request): { address: string } | null }
)?.requestIP?.(request)?.address ?? "unknown");
return rateLimit(ip) ?? undefined;
});
In hosted-mode.md, this protects public endpoints like /v1/signup and namespace-scoped routes under /v1/namespaces/:namespace.
Usage Quotas and Statistics
Usage tracking provides monthly quotas via the GET /v1/usage/:namespace endpoint, accessible after authentication.md. It computes statistics from the database-schema.md:
- Total nodes (
COUNT(*) FROM nodes). - Active nodes (distinct
node_idin events this month). - Total events this month.
- Webhook deliveries (rules with
urlandlast_fired_atthis month). - Emails sent (rules with
emailandlast_fired_atthis month).
The period uses the current UTC month-year (e.g., "2024-10").
export function handleGetUsage(
db: Database,
nsId: number,
namespace: string,
tokenId: number,
): Response {
const now = new Date();
const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
const activeNodes = db
.query(
`SELECT COUNT(DISTINCT node_id) as c FROM events WHERE ns_id = ? AND created_at >= datetime('now', 'start of month')`,
)
.get(nsId) as { c: number };
// ... similar queries for totalEvents, totalNodes, webhookDeliveries, emailsSent
const token = db
.query("SELECT email FROM tokens WHERE id = ?")
.get(tokenId) as { email: string | null } | null;
return Response.json({
email: token?.email ?? null,
namespace,
period,
nodes: totalNodes.c,
active_nodes: activeNodes.c,
total_events: totalEvents.c,
webhook_deliveries: webhookDeliveries.c,
emails_sent: emailsSent.c,
});
}
In hosted-mode.md, these metrics integrate with Legendum billing (via legendum_token in tokens table). Self-hosted users see stats for monitoring without enforcement.
CLI Integration
The depends usage command fetches and formats stats, supporting JSON output:
depends usage [namespace]
It displays totals like "Nodes: 5 total, 2 active this month" and conditional lines for webhooks/emails. See cli-commands.md for details.
Design Decisions
- IP-Based Limiting: Simple, stateless per request; no database overhead. Suited for burst protection, not strict per-user quotas.
- No Per-Namespace Limits: Global to API, complementing usage stats for granular billing.
- Self-Hosted Leniency: Bypasses align with self-hosted-mode.md for unlimited local use.
- Hardcoded Thresholds: Balances usability (120/min ≈ 2/sec) with protection; tunable via code in self-hosted forks.
- Monthly Stats: Aligns with billing cycles; queries use SQLite's efficient date functions and indexes.
These mechanisms ensure API stability while providing transparency into quotas, fitting the project's dual self-hosted/hosted architecture. For full endpoints, see api-reference.md.
Recent changes
- Created: New rate-limiting.md covers API rate limiting by IP and usage quotas