TinyKit Pro Docs

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:

  1. Redeploy Convex functions (npx convex deploy)
  2. 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 fail

Testing 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

  1. Place after authentication: Always call requireRateLimit() after requireAccess()
  2. Use user-specific keys: Rate limit per user ID for fairness
  3. Choose appropriate strategy: Fixed window for security, token bucket for flexibility
  4. Set reasonable limits: Balance user experience with resource protection
  5. Handle errors gracefully: Show clear retry guidance to users
  6. Monitor usage: Track rate limit hits in production
  7. 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",
  });
}

← Previous: Three-Tier Security | Next: Logging →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro