Skip to content

Commit 6b670fc

Browse files
feat: adds shared rate limiting to the relay using Redis (#3796)
Signed-off-by: Simeon Nakov <[email protected]> Co-authored-by: konstantinabl <[email protected]>
1 parent bd078cf commit 6b670fc

File tree

21 files changed

+1513
-406
lines changed

21 files changed

+1513
-406
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ Note: Read more about `DEV_MODE` which provides optimal local and developer test
8080

8181
The following table highlights some initial configuration values to consider
8282

83-
| Config | Default | Description |
84-
| ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85-
| `CHAIN_ID` | `0x12a` | The network chain id. Local and previewnet envs should use `0x12a` (298). Previewnet, Testnet and Mainnet should use `0x129` (297), `0x128` (296) and `0x127` (295) respectively |
86-
| `HEDERA_NETWORK` | `` | Which network to connect to. Automatically populates the main node & mirror node endpoints. Can be `MAINNET`, `PREVIEWNET`, `TESTNET` or `OTHER` |
83+
| Config | Default | Description |
84+
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
85+
| `CHAIN_ID` | `0x12a` | The network chain id. Local and previewnet envs should use `0x12a` (298). Previewnet, Testnet and Mainnet should use `0x129` (297), `0x128` (296) and `0x127` (295) respectively |
86+
| `HEDERA_NETWORK` | `` | Which network to connect to. Automatically populates the main node & mirror node endpoints. Can be `MAINNET`, `PREVIEWNET`, `TESTNET` or `OTHER` |
8787
| `MIRROR_NODE_URL` | `` | The Mirror Node API endpoint. Official endpoints are Previewnet (https://previewnet.mirrornode.hedera.com), Testnet (https://testnet.mirrornode.hedera.com), Mainnet (https://mainnet-public.mirrornode.hedera.com). See [Mirror Node REST API](https://docs.hedera.com/hedera/sdks-and-apis/rest-api) |
8888

8989
#### Run
@@ -258,13 +258,15 @@ The hedera-json-rpc-relay ships with a metrics endpoint at `/metrics`. Here is a
258258
Please note that the `/metrics` endpoint is also a default scrape configurations for prometheus. The `job_name` of `kubernetes-pods` is generally deployed as a default with prometheus; in the case where this scrape_config is present metrics will start getting populated by that scrape_config and no other configurations are necessary.
259259

260260
##### Dashboard
261+
261262
[Grafana JSON Dashboards](https://github.com/hiero-ledger/hiero-json-rpc-relay/tree/main/charts/hedera-json-rpc-relay/dashboards) can be used as the dashboard for hedera-json-rpc-relay.
262263

263264
##### Admin-specific RPC methods
264265

265266
- GET `/config` - To provide more transparency and operational insight to the developers, the hiero-json-rpc-relay exposes all environment variables. Such information could aid in troubleshooting and understanding the context in which the relay is running.
266267

267268
Expected response:
269+
268270
```
269271
{
270272
"relay": {

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Unless you need to set a non-default value, it is recommended to only populate o
8888
| `MIRROR_NODE_URL_HEADER_X_API_KEY` | "" | Authentication for a `MIRROR_NODE_URL` that requires authentication via the `x-api-key` header. |
8989
| `MULTI_SET` | "false" | Switch between different implementation of setting multiple K/V pairs in the shared cache. True is mSet, false is pipeline |
9090
| `JUMBO_TX_ENABLED` | "true" | Controls how large transactions are handled during `eth_sendRawTransaction`. When set to `true`, transactions up to 128KB can be sent directly to consensus nodes without using Hedera File Service (HFS), as long as contract bytecode doesn't exceed 24KB. When set to `false`, all transactions containing contract deployments use the traditional HFS approach. This feature leverages the increased transaction size limit to simplify processing of standard Ethereum transactions. |
91+
| `IP_RATE_LIMIT_STORE` | null | Specifies the rate limit store to use for IP-based rate limiting: valid values are "LRU", "REDIS", with the possibility to be extended with a custom implementation (see [Store Selection](rate-limiting.md#store-selection)). If unset, falls back to Redis when `REDIS_ENABLED=true`, otherwise uses in-memory LRU. |
9192
| `REDIS_ENABLED` | "true" | Enable usage of Redis as shared cache |
9293
| `REDIS_RECONNECT_DELAY_MS` | "1000" | Sets the delay between reconnect retries from the Redis client in ms |
9394
| `REDIS_URL` | "redis://127.0.0.1:6379" | Sets the url for the Redis shared cache |

docs/rate-limiting.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
Rate-limiting middleware for Koa Json Rpc. Use to limit repeated requests to APIs and/or endpoints by IP.
44

5+
## How It Works
6+
7+
1. On each incoming request, the `IPRateLimiterService` constructs a key combining the client IP and method name (e.g., `ratelimit:{ip}:{method}`).
8+
2. It selects a rate limit store backend (LRU or Redis) based on the `IP_RATE_LIMIT_STORE` and `REDIS_ENABLED` environment variables with the possibility to be extended and use a custom store (more info below).
9+
3. The store's `incrementAndCheck(key, limit, duration)` method is invoked:
10+
- **LRU**: maintains counts in an in-memory map and resets them after the configured duration.
11+
- **Redis**: uses an atomic Lua script to `INCR` and set `EXPIRE`, ensuring cross-pod consistency.
12+
4. If the request count exceeds the limit, the service logs a warning, increments the Prometheus counter `rpc_relay_ip_rate_limit`, and `shouldRateLimit` returns `true` (blocking the request).
13+
5. Setting `RATE_LIMIT_DISABLED=true` bypasses all rate limiting checks globally.
14+
515
## Configuration
616

717
All rate-limiting options are exposed and can be configured from `.env` .
@@ -23,6 +33,37 @@ RATE_LIMIT_DISABLED = false;
2333
- **LIMIT_DURATION**: - reset limit duration. This creates a timestamp, which resets all limits, when it's reached. Default is to `60000` (1 minute).
2434
- **RATE_LIMIT_DISABLED**: - if set to `true` no rate limiting will be performed.
2535

36+
### Store Selection
37+
38+
You can configure which backend store to use for rate limiting via environment variables:
39+
40+
- **IP_RATE_LIMIT_STORE**: Specifies the rate limit store to use. Valid values:
41+
- "LRU": In-memory store.
42+
- "REDIS": Redis-based store.
43+
- Any other custom type if you have implemented a custom store and added it to the `RateLimitStoreType` enum.
44+
If not set, the relay will fall back to using Redis when `REDIS_ENABLED=true`, otherwise it uses LRU.
45+
- **REDIS_ENABLED**: If `true`, enables Redis-based rate limiting when `IP_RATE_LIMIT_STORE` is not explicitly set. Default: `false`.
46+
- **REDIS_URL**: The Redis connection URL (e.g. `redis://localhost:6379`). Required when using Redis store.
47+
48+
To extend with a custom store:
49+
50+
1. Implement a class that implements `RateLimitStore`.
51+
2. Add your custom store type string to the `RateLimitStoreType` array in `packages/relay/src/lib/types/rateLimiter.ts`.
52+
3. Start the relay with `IP_RATE_LIMIT_STORE=MyCustomStore`
53+
54+
```ts
55+
import { RateLimitStore } from '@hashgraph/json-rpc-relay/dist/lib/types';
56+
57+
class MyCustomStore implements RateLimitStore {
58+
constructor(options: MyOptions) {
59+
/* ... */
60+
}
61+
async incrementAndCheck(key: string, limit: number, durationMs: number): Promise<boolean> {
62+
// custom logic
63+
}
64+
}
65+
```
66+
2667
The following table highlights each relay endpoint and the TIER associated with it as dictated by [methodConfiguration.ts](/packages/server/src/koaJsonRpc/lib/methodConfiguration.ts)
2768

2869
| Method endpoint | Tier |

packages/config-service/src/services/globalConfig.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,12 @@ const _CONFIG = {
635635
required: false,
636636
defaultValue: true,
637637
},
638+
IP_RATE_LIMIT_STORE: {
639+
envName: 'IP_RATE_LIMIT_STORE',
640+
type: 'string',
641+
required: false,
642+
defaultValue: null,
643+
},
638644
REDIS_RECONNECT_DELAY_MS: {
639645
envName: 'REDIS_RECONNECT_DELAY_MS',
640646
type: 'number',

packages/server/src/koaJsonRpc/lib/methodConfiguration.ts renamed to packages/relay/src/lib/config/methodConfiguration.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,14 @@
22

33
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
44

5+
import { MethodRateLimitConfiguration } from '../types';
6+
57
const tier1rateLimit = ConfigService.get('TIER_1_RATE_LIMIT');
68
const tier2rateLimit = ConfigService.get('TIER_2_RATE_LIMIT');
79
const tier3rateLimit = ConfigService.get('TIER_3_RATE_LIMIT');
810

9-
export interface IMethodRateLimit {
10-
total: number;
11-
}
12-
13-
export interface IMethodRateLimitConfiguration {
14-
[method: string]: IMethodRateLimit;
15-
}
16-
1711
// total requests per rate limit duration (default ex. 200 request per 60000ms)
18-
export const methodConfiguration: IMethodRateLimitConfiguration = {
12+
export const methodConfiguration: MethodRateLimitConfiguration = {
1913
web3_clientVersion: {
2014
total: tier3rateLimit,
2115
},

packages/relay/src/lib/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,5 @@ export default {
273273
ETH_GET_TRANSACTION_COUNT: 'eth_getTransactionCount',
274274
ETH_GET_TRANSACTION_RECEIPT: 'eth_GetTransactionReceipt',
275275
ETH_SEND_RAW_TRANSACTION: 'eth_sendRawTransaction',
276-
277276
NON_CACHABLE_BLOCK_PARAMS: 'latest|pending|finalized|safe',
278277
};

packages/relay/src/lib/hbarlimiter/index.ts

Whitespace-only changes.

packages/relay/src/lib/services/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export * from './ethService/blockService/IBlockService';
1111
export * from './ethService/contractService/ContractService';
1212
export * from './ethService/contractService/IContractService';
1313
export * from './ethService/transactionService/TransactionService';
14+
export * from '../types/rateLimiter';
15+
export * from './rateLimiterService/LruRateLimitStore';
16+
export * from './rateLimiterService/RedisRateLimitStore';
17+
export * from './rateLimiterService/rateLimiterService';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { RateLimitKey, RateLimitStore } from '../../types';
4+
5+
interface DatabaseEntry {
6+
reset: number;
7+
methodInfo: any;
8+
}
9+
10+
interface MethodDatabase {
11+
methodName: string;
12+
remaining: number;
13+
total: number;
14+
}
15+
16+
/**
17+
* LRU-based in-memory rate limit store.
18+
* Tracks request counts per IP and method within a time window.
19+
*/
20+
export class LruRateLimitStore implements RateLimitStore {
21+
private database: any;
22+
private duration: number;
23+
24+
/**
25+
* Initializes the store with the specified duration window.
26+
* @param duration - Time window in milliseconds for rate limiting.
27+
*/
28+
constructor(duration: number) {
29+
this.database = Object.create(null);
30+
this.duration = duration;
31+
}
32+
33+
/**
34+
* Increments the request count for a given IP and method, checking if the limit is exceeded.
35+
* @param key - The rate limit key containing IP and method information.
36+
* @param limit - Maximum allowed requests in the current window.
37+
* @returns True if rate limit exceeded, false otherwise.
38+
*/
39+
async incrementAndCheck(key: RateLimitKey, limit: number): Promise<boolean> {
40+
const { ip, method } = key;
41+
42+
this.precheck(ip, method, limit);
43+
if (!this.shouldReset(ip)) {
44+
if (this.checkRemaining(ip, method)) {
45+
this.decreaseRemaining(ip, method);
46+
return false;
47+
}
48+
return true;
49+
} else {
50+
this.reset(ip, method, limit);
51+
this.decreaseRemaining(ip, method);
52+
return false;
53+
}
54+
}
55+
56+
/**
57+
* Ensures the IP and method are initialized in the database.
58+
* @param ip - The IP address to check.
59+
* @param methodName - The method name to check.
60+
* @param total - The total number of allowed requests.
61+
*/
62+
private precheck(ip: string, methodName: string, total: number): void {
63+
if (!this.checkIpExist(ip)) {
64+
this.setNewIp(ip);
65+
}
66+
67+
if (!this.checkMethodExist(ip, methodName)) {
68+
this.setNewMethod(ip, methodName, total);
69+
}
70+
}
71+
72+
/**
73+
* Initializes a new IP entry in the database.
74+
* @param ip - The IP address to initialize.
75+
*/
76+
private setNewIp(ip: string): void {
77+
const entry: DatabaseEntry = {
78+
reset: Date.now() + this.duration,
79+
methodInfo: {},
80+
};
81+
this.database[ip] = entry;
82+
}
83+
84+
/**
85+
* Initializes a new method entry for a given IP in the database.
86+
* @param ip - The IP address associated with the method.
87+
* @param methodName - The method name to initialize.
88+
* @param total - The total number of allowed requests.
89+
*/
90+
private setNewMethod(ip: string, methodName: string, total: number): void {
91+
const entry: MethodDatabase = {
92+
methodName: methodName,
93+
remaining: total,
94+
total: total,
95+
};
96+
this.database[ip].methodInfo[methodName] = entry;
97+
}
98+
99+
/**
100+
* Checks if an IP exists in the database.
101+
* @param ip - The IP address to check.
102+
* @returns True if the IP exists, false otherwise.
103+
*/
104+
private checkIpExist(ip: string): boolean {
105+
return this.database[ip] !== undefined;
106+
}
107+
108+
/**
109+
* Checks if a method exists for a given IP in the database.
110+
* @param ip - The IP address associated with the method.
111+
* @param method - The method name to check.
112+
* @returns True if the method exists, false otherwise.
113+
*/
114+
private checkMethodExist(ip: string, method: string): boolean {
115+
return this.database[ip].methodInfo[method] !== undefined;
116+
}
117+
118+
/**
119+
* Checks if there are remaining requests for a given IP and method.
120+
* @param ip - The IP address associated with the method.
121+
* @param methodName - The method name to check.
122+
* @returns True if there are remaining requests, false otherwise.
123+
*/
124+
private checkRemaining(ip: string, methodName: string): boolean {
125+
return this.database[ip].methodInfo[methodName].remaining > 0;
126+
}
127+
128+
/**
129+
* Determines if the rate limit should be reset for a given IP.
130+
* @param ip - The IP address to check.
131+
* @returns True if the rate limit should be reset, false otherwise.
132+
*/
133+
private shouldReset(ip: string): boolean {
134+
return this.database[ip].reset < Date.now();
135+
}
136+
137+
/**
138+
* Resets the rate limit for a given IP and method.
139+
* @param ip - The IP address associated with the method.
140+
* @param methodName - The method name to reset.
141+
* @param total - The total number of allowed requests.
142+
*/
143+
private reset(ip: string, methodName: string, total: number): void {
144+
this.database[ip].reset = Date.now() + this.duration;
145+
for (const [keyMethod] of Object.entries(this.database[ip].methodInfo)) {
146+
this.database[ip].methodInfo[keyMethod].remaining = this.database[ip].methodInfo[keyMethod].total;
147+
}
148+
// Ensure the current method being checked is reset with the potentially new total (limit)
149+
this.database[ip].methodInfo[methodName].remaining = total;
150+
this.database[ip].methodInfo[methodName].total = total; // also update total if it changed
151+
}
152+
153+
/**
154+
* Decreases the remaining request count for a given IP and method.
155+
* @param ip - The IP address associated with the method.
156+
* @param methodName - The method name to decrease the count for.
157+
*/
158+
private decreaseRemaining(ip: string, methodName: string): void {
159+
const currentRemaining = this.database[ip].methodInfo[methodName].remaining;
160+
this.database[ip].methodInfo[methodName].remaining = currentRemaining > 0 ? currentRemaining - 1 : 0;
161+
}
162+
}

0 commit comments

Comments
 (0)