Rate Limiting
TinyKit Pro includes a comprehensive rate limiting system using @convex-dev/rate-limiter to protect against abuse, prevent excessive resource usage, and ensu...
TinyKit Pro includes a comprehensive rate limiting system using @convex-dev/rate-limiter to protect against abuse, prevent excessive resource usage, and ensure fair usage across all users.
🎯 Purpose
Rate limiting is essential for:
- Security: Prevent brute force attacks on authentication endpoints
- Resource Protection: Prevent excessive database writes and API calls
- Fair Usage: Ensure no single user can monopolize system resources
- Cost Control: Limit expensive operations (file uploads, external APIs)
- User Experience: Prevent accidental abuse from buggy client code
🏗️ Architecture
Rate Limiting Strategies
TinyKit Pro uses two rate limiting strategies from @convex-dev/rate-limiter:
1. Fixed Window
- How it works: Allows a fixed number of requests within a time period
- Reset behavior: Counter resets at the end of each period
- Use case: Strict limits for security-critical operations
- Example: Authentication attempts (10 attempts per hour)
{
kind: "fixed window",
rate: 10, // 10 requests
period: HOUR, // per hour
}2. Token Bucket
- How it works: Tokens refill at a constant rate up to a maximum capacity
- Burst capacity: Allows short bursts of activity beyond the base rate
- Use case: Operations that benefit from flexibility
- Example: Profile updates (5/min base rate, 10 token capacity)
{
kind: "token bucket",
rate: 5, // 5 tokens per minute
period: MINUTE, // refill period
capacity: 10, // max tokens (allows bursts)
}Key Difference: Token bucket allows accumulating "credits" for bursts, while fixed window enforces a strict limit.
📊 Pre-configured Rate Limits
TinyKit Pro includes seven pre-configured rate limits ready to use:
Authentication Operations
authAttempts: {
kind: "fixed window",
rate: 10,
period: HOUR,
}- Strategy: Fixed window
- Limit: 10 attempts per hour
- Use: Sign in, sign up, password reset
- Rationale: Strict limit prevents brute force attacks
Profile Updates
profileUpdates: {
kind: "token bucket",
rate: 5,
period: MINUTE,
capacity: 10,
}- Strategy: Token bucket
- Limit: 5 per minute (10 burst capacity)
- Use: Name, email, username, bio updates
- Rationale: Allows quick adjustments while preventing spam
Content Creation
contentCreation: {
kind: "token bucket",
rate: 20,
period: MINUTE,
capacity: 30,
}- Strategy: Token bucket
- Limit: 20 per minute (30 burst capacity)
- Use: Posts, comments, messages, reviews
- Rationale: Accommodates active users while preventing spam bots
Admin Operations
adminOperations: {
kind: "token bucket",
rate: 100,
period: HOUR,
capacity: 150,
}- Strategy: Token bucket
- Limit: 100 per hour (150 burst capacity)
- Use: User role updates, bulk operations, admin actions
- Rationale: Higher limits for trusted admin users
External API Calls
externalApiCalls: {
kind: "token bucket",
rate: 50,
period: HOUR,
capacity: 100,
}- Strategy: Token bucket
- Limit: 50 per hour (100 burst capacity)
- Use: Third-party API integrations, webhooks
- Rationale: Controls costs and prevents API quota exhaustion
File Uploads
fileUploads: {
kind: "token bucket",
rate: 10,
period: HOUR,
capacity: 20,
}- Strategy: Token bucket
- Limit: 10 per hour (20 burst capacity)
- Use: Avatar uploads, document uploads
- Rationale: Prevents storage abuse and bandwidth costs
Search Queries
searchQueries: {
kind: "token bucket",
rate: 30,
period: MINUTE,
capacity: 50,
}- Strategy: Token bucket
- Limit: 30 per minute (50 burst capacity)
- Use: Full-text search, autocomplete queries
- Rationale: Expensive database operations require throttling
🚀 Usage
Basic Rate Limiting
import { requireAccess, requireRateLimit } from "../../lib/access";
import { mutation } from "../../lib/triggers/triggers";
import { v } from "convex/values";
export const updateProfile = mutation({
args: { name: v.string() },
handler: async (ctx, args) => {
// 1. Authenticate and authorize
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// 2. Apply rate limit (throws on exceeded)
await requireRateLimit(ctx, "profileUpdates", { key: userId });
// 3. Perform the operation
await ctx.db.patch(userId, { name: args.name });
},
});Pattern: Always call requireRateLimit() AFTER requireAccess() but BEFORE the actual operation.
Global Rate Limiting
export const signUp = mutation({
args: { email: v.string(), password: v.string() },
handler: async (ctx, args) => {
// No key = global rate limit across all users
await requireRateLimit(ctx, "authAttempts");
// Create user account
const userId = await ctx.db.insert("users", {
email: args.email,
// ... other fields
});
return { userId };
},
});Use case: Protect public endpoints where no user is authenticated yet.
Variable Token Consumption
export const generateAIContent = mutation({
args: { prompt: v.string(), maxTokens: v.number() },
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Consume tokens based on estimated cost
const estimatedCost = Math.ceil(args.maxTokens / 100);
await requireRateLimit(ctx, "externalApiCalls", {
key: userId,
count: estimatedCost, // Consume multiple tokens at once
});
// Call external AI API
const result = await generateContent(args.prompt, args.maxTokens);
return result;
},
});Use case: Operations with variable costs (LLM tokens, batch operations).
Non-Throwing Rate Limits
export const checkUploadAvailability = mutation({
args: {},
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Check limit without throwing
const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
key: userId,
throws: false, // Don't throw on limit exceeded
});
if (!ok) {
return {
canUpload: false,
retryAfter,
retryAfterDate: retryAfter ? new Date(retryAfter).toISOString() : null,
};
}
return { canUpload: true };
},
});Use case: Check availability before showing UI, provide retry guidance.
⚙️ Advanced Patterns
Admin Operations with Rate Limiting
export const updateUserRole = mutation({
args: { userId: v.string(), userRole: v.string() },
handler: async (ctx, args) => {
const { userId: adminId } = await requireAccess(ctx, {
userRole: ["admin"],
});
// Rate limit admin operations (prevents accidental abuse)
await requireRateLimit(ctx, "adminOperations", { key: adminId });
await ctx.db.patch(args.userId, { userRole: args.userRole });
},
});Why rate limit admins?
- Prevents accidental bulk operations from buggy scripts
- Provides audit trail of excessive admin activity
- Protects against compromised admin accounts
File Upload with Rate Limiting
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Rate limit file uploads to prevent storage abuse
await requireRateLimit(ctx, "fileUploads", { key: userId });
// Generate and return an upload URL
return await ctx.storage.generateUploadUrl();
},
});Pattern: Rate limit the upload URL generation, not the actual file upload endpoint.
Checking Rate Limit Status
import { checkRateLimit } from "../../lib/access";
export const getRateLimitStatus = mutation({
args: {},
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Check status without consuming tokens (count: 0)
const uploadStatus = await checkRateLimit(ctx, "fileUploads", userId);
const profileStatus = await checkRateLimit(ctx, "profileUpdates", userId);
return {
uploads: {
available: uploadStatus.ok,
retryAfter: uploadStatus.retryAfter,
},
profile: {
available: profileStatus.ok,
retryAfter: profileStatus.retryAfter,
},
};
},
});Use case: Dashboard showing user's current rate limit status.
🔒 When to Use Rate Limiting
✅ Always Rate Limit
- Authentication operations (sign in, sign up, password reset)
- User-facing mutations (profile updates, content creation)
- File uploads (avatars, documents, media)
- External API calls (third-party integrations, webhooks)
- Admin operations (role changes, bulk actions)
- Search operations (expensive full-text queries)
❌ Don't Rate Limit
- Internal mutations (backend-only functions)
- Simple queries (unless computationally expensive)
- Triggered operations (database triggers, scheduled jobs)
- Webhook receivers (they have their own limits)
📚 Error Handling
Rate Limit Error Structure
When a rate limit is exceeded with throws: true, a ConvexError is thrown:
{
message: "Rate limit exceeded",
code: "RATE_LIMIT_EXCEEDED",
limitName: "profileUpdates",
retryAfter: 1704067200000, // Unix timestamp (ms)
retryAfterDate: "2024-01-01T00:00:00.000Z"
}Frontend Error Handling
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { toast } from "sonner";
function UpdateProfile() {
const updateProfile = useMutation(api.users.private.updateProfile);
const handleUpdate = async (name: string) => {
try {
await updateProfile({ name });
toast.success("Profile updated!");
} catch (error) {
if (error.data?.code === "RATE_LIMIT_EXCEEDED") {
const retryDate = new Date(error.data.retryAfter);
const minutes = Math.ceil((retryDate.getTime() - Date.now()) / 60000);
toast.error(`Rate limit exceeded. Try again in ${minutes} minute(s).`);
} else {
toast.error("Failed to update profile");
}
}
};
return <Button onClick={() => handleUpdate("New Name")}>Update</Button>;
}🛠️ Configuration
Modifying Rate Limits
All rate limits are defined in convex/lib/rateLimiter.ts:
export const RATE_LIMITS = {
profileUpdates: {
kind: "token bucket",
rate: 5, // Change this value
period: MINUTE,
capacity: 10, // Change burst capacity
},
// ... other limits
} as const;After changing limits:
- Redeploy Convex functions (
npx convex deploy) - Limits take effect immediately (no data migration needed)
Adding Custom Rate Limits
// 1. Add to RATE_LIMITS in convex/lib/rateLimiter.ts
export const RATE_LIMITS = {
// ... existing limits
customOperation: {
kind: "token bucket",
rate: 15,
period: MINUTE,
capacity: 25,
},
} as const;
// 2. Use in mutations
export const myCustomOperation = mutation({
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
await requireRateLimit(ctx, "customOperation", { key: userId });
// ... operation logic
},
});Resetting Rate Limits (Admin/Testing)
import { internalMutation } from "../../_generated/server";
import { resetRateLimit } from "../../lib/access";
import { v } from "convex/values";
export const resetUserRateLimit = internalMutation({
args: {
userId: v.string(),
limitName: v.string(),
},
handler: async (ctx, args) => {
await resetRateLimit(ctx, args.limitName as RateLimitName, args.userId);
},
});⚠️ Warning: Only use in testing or exceptional admin scenarios.
📊 API Reference
requireRateLimit()
function requireRateLimit(
ctx: MutationCtx,
limitName: RateLimitName,
options?: {
key?: string; // Subject identifier (default: global)
throws?: boolean; // Throw on exceeded (default: true)
count?: number; // Tokens to consume (default: 1)
},
): Promise<RateLimitResult>;Returns:
{
ok: boolean; // Whether operation is allowed
retryAfter?: number; // Unix timestamp when to retry (if !ok)
}Throws: ConvexError with code: "RATE_LIMIT_EXCEEDED" when limit exceeded and throws: true
checkRateLimit()
function checkRateLimit(
ctx: MutationCtx,
limitName: RateLimitName,
key?: string,
): Promise<RateLimitResult>;Behavior: Same as requireRateLimit() but with throws: false and count: 0 (doesn't consume tokens).
resetRateLimit()
function resetRateLimit(
ctx: MutationCtx,
limitName: RateLimitName,
key: string,
): Promise<void>;Use: Admin/testing functions only. Clears rate limit state for a specific key.
🧪 Testing
Manual Testing
// Test rate limit enforcement
const results = [];
for (let i = 0; i < 15; i++) {
try {
await updateProfile({ name: `Test ${i}` });
results.push({ attempt: i, success: true });
} catch (error) {
results.push({ attempt: i, success: false, error: error.message });
}
}
// Expected: First 10 succeed (burst capacity), next 5 failTesting with Non-Throwing Limits
export const testRateLimit = mutation({
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
const attempts = [];
for (let i = 0; i < 15; i++) {
const { ok, retryAfter } = await requireRateLimit(ctx, "profileUpdates", {
key: userId,
throws: false,
});
attempts.push({ attempt: i, ok, retryAfter });
// Wait a bit if rate limited
if (!ok && retryAfter) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return attempts;
},
});🎯 Best Practices
- Place after authentication: Always call
requireRateLimit()afterrequireAccess() - Use user-specific keys: Rate limit per user ID for fairness
- Choose appropriate strategy: Fixed window for security, token bucket for flexibility
- Set reasonable limits: Balance user experience with resource protection
- Handle errors gracefully: Show clear retry guidance to users
- Monitor usage: Track rate limit hits in production
- Test thoroughly: Verify limits work as expected before deploying
🚨 Common Mistakes
// ❌ DON'T: Rate limit before authentication
await requireRateLimit(ctx, "profileUpdates", { key: "unknown" });
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// ✅ DO: Authenticate first, then rate limit
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
await requireRateLimit(ctx, "profileUpdates", { key: userId });
// ❌ DON'T: Use global rate limit for user operations
await requireRateLimit(ctx, "profileUpdates"); // No key = global
// ✅ DO: Use user-specific rate limiting
await requireRateLimit(ctx, "profileUpdates", { key: userId });
// ❌ DON'T: Ignore rate limit errors
try {
await requireRateLimit(ctx, "fileUploads", { key: userId });
} catch (error) {
// Silently ignore - user doesn't know why operation failed
}
// ✅ DO: Handle and communicate rate limit errors
const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
key: userId,
throws: false,
});
if (!ok) {
throw new ConvexError({
message: `Upload limit exceeded. Try again at ${new Date(retryAfter!).toLocaleTimeString()}`,
code: "RATE_LIMIT_EXCEEDED",
});
}📚 Related Documentation
- Access Control - Using
requireAccess()for authentication - Logging - Structured logging for debugging
- Convex Best Practices - Optimizing database operations
Logging System
TinyKit Pro includes a comprehensive logging system with environment-based log levels, structured output, and performance timing capabilities. The logging sy...
Database Triggers
TinyKit Pro uses convex-helpers/server/triggers to automatically maintain data integrity and consistency across database operations. The trigger system inter...