Skip to content

Commit ee31045

Browse files
committed
feat: rework IPRateLimiter with Redis integration
Signed-off-by: Logan Nguyen <[email protected]>
1 parent 2e9648a commit ee31045

File tree

10 files changed

+382
-563
lines changed

10 files changed

+382
-563
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './ethService/contractService/IContractService';
1313
export * from './ethService/transactionService/TransactionService';
1414
export * from '../types/rateLimiter';
1515
export * from './rateLimiterService/LruRateLimitStore';
16+
export * from './rateLimiterService/RateLimitStoreFactory';
1617
export * from './rateLimiterService/RedisRateLimitStore';
1718
export * from './rateLimiterService/rateLimiterService';
1819
export * from './transactionPoolService/LocalPendingTransactionStorage';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { Logger } from 'pino';
4+
import { Counter } from 'prom-client';
5+
import { RedisClientType } from 'redis';
6+
7+
import { RateLimitStore } from '../../types';
8+
import { LruRateLimitStore } from './LruRateLimitStore';
9+
import { RedisRateLimitStore } from './RedisRateLimitStore';
10+
11+
/**
12+
* Factory for creating RateLimitStore instances.
13+
*
14+
* Encapsulates the logic for selecting the appropriate storage implementation
15+
* based on available infrastructure (Redis vs in-memory).
16+
*/
17+
export class RateLimitStoreFactory {
18+
/**
19+
* Creates a RateLimitStore instance.
20+
*
21+
* @param logger - Logger instance for the store.
22+
* @param duration - Time window in milliseconds for rate limiting.
23+
* @param rateLimitStoreFailureCounter - Optional counter for tracking store failures.
24+
* @param redisClient - Optional Redis client. If provided, creates Redis-backed storage;
25+
* otherwise creates local in-memory storage.
26+
* @returns A RateLimitStore implementation.
27+
*/
28+
static create(
29+
logger: Logger,
30+
duration: number,
31+
rateLimitStoreFailureCounter?: Counter,
32+
redisClient?: RedisClientType,
33+
): RateLimitStore {
34+
return redisClient
35+
? new RedisRateLimitStore(redisClient, logger, duration, rateLimitStoreFailureCounter)
36+
: new LruRateLimitStore(duration);
37+
}
38+
}
Lines changed: 19 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
// SPDX-License-Identifier: Apache-2.0
22

3-
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
43
import { Logger } from 'pino';
54
import { Counter } from 'prom-client';
6-
import { createClient, RedisClientType } from 'redis';
5+
import { RedisClientType } from 'redis';
76

8-
import { RedisCacheError } from '../../errors/RedisCacheError';
97
import { RateLimitKey, RateLimitStore } from '../../types';
108

