mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
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:
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
createCaller,
|
||||
createTRPCContext,
|
||||
} from './api'
|
||||
export { createRateLimitMiddleware } from './api/lib/rate-limiter'
|
||||
export type {
|
||||
Activity,
|
||||
ApiInput,
|
||||
|
||||
Reference in New Issue
Block a user