Reopen the rate-limit PR (#1058)

* feat: draft rate limit implementation

* feat: implement rate limiting for proxy APIs

* feat: Implement category-based rate limiting for ETH RPC procedures, update market proxy limits, and add rate limiter tests.

* refactor: enhance rate limiter with type safety and context handling, update middleware integration in TRPC

* feat: add clearRateLimitCache function and enhance rate limiter tests for improved functionality and isolation

* fix: reduce market rate limit for testing

* refactor: improve rate limiter IP handling and update market rate limit configuration

* feat: implement global rate limiting

* feat: add host-based rate limiting tests for improved isolation and functionality

* feat: enhance rate limiter tests with additional scenarios for blocking and handling requests

* refactor: enhance rate limiter with debug logging and improved error handling for rate limit exceeded

* chore: remove obsolete rate limiter tests to streamline codebase

* chore: remove wallet dependency to streamline package management

* chore: remove wallet dependency and add deprecation notices for walletconnect packages

* feat: add optional path property to RateLimitMiddlewareOptions interface
This commit is contained in:
Jinho Jang
2026-02-27 22:03:05 +09:00
committed by GitHub
parent 720fd5a650
commit 28bbe5422f
9 changed files with 268 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@status-im/wallet': patch
---
feat: implement rate limit for proxy APIs
@@ -0,0 +1,114 @@
import { TRPCError } from '@trpc/server'
/**
* Rate limiting configuration
*/
type RateLimitContext = {
headers: {
get(name: string): string | null | undefined
}
}
export interface RateLimitMiddlewareOptions<TNextResult = Promise<unknown>> {
ctx: RateLimitContext
next: () => TNextResult
path?: string
}
export interface RateLimitOptions<TOpts extends RateLimitMiddlewareOptions> {
windowMs: number
maxRequests: number | ((opts: TOpts) => number)
message?: string
keyPrefix?: string
getCategory?: (opts: TOpts) => string | undefined
getKey?: (opts: TOpts) => string
}
const requestCounts = new Map<string, { count: number; resetTime: number }>()
let lastPruneAt = 0
const PRUNE_INTERVAL_MS = 60 * 1000
export const clearRateLimitCache = () => {
requestCounts.clear()
lastPruneAt = 0
}
/**
* Higher-order function to create a rate limiting middleware
*/
export const createRateLimitMiddleware = <
TOpts extends RateLimitMiddlewareOptions<TNextResult>,
TNextResult extends Promise<unknown>,
TMiddlewareReturn,
>(
trpc: {
middleware: (fn: (opts: TOpts) => TNextResult) => TMiddlewareReturn
},
options: RateLimitOptions<TOpts>,
) => {
const {
windowMs,
maxRequests,
message = 'Rate limit exceeded. Please try again later.',
keyPrefix = 'default',
getCategory,
getKey,
} = options
return trpc.middleware((opts: TOpts) => {
const { ctx, next } = opts
const now = Date.now()
// Use custom key function if provided, otherwise default to IP-based key
let key: string
if (getKey) {
key = `${keyPrefix}:${getKey(opts)}`
} else {
const ip =
ctx.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const category = getCategory ? getCategory(opts) : undefined
key = `${keyPrefix}:${category ? category + ':' : ''}${ip}`
}
let record = requestCounts.get(key)
if (now - lastPruneAt >= PRUNE_INTERVAL_MS) {
for (const [storedKey, storedRecord] of requestCounts) {
if (now > storedRecord.resetTime) {
requestCounts.delete(storedKey)
}
}
lastPruneAt = now
}
if (!record || now > record.resetTime) {
record = { count: 0, resetTime: now + windowMs }
}
record.count++
requestCounts.set(key, record)
const limit =
typeof maxRequests === 'function' ? maxRequests(opts) : maxRequests
// Debug logging
// console.log('[Rate Limiter]', {
// key,
// count: record.count,
// limit,
// timeUntilReset: Math.round((record.resetTime - now) / 1000) + 's',
// })
if (record.count > limit) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message,
})
}
return next()
})
}
+128 -22
View File
@@ -2,6 +2,8 @@ import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import { createRateLimitMiddleware } from './rate-limiter'
/**
* 1. CONTEXT
*
@@ -29,7 +31,7 @@ type ApiContext = Awaited<ReturnType<typeof createTRPCContext>>
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<ApiContext>().create({
const trpc = initTRPC.context<ApiContext>().create({
transformer: superjson,
isServer: true,
allowOutsideOfServer: false,
@@ -50,44 +52,148 @@ const t = initTRPC.context<ApiContext>().create({
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const { createCallerFactory } = t
export const { createCallerFactory } = trpc
/**
* 3. ROUTER & PROCEDURES
*
* @see https://trpc.io/docs/router
*/
export const router = t.router
export const router = trpc.router
const errorMiddleware = t.middleware(async opts => {
/**
* Rate limiting for Market Proxy
*
* RATIONALE:
* - Aligned with the Market Proxy's CoinGecko API rate limit (30 RPM for Demo/NoKey).
* - Reference: https://github.com/status-im/market-proxy/blob/master/market-fetcher/config.yaml#L14-L19
*/
const marketRateLimitMiddleware = createRateLimitMiddleware(trpc, {
windowMs: 60 * 1000, // 1 minute
maxRequests: 30, // 30 requests per minute
keyPrefix: 'market',
message: 'Market data rate limit exceeded. Please try again in a minute.',
})
/**
* RPC method categorization based on eth-rpc-proxy specs:
* - permanent: Immutable data (blocks, receipts)
* - short: Semi-static data (balances, calls)
* - minimal: Highly dynamic data (gas prices, fees, nonces)
*/
const RPC_METHOD_CATEGORY_MAP: Record<
string,
'permanent' | 'short' | 'minimal'
> = {
// Permanent: Immutable data (60 RPM)
eth_getBlockByNumber: 'permanent',
eth_getTransactionReceipt: 'permanent',
alchemy_getAssetTransfers: 'permanent',
'assets.nativeTokenBalanceChart': 'permanent',
'assets.tokenBalanceChart': 'permanent',
'activities.page': 'permanent',
'activities.activities': 'permanent',
'collectibles.page': 'permanent',
'collectibles.collectible': 'permanent',
// Short: Semi-static data (30 RPM)
eth_getBalance: 'short',
'nodes.getNonce': 'short',
eth_getTransactionCount: 'short',
eth_feeHistory: 'short',
alchemy_getTokenBalances: 'short',
'assets.all': 'short',
'assets.nativeToken': 'short',
'assets.token': 'short',
'assets.nativeTokenPriceChart': 'short',
'assets.tokenPriceChart': 'short',
// Minimal: Highly dynamic data (15 RPM)
eth_estimateGas: 'minimal',
eth_maxPriorityFeePerGas: 'minimal',
eth_blockNumber: 'minimal',
'nodes.getFeeRate': 'minimal',
'nodes.broadcastTransaction': 'minimal',
eth_sendRawTransaction: 'minimal',
}
/**
* Category-specific limits (RPM)
* - permanent: Highly cacheable, higher limit allowed as hits likely won't reach provider.
* - short: Standard semi-static data.
* - minimal: Highly dynamic data, more restrictive to protect provider capacity.
*/
const RPC_CATEGORY_LIMITS: Record<string, number> = {
permanent: 60,
short: 30,
minimal: 15,
}
/**
* Rate limiting for ETH RPC Proxy (Alchemy)
*
* RATIONALE:
* - Aligned with the Alchemy Tier (30 RPM total for free bucket, but split by category).
* - Reference: https://github.com/status-im/eth-rpc-proxy/blob/master/nginx-proxy/cache.md#yaml-configuration-system
*/
const ethRPCRateLimitMiddleware = createRateLimitMiddleware(trpc, {
windowMs: 60 * 1000, // 1 minute
maxRequests: opts => {
const category = RPC_METHOD_CATEGORY_MAP[opts.path] ?? 'short'
return RPC_CATEGORY_LIMITS[category]
},
keyPrefix: 'eth-rpc',
getCategory: opts => RPC_METHOD_CATEGORY_MAP[opts.path] ?? 'short',
message: 'RPC request rate limit exceeded. Please try again in a minute.',
})
/**
* Global rate limiting for all API requests
*
* RATIONALE:
* - Protects the API from excessive requests to any domain
* - Keys by host header to limit per domain (regardless of IP address)
* - Fixed window: 60 seconds, 3000 requests per host
*/
const globalHostRateLimitMiddleware = createRateLimitMiddleware(trpc, {
windowMs: 60 * 1000, // 1 minute
maxRequests: 3000, // 3000 requests per minute per host
keyPrefix: 'global-host',
getKey: opts => {
const host = opts.ctx.headers.get('host') || 'unknown'
return host
},
message:
'Too many requests to this API domain. Please try again in a minute.',
})
const errorMiddleware = trpc.middleware(async opts => {
const result = await opts.next()
if (!result.ok && result.error) {
const error = result.error.cause
if (error instanceof Error && error.cause === 429) {
const error = result.error
if (error.cause instanceof Error && error.cause.cause === 429) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: error.message,
cause: error,
message:
'Provider rate limit exceeded. Please try again in a few moments.',
cause: error.cause,
})
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: error?.message || 'Unknown error',
cause: error,
})
throw result.error
}
return result
})
/**
* Unauthenticated procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(errorMiddleware)
// Unauthenticated procedure (Standard) with global host-based rate limiting
export const publicProcedure = trpc.procedure
.use(globalHostRateLimitMiddleware)
.use(errorMiddleware)
// Procedure for Market data endpoints
export const marketProcedure = publicProcedure.use(marketRateLimitMiddleware)
// Procedure for Node/RPC endpoints
export const ethRPCProcedure = publicProcedure.use(ethRPCRateLimitMiddleware)
@@ -7,7 +7,7 @@ import erc20TokenList from '../../../constants/erc20.json'
import { groupBy } from '../../../utils/group-by'
import { getAssetTransfers, getTransactionStatus } from '../../services/alchemy'
import { fetchTokensPriceForDate } from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import { ethRPCProcedure, router } from '../lib/trpc'
import type { AssetTransfer } from '../../services/alchemy/types'
import type { NetworkType } from '../types'
@@ -93,7 +93,7 @@ async function runWithConcurrency<T>(
}
export const activitiesRouter = router({
page: publicProcedure
page: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -107,7 +107,7 @@ export const activitiesRouter = router({
return await cachedPage(key)
}),
activities: publicProcedure
activities: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -17,7 +17,7 @@ import {
fetchTokenPriceHistory,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import { ethRPCProcedure, router } from '../lib/trpc'
import type {
CoinGeckoCoinDetailResponse,
@@ -195,7 +195,7 @@ async function fetchTokenData(
}
export const assetsRouter = router({
all: publicProcedure
all: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -216,7 +216,7 @@ export const assetsRouter = router({
return await cachedAll(inputHash)
}),
nativeToken: publicProcedure
nativeToken: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -240,7 +240,7 @@ export const assetsRouter = router({
return await cachedNativeToken(inputHash)
}),
token: publicProcedure
token: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -264,7 +264,7 @@ export const assetsRouter = router({
return await cachedToken(inputHash)
}),
nativeTokenPriceChart: publicProcedure
nativeTokenPriceChart: ethRPCProcedure
.input(
z.object({
symbol: z.string(),
@@ -276,7 +276,7 @@ export const assetsRouter = router({
return await cachedNativeTokenPriceChart(inputHash)
}),
tokenPriceChart: publicProcedure
tokenPriceChart: ethRPCProcedure
.input(
z.object({
symbol: z.string(),
@@ -288,7 +288,7 @@ export const assetsRouter = router({
return await cachedTokenPriceChart(inputHash)
}),
nativeTokenBalanceChart: publicProcedure
nativeTokenBalanceChart: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -301,7 +301,7 @@ export const assetsRouter = router({
return await cachedNativeTokenBalanceChart(inputHash)
}),
tokenBalanceChart: publicProcedure
tokenBalanceChart: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -19,7 +19,7 @@ import {
COINGECKO_REVALIDATION_TIMES,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import { ethRPCProcedure, router } from '../lib/trpc'
import type {
NFTMetadataResponseBody,
@@ -60,7 +60,7 @@ export type Collectible = {
}
export const collectiblesRouter = router({
page: publicProcedure
page: ethRPCProcedure
.input(
z.object({
address: z.string(),
@@ -81,7 +81,7 @@ export const collectiblesRouter = router({
const inputHash = JSON.stringify(input)
return await cachedPage(inputHash)
}),
collectible: publicProcedure
collectible: ethRPCProcedure
.input(
z.object({
contract: z.string(),
@@ -6,10 +6,10 @@ import {
COINGECKO_REVALIDATION_TIMES,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import { marketProcedure, router } from '../lib/trpc'
export const marketRouter = router({
tokenPrice: publicProcedure
tokenPrice: marketProcedure
.input(
z.object({
symbols: z.array(z.string()).min(1).max(100),
@@ -7,10 +7,10 @@ import {
getFeeRate,
getTransactionCount,
} from '../../services/alchemy'
import { publicProcedure, router } from '../lib/trpc'
import { ethRPCProcedure, router } from '../lib/trpc'
export const nodesRouter = router({
getFeeRate: publicProcedure
getFeeRate: ethRPCProcedure
.input(
z.object({
network: z.enum(['ethereum']).optional(),
@@ -27,7 +27,7 @@ export const nodesRouter = router({
return await cachedGetFeeRate(inputHash)
}),
broadcastTransaction: publicProcedure
broadcastTransaction: ethRPCProcedure
.input(
z.object({
txHex: z.string(),
@@ -41,7 +41,7 @@ export const nodesRouter = router({
)
}),
getNonce: publicProcedure
getNonce: ethRPCProcedure
.input(
z.object({
address: z.string(),
+1
View File
@@ -4,6 +4,7 @@ export {
createCaller,
createTRPCContext,
} from './api'
export { createRateLimitMiddleware } from './api/lib/rate-limiter'
export type {
Activity,
ApiInput,