UNPKG

convex-helpers

Version:

A collection of useful code to complement the official convex package.

321 lines (298 loc) 12.5 kB
/** * Rate limiting helper. * Note: this is now a Component I recommend you use instead: * [`@convex-dev/rate-limiter` component](https://www.convex.dev/components/rate-limiter) * Also see the associated Stack post for details: * https://stack.convex.dev/rate-limiting * ## Usage for this helper: ```ts import { defineRateLimits } from "convex-helpers/server/rateLimit"; const SECOND = 1000; // ms const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; export const { checkRateLimit, rateLimit, resetRateLimit } = defineRateLimits({ // A per-user limit, allowing one every ~6 seconds. // Allows up to 3 in quick succession if they haven't sent many recently. sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 }, // One global / singleton rate limit freeTrialSignUp: { kind: "fixed window", rate: 100, period: HOUR }, }); ``` And add the rate limit table to your schema: ```ts // in convex/schema.ts import { rateLimitTables } from "./rateLimit.js"; export default defineSchema({ ...rateLimitTables, otherTable: defineTable({}), // other tables }); ``` If you don't care about centralizing the configuration and type safety on the rate limit names, you don't have to use `defineRateLimits`, and can inline the config: ```ts import { checkRateLimit, rateLimit, resetRateLimit } from "./rateLimit.js"; //... await rateLimit(ctx, { name: "callLLM", count: numTokens, config: { kind: "fixed window", rate: 40000, period: DAY }, });, ``` You also don't have to define all of your rate limits in one place. You can use `defineRateLimits` multiple times. ### Strategies: The **`token bucket`** approach provides guarantees for overall consumption via the `rate` per `period` at which tokens are added, while also allowing unused tokens to accumulate (like "rollover" minutes) up to some `capacity` value. So if you could normally send 10 per minute, with a capacity of 20, then every two minutes you could send 20, or if in the last two minutes you only sent 5, you can send 15 now. The **`fixed window`** approach differs in that the tokens are granted all at once, every `period` milliseconds. It similarly allows accumulating "rollover" tokens up to a `capacity` (defaults to the `rate` for both rate limit strategies). ### Reserving capacity: You can also allow it to "reserve" capacity to avoid starvation on larger requests. Details in the [Stack post](https://stack.convex.dev/rate-limiting). ### To use a simple global rate limit: ```ts const { ok, retryAt } = await rateLimit(ctx, { name: "freeTrialSignUp" }); ``` - `ok` is whether it successfully consumed the resource - `retryAt` is when it would have succeeded in the future. **Note**: If you have many clients using the `retryAt` to decide when to retry, defend against a [thundering herd](https://en.wikipedia.org/wiki/Thundering_herd_problem) by adding some [jitter](https://stack.convex.dev/rate-limiting#jitter-introducing-randomness-to-avoid-thundering-herds). Or use the reserved functionality discussed in the [Stack post](https://stack.convex.dev/rate-limiting). ### To use a per-user rate limit: ```ts await rateLimit(ctx, { name: "createEvent", key: userId, count: 5, throws: true, }); ``` - `key` is a rate limit specific to some user / team / session ID / etc. - `count` is how many to consume (default is 1) - `throws` configures it to throw a `ConvexError` with `RateLimitError` data instead of returning when `ok` is false. */ import { v } from "convex/values"; import { ConvexError } from "convex/values"; import { defineTable } from "convex/server"; export function isRateLimitError(error) { return error instanceof ConvexError && error.data["kind"] === "RateLimited"; } export const RateLimitTable = "rateLimits"; /** * The table for rate limits to be added to your schema. * e.g.: * ```ts * export default defineSchema({ * ...rateLimitTables, * otherTable: defineTable({...}), * // other tables * }) * ``` * This is necessary as the rate limit implementation uses an index. */ export const rateLimitTables = { [RateLimitTable]: defineTable({ name: v.string(), key: v.optional(v.string()), // undefined is singleton value: v.number(), // can go negative if capacity is reserved ahead of time ts: v.number(), }).index("name", ["name", "key"]), }; /** * * @param limits The rate limits to define. The key is the name of the rate limit. * See {@link RateLimitConfig} for more information. * @returns { checkRateLimit, rateLimit, resetRateLimit } * See {@link checkRateLimit}, {@link rateLimit}, and {@link resetRateLimit} for * more information on their usage. They will be typed based on the limits you * provide, so the names will auto-complete, and you won't need to specify the * config inline. */ export function defineRateLimits(limits) { return { /** * See {@link checkRateLimit} for more information. * This function will be typed based on the limits you provide, so the names * will auto-complete, and you won't need to specify the config inline. */ checkRateLimit: async ({ db }, args) => { const config = ("config" in args && args.config) || limits[args.name]; if (!config) { throw new Error(`Rate limit ${args.name} not defined.`); } return checkRateLimit({ db }, { ...args, config }); }, /** * See {@link rateLimit} for more information. This function will be typed * based on the limits you provide, so the names will auto-complete, and you * won't need to specify the config inline. * * @param ctx The ctx object from a mutation, including a database writer. * @param args The arguments for rate limiting. If the name doesn't match a * rate limit you defined, you must provide the config inline. * @returns { ok, retryAt }: `ok` is true if the rate limit is not exceeded. * `retryAt` is the time in milliseconds when retrying could succeed. * If `reserve` is true, `retryAt` is the time you must schedule the * work to be done. */ rateLimit: async (ctx, args) => { const config = ("config" in args && args.config) || limits[args.name]; if (!config) { throw new Error(`Rate limit ${args.name} not defined.`); } return rateLimit(ctx, { ...args, config }); }, /** * See {@link resetRateLimit} for more information. This function will be * typed based on the limits you provide, so the names will auto-complete. * @param ctx The ctx object from a mutation, including a database writer. * @param args The name of the rate limit to reset. If a key is provided, it * will reset the rate limit for that key. If not, it will reset the rate * limit for the shared value. * @returns */ resetRateLimit: async (ctx, args) => { return resetRateLimit(ctx, args); }, }; } /** * Rate limit a request. * This function will check the rate limit and return whether the request is * allowed, and if not, when it could be retried. * * @param ctx A ctx object from a mutation, including a database writer. * @param args The arguments for rate limiting. * @param args.name The name of the rate limit. * @param args.key The key to use for the rate limit. If not provided, the rate * limit is a single shared value. * @param args.count The number of tokens to consume. Defaults to 1. * @param args.reserve Whether to reserve the tokens ahead of time. * Defaults to false. * @param args.throws Whether to throw an error if the rate limit is exceeded. * By default, {@link rateLimit} will just return { ok: false, retryAt: number } * @returns { ok, retryAt }: `ok` is true if the rate limit is not exceeded. * `retryAt` is the time in milliseconds when retrying could succeed. */ export async function rateLimit(ctx, args) { const status = await checkRateLimit(ctx, args); const { ok, retryAt } = status; if (ok) { const { ts, value } = status; const existing = await getExisting(ctx.db, args.name, args.key); if (existing) { await ctx.db.patch(existing._id, { ts, value }); } else { const { name, key } = args; await ctx.db.insert(RateLimitTable, { name, key, ts, value }); } } return { ok, retryAt }; } /** * Check a rate limit. * This function will check the rate limit and return whether the request is * allowed, and if not, when it could be retried. * Unlike {@link rateLimit}, this function does not consume any tokens. * * @param ctx A ctx object from a mutation, including a database writer. * @param args The arguments for rate limiting. * @param args.name The name of the rate limit. * @param args.key The key to use for the rate limit. If not provided, the rate * limit is a single shared value. * @param args.count The number of tokens to consume. Defaults to 1. * @param args.reserve Whether to reserve the tokens ahead of time. Defaults to * false. * @param args.throws Whether to throw an error if the rate limit is exceeded. * By default, {@link rateLimit} will just return { ok: false, retryAt: number } * @returns { ok, retryAt, ts, value }: `ok` is true if the rate limit is not * exceeded. `retryAt` is the time in milliseconds when retrying could succeed. */ export async function checkRateLimit(ctx, args) { const config = args.config; const now = Date.now(); const existing = await getExisting(ctx.db, args.name, args.key); const max = config.capacity ?? config.rate; const consuming = args.count ?? 1; if (args.reserve) { if (config.maxReserved && consuming > max + config.maxReserved) { throw new Error(`Rate limit ${args.name} count ${consuming} exceeds ${max + config.maxReserved}.`); } } else if (consuming > max) { throw new Error(`Rate limit ${args.name} count ${consuming} exceeds ${max}.`); } const state = existing ?? { value: max, ts: config.kind === "fixed window" ? (config.start ?? Math.floor(Math.random() * config.period)) : now, }; let ts, value, retryAt = undefined; if (config.kind === "token bucket") { const elapsed = now - state.ts; const rate = config.rate / config.period; value = Math.min(state.value + elapsed * rate, max) - consuming; ts = now; if (value < 0) { retryAt = now + -value / rate; } } else { const elapsedWindows = Math.floor((Date.now() - state.ts) / config.period); value = Math.min(state.value + config.rate * elapsedWindows, max) - consuming; ts = state.ts + elapsedWindows * config.period; if (value < 0) { const windowsNeeded = Math.ceil(-value / config.rate); retryAt = ts + config.period * windowsNeeded; } } if (value < 0) { if (!args.reserve || (config.maxReserved && -value > config.maxReserved)) { if (args.throws) { throw new ConvexError({ kind: "RateLimited", name: args.name, retryAt, }); } return { ok: false, retryAt }; } } return { ok: true, retryAt, ts, value }; } /** * Reset a rate limit. This will remove the rate limit from the database. * The next request will start fresh. * Note: In the case of a fixed window without a specified `start`, * the new window will be a random time. * @param ctx A ctx object from a mutation, including a database writer. * @param args The name of the rate limit to reset. If a key is provided, it will * reset the rate limit for that key. If not, it will reset the rate limit for * the shared value. */ export async function resetRateLimit(ctx, args) { const existing = await getExisting(ctx.db, args.name, args.key); if (existing) { await ctx.db.delete(existing._id); } } // Helper to get the existing value for a rate limit. async function getExisting(db, name, key) { return db .query(RateLimitTable) .withIndex("name", (q) => q.eq("name", name).eq("key", key)) .unique(); }