119
/**
1210
* Redis-based rate limit store implementation using Lua scripting for atomic operations.
13-
* Implements both RateLimitStore for core functionality
11+
* Implements RateLimitStore for core functionality.
1412
*/
1513
export class RedisRateLimitStore implements RateLimitStore {
16-
private redisClient: RedisClientType;
17-
private logger: Logger;
18-
private connected: Promise<boolean>;
19-
private rateLimitStoreFailureCounter?: Counter;
14+
private readonly logger: Logger;
15+
private readonly rateLimitStoreFailureCounter?: Counter;
2016
private readonly duration: number;
2117

2218
/**
@@ -46,66 +42,23 @@ export class RedisRateLimitStore implements RateLimitStore {
4642
return 0
4743
`;
4844

49-
constructor(logger: Logger, duration: number, rateLimitStoreFailureCounter?: Counter) {
45+
/**
46+
* Creates a Redis-backed rate limit store.
47+
*
48+
* @param redisClient - A connected Redis client instance.
49+
* @param logger - Logger instance for logging.
50+
* @param duration - Time window in milliseconds for rate limiting.
51+
* @param rateLimitStoreFailureCounter - Optional counter for tracking store failures.
52+
*/
53+
constructor(
54+
private readonly redisClient: RedisClientType,
55+
logger: Logger,
56+
duration: number,
57+
rateLimitStoreFailureCounter?: Counter,
58+
) {
5059
this.logger = logger.child({ name: 'redis-rate-limit-store' });
5160
this.duration = duration;
5261
this.rateLimitStoreFailureCounter = rateLimitStoreFailureCounter;
53-
54-
const redisUrl = ConfigService.get('REDIS_URL')!;
55-
const reconnectDelay = ConfigService.get('REDIS_RECONNECT_DELAY_MS');
56-
57-
this.redisClient = createClient({
58-
url: redisUrl,
59-
socket: {
60-
reconnectStrategy: (retries: number) => {
61-
const delay = retries * reconnectDelay;
62-
this.logger.warn(`Rate limiter Redis reconnection attempt #${retries}. Delay: ${delay}ms`);
63-
return delay;
64-
},
65-
},
66-
});
67-
68-
this.connected = this.redisClient
69-
.connect()
70-
.then(() => true)
71-
.catch((error) => {
72-
this.logger.error(error, 'Rate limiter Redis connection could not be established!');
73-
return false;
74-
});
75-
76-
this.redisClient.on('ready', () => {
77-
this.connected = Promise.resolve(true);
78-
this.logger.info(`Rate limiter connected to Redis server successfully!`);
79-
});
80-
81-
this.redisClient.on('end', () => {
82-
this.connected = Promise.resolve(false);
83-
this.logger.info('Rate limiter disconnected from Redis server!');
84-
});
85-
86-
this.redisClient.on('error', (error) => {
87-
this.connected = Promise.resolve(false);
88-
const redisError = new RedisCacheError(error);
89-
if (redisError.isSocketClosed()) {
90-
this.logger.error(`Rate limiter Redis error when closing socket: ${redisError.message}`);
91-
} else {
92-
this.logger.error(`Rate limiter Redis error: ${redisError.fullError}`);
93-
}
94-
});
95-
}
96-
97-
/**
98-
* Ensures the Redis client is connected before use.
99-
* @private
100-
* @returns Connected Redis client instance.
101-
* @throws Error if the Redis client is not connected.
102-
*/
103-
private async getConnectedClient(): Promise<RedisClientType> {
104-
const isConnected = await this.connected;
105-
if (!isConnected) {
106-
throw new Error('Redis client is not connected');
107-
}
108-
return this.redisClient;
10962
}
11063

11164
/**
@@ -116,9 +69,8 @@ export class RedisRateLimitStore implements RateLimitStore {
11669
*/
11770
async incrementAndCheck(key: RateLimitKey, limit: number): Promise<boolean> {
11871
try {
119-
const client = await this.getConnectedClient();
12072
const durationSeconds = Math.ceil(this.duration / 1000);
121-
const result = await client.eval(RedisRateLimitStore.LUA_SCRIPT, {
73+
const result = await this.redisClient.eval(RedisRateLimitStore.LUA_SCRIPT, {
12274
keys: [key.toString()],
12375
arguments: [String(limit), String(durationSeconds)],
12476
});
@@ -138,24 +90,4 @@ export class RedisRateLimitStore implements RateLimitStore {
13890
return false;
13991
}
14092
}
141-
142-
/**
143-
* Checks if the Redis client is connected.
144-
*/
145-
async isConnected(): Promise<boolean> {
146-
return this.connected;
147-
}
148-
149-
/**
150-
* Disconnects from Redis.
151-
*/
152-
async disconnect(): Promise<void> {
153-
try {
154-
if (await this.isConnected()) {
155-
await this.redisClient.quit();
156-
}
157-
} catch (error) {
158-
this.logger.error(error, 'Error disconnecting from Redis');
159-
}
160-
}
16193
}

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

Lines changed: 10 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'
44
import { Logger } from 'pino';
55
import { Counter, Registry } from 'prom-client';
66

7-
import { RateLimitKey, RateLimitStore, RateLimitStoreType } from '../../types';
7+
import { RateLimitKey, RateLimitStore } from '../../types';
88
import { RequestDetails } from '../../types/RequestDetails';
9-
import { LruRateLimitStore } from './LruRateLimitStore';
10-
import { RedisRateLimitStore } from './RedisRateLimitStore';
119

1210
/**
1311
* Service to apply IP and method-based rate limiting using configurable stores.
@@ -16,9 +14,16 @@ export class IPRateLimiterService {
1614
private store: RateLimitStore;
1715
private logger: Logger;
1816
private ipRateLimitCounter: Counter;
19-
private rateLimitStoreFailureCounter: Counter;
2017

21-
constructor(logger: Logger, register: Registry, duration: number) {
18+
/**
19+
* Creates an IPRateLimiterService instance.
20+
*
21+
* @param store - The rate limit storage backend (LRU or Redis-backed).
22+
* @param logger - Logger instance for logging.
23+
* @param register - Prometheus registry for metrics.
24+
*/
25+
constructor(store: RateLimitStore, logger: Logger, register: Registry) {
26+
this.store = store;
2227
this.logger = logger;
2328

2429
// Initialize IP rate limit counter
@@ -32,68 +37,6 @@ export class IPRateLimiterService {
3237
labelNames: ['methodName', 'storeType'],
3338
registers: [register],
3439
});
35-
36-
// Initialize store failure counter
37-
const storeFailureMetricName = 'rpc_relay_rate_limit_store_failures';
38-
if (register.getSingleMetric(storeFailureMetricName)) {
39-
register.removeSingleMetric(storeFailureMetricName);
40-
}
41-
this.rateLimitStoreFailureCounter = new Counter({
42-
name: storeFailureMetricName,
43-
help: 'Rate limit store failure counter',
44-
labelNames: ['storeType', 'operation'],
45-
registers: [register],
46-
});
47-
48-
const storeType = this.determineStoreType();
49-
this.store = this.createStore(storeType, duration);
50-
}
51-
52-
/**
53-
* Determines which rate limit store type to use based on configuration.
54-
* Fails fast if an invalid store type is explicitly configured.
55-
* @private
56-
* @returns Store type identifier.
57-
* @throws Error if an invalid store type is explicitly configured.
58-
*/
59-
private determineStoreType(): RateLimitStoreType {
60-
const configuredStoreType = ConfigService.get('IP_RATE_LIMIT_STORE');
61-
62-
// If explicitly configured, validate it
63-
if (configuredStoreType !== null) {
64-
const normalizedType = String(configuredStoreType).trim().toUpperCase() as RateLimitStoreType;
65-
66-
if (Object.values(RateLimitStoreType).includes(normalizedType)) {
67-
this.logger.info(`Using configured rate limit store type: ${normalizedType}`);
68-
return normalizedType;
69-
}
70-
71-
// Fail fast for invalid configurations
72-
throw new Error(
73-
`Unsupported IP_RATE_LIMIT_STORE value: "${configuredStoreType}". ` +
74-
`Supported values are: ${Object.values(RateLimitStoreType).join(', ')}`,
75-
);
76-
}
77-
78-
// Only fall back to REDIS_ENABLED if IP_RATE_LIMIT_STORE is not set
79-
const fallbackType = ConfigService.get('REDIS_ENABLED') ? RateLimitStoreType.REDIS : RateLimitStoreType.LRU;
80-
this.logger.info(`IP_RATE_LIMIT_STORE not configured, using fallback based on REDIS_ENABLED: ${fallbackType}`);
81-
return fallbackType;
82-
}
83-
84-
/**
85-
* Creates an appropriate rate limit store instance based on the specified type.
86-
*/
87-
private createStore(storeType: RateLimitStoreType, duration: number): RateLimitStore {
88-
switch (storeType) {
89-
case RateLimitStoreType.REDIS:
90-
return new RedisRateLimitStore(this.logger, duration, this.rateLimitStoreFailureCounter);
91-
case RateLimitStoreType.LRU:
92-
return new LruRateLimitStore(duration);
93-
default:
94-
// This should never happen due to enum typing, but including for completeness
95-
throw new Error(`Unsupported store type: ${storeType}`);
96-
}
9740
}
9841

9942
/**
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import chai, { expect } from 'chai';
4+
import chaiAsPromised from 'chai-as-promised';
5+
import { Logger, pino } from 'pino';
6+
import { Counter, Registry } from 'prom-client';
7+
8+
import { LruRateLimitStore } from '../../../../src/lib/services/rateLimiterService/LruRateLimitStore';
9+
import { RateLimitStoreFactory } from '../../../../src/lib/services/rateLimiterService/RateLimitStoreFactory';
10+
import { RedisRateLimitStore } from '../../../../src/lib/services/rateLimiterService/RedisRateLimitStore';
11+
12+
chai.use(chaiAsPromised);
13+
14+
describe('RateLimitStoreFactory', () => {
15+
let logger: Logger;
16+
let registry: Registry;
17+
let rateLimitStoreFailureCounter: Counter;
18+
const testDuration = 5000;
19+
20+
beforeEach(() => {
21+
logger = pino({ level: 'silent' });
22+
registry = new Registry();
23+
rateLimitStoreFailureCounter = new Counter({
24+
name: 'test_rate_limit_store_failure',
25+
help: 'Test counter for rate limit store failures',
26+
labelNames: ['store_type', 'method'],
27+
registers: [registry],
28+
});
29+
});
30+
31+
describe('create', () => {
32+
it('should return LruRateLimitStore when redisClient is not provided', () => {
33+
const store = RateLimitStoreFactory.create(logger, testDuration);
34+
35+
expect(store).to.be.instanceOf(LruRateLimitStore);
36+
});
37+
38+
it('should return LruRateLimitStore when redisClient is undefined', () => {
39+
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, undefined);
40+
41+
expect(store).to.be.instanceOf(LruRateLimitStore);
42+
});
43+
44+
it('should return RedisRateLimitStore when redisClient is provided', () => {
45+
// Mock Redis client - just needs to be a truthy object for the factory logic
46+
const mockRedisClient = { eval: () => {} } as any;
47+
48+
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);
49+
50+
expect(store).to.be.instanceOf(RedisRateLimitStore);
51+
});
52+
53+
it('should return LruRateLimitStore without failure counter', () => {
54+
const store = RateLimitStoreFactory.create(logger, testDuration);
55+
56+
expect(store).to.be.instanceOf(LruRateLimitStore);
57+
});
58+
59+
it('should return RedisRateLimitStore with failure counter', () => {
60+
const mockRedisClient = { eval: () => {} } as any;
61+
62+
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);
63+
64+
expect(store).to.be.instanceOf(RedisRateLimitStore);
65+
});
66+
67+
it('should create different store instances on multiple calls', () => {
68+
const store1 = RateLimitStoreFactory.create(logger, testDuration);
69+
const store2 = RateLimitStoreFactory.create(logger, testDuration);
70+
71+
expect(store1).to.not.equal(store2);
72+
expect(store1).to.be.instanceOf(LruRateLimitStore);
73+
expect(store2).to.be.instanceOf(LruRateLimitStore);
74+
});
75+
76+
it('should create different Redis store instances on multiple calls', () => {
77+
const mockRedisClient = { eval: () => {} } as any;
78+
79+
const store1 = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);
80+
const store2 = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);
81+
82+
expect(store1).to.not.equal(store2);
83+
expect(store1).to.be.instanceOf(RedisRateLimitStore);
84+
expect(store2).to.be.instanceOf(RedisRateLimitStore);
85+
});
86+
});
87+
});

0 commit comments

Comments
 (0)