UNPKG

@convex-dev/rate-limiter

Version:

A rate limiter component for Convex. Define and use application-layer rate limits. Type-safe, transactional, fair, safe, and configurable sharding to scale.

334 lines (321 loc) 11.3 kB
import { type Expand, type FunctionArgs, type FunctionReference, type FunctionReturnType, type GenericDataModel, type GenericQueryCtx, mutationGeneric, queryGeneric, } from "convex/server"; import { ConvexError, v } from "convex/values"; import type { ComponentApi } from "../component/_generated/component.js"; import type { RateLimitArgs, RateLimitConfig, RateLimitError, RateLimitReturns, GetValueReturns, } from "../shared.js"; import { getValueArgs, getValueReturns } from "../shared.js"; export { calculateRateLimit } from "../shared.js"; export type { RateLimitArgs, RateLimitConfig, RateLimitError, RateLimitReturns, }; export const SECOND = 1000; export const MINUTE = 60 * SECOND; export const HOUR = 60 * MINUTE; export const DAY = 24 * HOUR; export const WEEK = 7 * DAY; export function isRateLimitError( error: unknown, ): error is { data: RateLimitError } { return ( error instanceof ConvexError && (error as any).data["kind"] === "RateLimited" ); } /** * Define rate limits for a set of named rate limits. * e.g. * ```ts * import { RateLimiter } from "@convex-dev/rate-limiter"; * import { components } from "./_generated/api.js"; * * const rateLimiter = new RateLimiter(components.rateLimiter, { * // 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 }, * }); * //... elsewhere * await rateLimiter.limit(ctx, "sendMessage", { key: ctx.userId, throws: true }); * ``` * * @param component The rate limiter component. Like `components.rateLimiter`. * Imported like `import { components } from "./_generated/api.js";` * @param limits The rate limits to define. The key is the name of the rate limit. * See {@link RateLimitConfig} for more information. * @returns A rate limiter that has types based on the provided limits. * If you provide a different name, you will need to provide the config inline. */ export class RateLimiter< Limits extends Record<string, RateLimitConfig> = Record<never, never>, > { constructor( public component: ComponentApi, public limits?: Limits, ) {} /** * 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 limit}, this function does not consume any tokens. * * @param ctx The ctx object from a query or mutation, including runQuery. * @param name The name of the rate limit. * @param options The rate limit arguments. `config` is required if the rate * limit was not defined in {@link RateLimiter}. See {@link RateLimitArgs}. * @returns `{ ok, retryAfter }`: `ok` is true if the rate limit is not exceeded. * `retryAfter` is the duration in milliseconds when retrying could succeed. * If `reserve` is true, `ok` is true if there's enough capacity including * reservation. If there is a maxiumum reservation limit, `ok` will be false * when it is exceeded. When `ok` is true and `retryAfter` is defined, it is * the duration you must wait before executing the work. * e.g.: * ```ts * if (status.retryAfter) { * await ctx.scheduler.runAfter(retryAfter, ...) * ``` */ async check<Name extends string = keyof Limits & string>( ctx: RunQueryCtx, name: Name, ...options: Name extends keyof Limits & string ? [WithKnownNameOrInlinedConfig<Limits, Name, RateLimitArgs>?] : [WithKnownNameOrInlinedConfig<Limits, Name, RateLimitArgs>] ): Promise<RateLimitReturns> { return ctx.runQuery(this.component.lib.checkRateLimit, { ...options[0], name, config: this.getConfig(options[0], name), }); } /** * 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 The ctx object from a mutation, including runMutation. * @param name The name of the rate limit. * @param options The rate limit arguments. `config` is required if the rate * limit was not defined in {@link RateLimiter}. See {@link RateLimitArgs}. * @returns `{ ok, retryAfter }`: `ok` is true if the rate limit is not exceeded. * `retryAfter` is the duration in milliseconds when retrying could succeed. * If `reserve` is true, `ok` is true if there's enough capacity including * reservation. If there is a maxiumum reservation limit, `ok` will be false * when it is exceeded. When `ok` is true and `retryAfter` is defined, it is * the duration you must wait before executing the work. * e.g.: * ```ts * if (status.retryAfter) { * await ctx.scheduler.runAfter(retryAfter, ...) * ``` */ async limit<Name extends string = keyof Limits & string>( ctx: RunMutationCtx, name: Name, ...options: Name extends keyof Limits & string ? [WithKnownNameOrInlinedConfig<Limits, Name, RateLimitArgs>?] : [WithKnownNameOrInlinedConfig<Limits, Name, RateLimitArgs>] ): Promise<RateLimitReturns> { return ctx.runMutation(this.component.lib.rateLimit, { ...options[0], name, config: this.getConfig(options[0], name), }); } /** * 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 The ctx object from a mutation, including runMutation. * @param name The name of the rate limit to reset, including all shards. * @param key 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. */ async reset<Name extends string = keyof Limits & string>( { runMutation }: RunMutationCtx, name: Name, args?: { key?: string }, ): Promise<void> { await runMutation(this.component.lib.resetRateLimit, { ...(args ?? null), name, }); } /** * Get the current value and metadata of a rate limit. * This function returns the current token utilization data without consuming any tokens. * * @param ctx The ctx object from a query, including runQuery. * @param name The name of the rate limit. * @param options The rate limit arguments. `config` is required if the rate * limit was not defined in {@link RateLimiter}. See {@link RateLimitArgs}. * @returns An object containing the current value, timestamp, window start time (for fixed window), * and the rate limit configuration. */ async getValue<Name extends string = keyof Limits & string>( ctx: RunQueryCtx, name: Name, ...options: Name extends keyof Limits & string ? [ WithKnownNameOrInlinedConfig< Limits, Name, { key?: string; sampleShards?: number; } >?, ] : [ WithKnownNameOrInlinedConfig< Limits, Name, { key?: string; sampleShards?: number; } >, ] ): Promise<GetValueReturns> { return ctx.runQuery(this.component.lib.getValue, { ...options[0], name, config: this.getConfig(options[0], name), }); } /** * Creates a public query that can be exported from your API that returns the * current value of a rate limit. * This is a convenience function to re-export the query for client use. * * @param name The name of the rate limit. * @returns An object containing a getRateLimit function that can be exported. * * Example: * ```ts * // In your API file: * export const getRateLimit = rateLimiter.getValueQuery("myLimit"); * * // In your client: * const { status, getValue, retryAt } = useRateLimit(api.getRateLimit, 10); * ``` */ hookAPI< DataModel extends GenericDataModel, Name extends string = keyof Limits & string, >( name: Name, ...options: Name extends keyof Limits ? [WithKnownNameOrInlinedConfig<Limits, Name, HookOpts<DataModel>>?] : [WithKnownNameOrInlinedConfig<Limits, Name, HookOpts<DataModel>>] ) { return { getRateLimit: queryGeneric({ args: getValueArgs, returns: getValueReturns, handler: async (ctx, args): Promise<GetValueReturns> => { const finalName = args.name ?? name; const { key: keyOrFn, ...rest } = options[0] ?? {}; let key: string | undefined; if (args.key && !keyOrFn) { throw new Error( "To allow client-provided key, provide a `key` function in the hook options.", ); } if (typeof keyOrFn === "function") { key = await keyOrFn(ctx, args.key); } else if (keyOrFn !== undefined) { key = keyOrFn; } return ctx.runQuery(this.component.lib.getValue, { ...rest, ...args, key, name: finalName, config: args.config ?? this.getConfig(options[0], finalName), }); }, }), getServerTime: mutationGeneric({ args: {}, returns: v.number(), handler: async () => { return Date.now(); }, }), }; } private getConfig<Name extends string, Args>( args: WithKnownNameOrInlinedConfig<Limits, Name, Args> | undefined, name: Name, ): RateLimitConfig { const config = (args && "config" in args && args.config) || (this.limits && this.limits[name]); if (!config) { throw new Error( `Rate limit ${name} not defined. ` + `You must provide a config inline or define it in the constructor.`, ); } return config; } } export default RateLimiter; // Type utilities export type RunQueryCtx = { runQuery: <Query extends FunctionReference<"query", "internal">>( query: Query, args: FunctionArgs<Query>, ) => Promise<FunctionReturnType<Query>>; }; export type RunMutationCtx = RunQueryCtx & { runMutation: <Mutation extends FunctionReference<"mutation", "internal">>( mutation: Mutation, args: FunctionArgs<Mutation>, ) => Promise<FunctionReturnType<Mutation>>; }; type WithKnownNameOrInlinedConfig< Limits extends Record<string, RateLimitConfig>, Name extends string, Args, > = Expand< Omit<Args, "name" | "config"> & (Name extends keyof Limits ? object : { /** The rate limit configuration, if specified inline. * If you use {@link RateLimits} to define the named rate limit, you don't * specify the config inline.} */ config: RateLimitConfig; }) >; type HookOpts<DataModel extends GenericDataModel> = { key?: | string | (( ctx: GenericQueryCtx<DataModel>, // The key provided by the client, if any. keyFromClient?: string, ) => string | Promise<string>); sampleShards?: number; };