ExperimentalRate limits, locks and durable calls might change or go away in any release. Stability
Rate limiting
experimental_rateLimit() counts calls with Effect's RateLimiter, in memory, Redis, Upstash or Durable Objects, and sets the RateLimit headers.
On this page
experimental_rateLimit() counts calls with Effect's RateLimiter (effect/persistence). The store is a layer on the handler: layerStoreMemory for one instance, layerStoreRedis for many.
import { RateLimiter } from 'effect/persistence';
import { experimental_rateLimit } from '@trpc12/trpc/middleware';
const login = t.procedure
.use(
experimental_rateLimit<{ ctx: { ip: string } }>({
key: (opts) => `login:${opts.ctx.ip}`,
limit: 5,
window: '1 minute',
}),
)
.mutation(/* … */);
createFetchHandler({
router,
layer: RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)),
});Past the limit it fails with experimental_RateLimited (TOO_MANY_REQUESTS, 429, data: { limit, retryAfter } in milliseconds), and it sets RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and Retry-After. onExceeded: 'delay' waits for capacity instead.
Stores
The store is Effect's RateLimiterStore, under RateLimiter.layer. A store that fails (the network, the database) is a defect: the call fails with a masked 500, not the caller's 429.
| Store | From | Counts across |
|---|---|---|
RateLimiter.layerStoreMemory | effect/persistence | one process |
RateLimiter.layerStoreRedis() | effect/persistence | everything sharing the Redis |
experimental_upstashRateLimiterStore() | @trpc12/trpc/middleware | everything sharing the database |
experimental_durableRateLimiterStore(binding) | @trpc12/trpc/cloudflare | every Worker instance |
layerStoreRedis() needs Effect's Redis service, e.g. NodeRedis.layer({ url }) from @effect/platform-node.
Upstash
experimental_upstashRateLimiterStore({ url, token }) is Effect's Redis store over Upstash's REST API, so it runs where TCP doesn't (Workers, edge functions). Each call is one fetch, two the first time a Lua script runs. experimental_upstashRedis({ url, token }) is the Redis service underneath, for anything else built on it (experimental_redisLocker()); its subscribe fails, since Upstash's REST API has no pub/sub.
import { import LayerLayer } from 'effect';
import { import RateLimiterRateLimiter } from 'effect/persistence';
import { function experimental_upstashRateLimiterStore(options: experimental_UpstashRateLimiterStoreOptions): Layer.Layer<RateLimiter.RateLimiterStore>experimental_upstashRateLimiterStore } from '@trpc12/trpc/middleware';
createFetchHandler<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}, undefined>(opts: FetchHandlerOptions<{
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}, undefined>): FetchHandler<undefined>
createFetchHandler({
BaseHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.router: {
health: Procedure<{
type: "query";
ctx: {};
meta: {};
input: undefined;
output: string;
errors: never;
services: never;
}>;
}
router,
layer?: Layer.Layer<never, any, never> | undefinedlayer: import RateLimiterRateLimiter.const layer: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>layer.Pipeable.pipe<Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>, Layer.Layer<RateLimiter.RateLimiter, never, never>>(this: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>, ab: (_: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>) => Layer.Layer<RateLimiter.RateLimiter, never, never>): Layer.Layer<RateLimiter.RateLimiter, never, never> (+21 overloads)pipe(
import LayerLayer.const provide: <never, never, RateLimiter.RateLimiterStore>(that: Layer.Layer<RateLimiter.RateLimiterStore, never, never>) => <RIn2, E2, ROut2>(self: Layer.Layer<ROut2, E2, RIn2>) => Layer.Layer<ROut2, E2, Exclude<RIn2, RateLimiter.RateLimiterStore>> (+3 overloads)provide(
function experimental_upstashRateLimiterStore(options: experimental_UpstashRateLimiterStoreOptions): Layer.Layer<RateLimiter.RateLimiterStore>experimental_upstashRateLimiterStore({
experimental_UpstashOptions.url: stringurl: const env: {
UPSTASH_REDIS_REST_URL: string;
UPSTASH_REDIS_REST_TOKEN: string;
}
env.type UPSTASH_REDIS_REST_URL: stringUPSTASH_REDIS_REST_URL,
experimental_UpstashOptions.token: stringtoken: const env: {
UPSTASH_REDIS_REST_URL: string;
UPSTASH_REDIS_REST_TOKEN: string;
}
env.type UPSTASH_REDIS_REST_TOKEN: stringUPSTASH_REDIS_REST_TOKEN,
}),
),
),
});Cloudflare
experimental_durableRateLimiterStore(env.RATE_LIMITS) counts each key in its own Durable Object, with the object's clock and storage, so a limit holds across Worker instances and evictions, and each call can pass its own limit, window and algorithm. It's one subrequest per call. The object delegates to experimental_durableRateLimitObject(this.ctx), whose alarm deletes the counters once they expire (Cloudflare).
Cloudflare's Rate Limiting binding (env.LIMITER.limit({ key })) isn't a store. Its limit and period (10 or 60 seconds) are fixed in wrangler.jsonc, it counts per Cloudflare location and settles eventually, and it answers only { success }. So it can't take experimental_rateLimit()'s limit and window, run a token bucket, or fill in RateLimit-Remaining and Retry-After. To use it, call it from your own middleware and fail with experimental_RateLimited.
On the client
experimental_RateLimited is a typed error, so the client narrows on its code. retryLink({ retry, delayMs }) gets the error, so it can wait error.data.retryAfter (retryLink).
Credits
experimental_rateLimit() follows oRPC's rate limit helpers.