Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 136 additions & 120 deletions apps/content/docs/helpers/ratelimit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@ npm install @orpc/ratelimit@beta

## Basic Usage

The core concept is the `RateLimiter` interface, which defines a standard way to check and enforce rate limits. You can create your own custom limiter or use one of the provided adapters for popular storage backends. The `limit` method accepts a key and an optional `weight` value, which defaults to `1`, so a single request can consume multiple points.
The core concept is the `RateLimiter` interface, which defines a standard way to check and enforce rate limits. You can create your own custom limiter or use one of the provided [adapters](#adapters):

| Name | Blocking Mode | Adapter for |
| -------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- |
| [`MemoryRateLimiter`](#memory) | ✅ | In-memory storage |
| [`RedisRateLimiter`](#redis) | ✅ | [Redis](https://github.com/redis/redis) |
| [`UpstashRateLimiter`](#upstash) | ✅ | [Upstash Rate Limit](https://www.npmjs.com/package/@upstash/ratelimit) |
| [`BunRedisRateLimiter`](#bun-redis) | ✅ | [Bun's Redis](https://bun.com/docs/runtime/redis) |
| [`CloudflareRateLimiter`](#cloudflare) | ❌ | [Cloudflare's Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) |

The `limit` method accepts a key and an optional `weight` value, which defaults to `1`, so a single request can consume multiple points.

```ts twoslash
import { MemoryRateLimiter } from '@orpc/ratelimit/memory'
Expand All @@ -38,22 +48,114 @@ if (!result.success) {
}
```

## Adapters
### Blocking Mode

Some adapters support blocking mode, which waits until capacity becomes available instead of rejecting requests immediately.

```ts
const limiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
blockingUntilReady: {
enabled: true, // Disabled by default
timeout: 5000, // Wait up to 5 seconds
},
})
```

The package includes adapters for multiple storage backends and runtimes.
Each adapter might require `maxRequests` and `window` to configure the limit, along with adapter specific options.
## Ratelimit Middleware

| Name | Blocking Mode | Adapter for |
| ----------------------- | ------------- | --------------------------------------------------------------------------------------------------------- |
| `MemoryRateLimiter` | ✅ | In-memory storage |
| `RedisRateLimiter` | ✅ | [Redis](https://github.com/redis/redis) |
| `UpstashRateLimiter` | ✅ | [Upstash Rate Limit](https://www.npmjs.com/package/@upstash/ratelimit) |
| `BunRedisRateLimiter` | ✅ | [Bun's Redis](https://bun.com/docs/runtime/redis) |
| `CloudflareRateLimiter` | ❌ | [Cloudflare's Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) |
The `ratelimit` helper creates middleware that enforces rate limits for [procedures](/docs/procedure).

<CodeGroup>
```ts
import { ratelimit, RateLimiter } from '@orpc/ratelimit'

```ts memory
const procedure = os
.$context<{ ratelimiter: RateLimiter }>()
.input(z.object({ email: z.email() }))
.use(
ratelimit({
limiter: ({ context }) => context.ratelimiter,
key: ({ context }, input) => `login:${input.email}`,
weight: 1, // Optional weight for each request, default is 1
}),
)
.handler(({ input }) => {
return { success: true }
})

const ratelimiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
})

const result = await call(
procedure,
{ email: 'user@example.com' },
{ context: { ratelimiter } }
)
```

:::info[Automatic Deduplication]
When the same `limiter` and `key` combination is used multiple times in a single request chain, the `ratelimit` middleware performs the rate limit check only once. This behavior follows the [Dedupe Middleware](/docs/recipes/dedupe-middleware) recipe. To disable deduplication, set `dedupe: false`.
:::

:::tip[Conditional Limiter]
You can choose different limiters dynamically based on the request context:

```ts
const premiumLimiter = new MemoryRateLimiter({
maxRequests: 100,
window: 60000,
})

const standardLimiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
})

const result = await call(
procedure,
{ email: 'user@example.com' },
{
context: {
ratelimiter: isPremiumUser ? premiumLimiter : standardLimiter,
},
},
)
```

:::

## Handler Plugin

The `RateLimitHandlerPlugin` automatically adds HTTP rate limiting headers (`RateLimit-*` and `Retry-After`) to responses when used with [Ratelimit Middleware](#ratelimit-middleware). This lets clients inspect the current limit state and know when they can retry after hitting a limit.

```ts
import { RateLimitHandlerPlugin } from '@orpc/ratelimit'

const handler = new RPCHandler(router, {
plugins: [
new RateLimitHandlerPlugin(),
],
})
```

:::info
You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate limiting headers.
:::

:::info
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
:::

## Adapters

### Memory

Keeps counters in the memory of the current process, so limits are not shared between instances. A good fit for development, tests, and single-process servers.

```ts
import { MemoryRateLimiter } from '@orpc/ratelimit/memory'

const limiter = new MemoryRateLimiter({
Expand Down Expand Up @@ -83,10 +185,15 @@ const limiter = new MemoryRateLimiter({
})
```

```ts redis
### Redis

Stores counters in Redis, so every instance using the same server enforces the same limits. Works with both standalone and cluster clients.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cluster support claim here has two sharp edges the docs don't mention. On node-redis 6.0.x/6.1.x — still inside the >=6.0.0 peer range — SCRIPT LOAD isn't fanned out to every master, so the NOSCRIPT reload-retry can reload onto the wrong shard and still fail; and on a cluster, concurrent first limit() calls before connect() can fail with "The client is offline" because isOpen flips before topology discovery finishes (the example below calls connect() for this reason, but describes it as optional). Consider scoping the support claim or noting the minimum node-redis version.

Technical details
# Cluster support has undocumented caveats

## Affected sites
- `apps/content/docs/helpers/ratelimit.mdx:190` — claims "Works with both standalone and cluster clients" unconditionally
- `packages/ratelimit/src/adapters/redis.ts:123``executeScript` reload-retry depends on `SCRIPT LOAD` reaching the shard that owns the key
- `packages/ratelimit/package.json:69``"redis": ">=6.0.0"` admits versions with single-node `SCRIPT LOAD` routing

## Required outcome
- The documented cluster support is accurate across the allowed `redis` peer range, or the adapter is made shard-safe on 6.0.x/6.1.x.

## Suggested approach (optional)
- Add a short note that cluster use requires node-redis >= 6.2 (where `SCRIPT LOAD` fans out to all masters), or
- On `NOSCRIPT`, fall back to `EVAL` with the script body instead of re-running `scriptLoad` + `evalSha`: `EVAL` routes by the single key to the owning shard and loads the script there, sidestepping the per-node cache problem on every supported version.

## Open questions for the human
- Should clusters be supported across the whole `>=6.0.0` peer range, or is raising the peer floor to 6.2 acceptable?


```ts
import { RedisRateLimiter } from '@orpc/ratelimit/redis'
import { createClient } from 'redis'

// Both standalone (`createClient`) and cluster (`createCluster`) clients are supported.
const client = createClient({ url: 'redis://localhost:6379' })

// RedisRateLimiter lazily connects to Redis when needed.
Expand Down Expand Up @@ -127,7 +234,11 @@ const limiter = new RedisRateLimiter(client, {
})
```

````ts upstash
### Upstash

Delegates to an `@upstash/ratelimit` instance, so the algorithm and limits are configured there. A good fit for serverless and edge runtimes.

````ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { UpstashRateLimiter } from '@orpc/ratelimit/upstash'
Expand Down Expand Up @@ -170,7 +281,11 @@ const limiter = new UpstashRateLimiter(ratelimit, {
})
````

```ts bun
### Bun Redis

The Redis adapter for Bun's built-in Redis client, with no extra dependency. It shares counters with `RedisRateLimiter`.

```ts
import { BunRedisRateLimiter } from '@orpc/bun'
import { redis } from 'bun'

Expand Down Expand Up @@ -208,7 +323,11 @@ const limiter = new BunRedisRateLimiter(redis, {
})
```

```ts cloudflare
### Cloudflare

Uses the Workers Rate Limiting binding, so limits are configured in your Worker settings rather than in the adapter.

```ts
import { CloudflareRateLimiter } from '@orpc/cloudflare'

export default {
Expand All @@ -224,106 +343,3 @@ export default {
}
}
```

</CodeGroup>

### Blocking Mode

Some adapters support blocking mode, which waits until capacity becomes available instead of rejecting requests immediately.

```ts
const limiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
blockingUntilReady: {
enabled: true, // Disabled by default
timeout: 5000, // Wait up to 5 seconds
},
})
```

## Ratelimit Middleware

The `ratelimit` helper creates middleware that enforces rate limits for [procedures](/docs/procedure).

```ts
import { ratelimit, RateLimiter } from '@orpc/ratelimit'

const procedure = os
.$context<{ ratelimiter: RateLimiter }>()
.input(z.object({ email: z.email() }))
.use(
ratelimit({
limiter: ({ context }) => context.ratelimiter,
key: ({ context }, input) => `login:${input.email}`,
weight: 1, // Optional weight for each request, default is 1
}),
)
.handler(({ input }) => {
return { success: true }
})

const ratelimiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
})

const result = await call(
procedure,
{ email: 'user@example.com' },
{ context: { ratelimiter } }
)
```

:::info[Automatic Deduplication]
When the same `limiter` and `key` combination is used multiple times in a single request chain, the `ratelimit` middleware performs the rate limit check only once. This behavior follows the [Dedupe Middleware](/docs/recipes/dedupe-middleware) recipe. To disable deduplication, set `dedupe: false`.
:::

:::tip[Conditional Limiter]
You can choose different limiters dynamically based on the request context:

```ts
const premiumLimiter = new MemoryRateLimiter({
maxRequests: 100,
window: 60000,
})

const standardLimiter = new MemoryRateLimiter({
maxRequests: 10,
window: 60000,
})

const result = await call(
procedure,
{ email: 'user@example.com' },
{
context: {
ratelimiter: isPremiumUser ? premiumLimiter : standardLimiter,
},
},
)
```

:::

## Handler Plugin

The `RateLimitHandlerPlugin` automatically adds HTTP rate limiting headers (`RateLimit-*` and `Retry-After`) to responses when used with [Ratelimit Middleware](#ratelimit-middleware). This lets clients inspect the current limit state and know when they can retry after hitting a limit.

```ts
import { RateLimitHandlerPlugin } from '@orpc/ratelimit'

const handler = new RPCHandler(router, {
plugins: [
new RateLimitHandlerPlugin(),
],
})
```

:::info
You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate limiting headers.
:::

:::info
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
:::
6 changes: 3 additions & 3 deletions packages/ratelimit/src/adapters/redis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RedisClientType } from 'redis'
import type { RedisClientType, RedisClusterType } from 'redis'
import type { RateLimiter, RateLimitOptions, RateLimitResult } from '../types'
import { sleep } from '@orpc/shared'

Expand Down Expand Up @@ -63,7 +63,7 @@ export interface RedisRateLimiterOptions {
* @see {@link https://orpc.dev/docs/helpers/ratelimit#adapters | Rate Limit Helpers - Adapters}
*/
export class RedisRateLimiter implements RateLimiter {
private readonly redis: RedisClientType<any, any, any, any, any>
private readonly redis: RedisClientType<any, any, any, any, any> | RedisClusterType<any, any, any, any, any>
private readonly prefix: string
private readonly maxRequests: number
private readonly window: number
Expand All @@ -72,7 +72,7 @@ export class RedisRateLimiter implements RateLimiter {
private scriptSha: undefined | Awaited<ReturnType<typeof this.redis.scriptLoad>>

constructor(
redis: RedisClientType<any, any, any, any, any>,
redis: RedisClientType<any, any, any, any, any> | RedisClusterType<any, any, any, any, any>,
options: RedisRateLimiterOptions,
) {
this.redis = redis
Expand Down
Loading